Skip to content

Imagen y PDF

Pablo Martínez López edited this page Oct 25, 2026 · 4 revisions

Imagen y PDF

English · Español

Todo el procesado de imagen y toda la escritura del PDF ocurren en Rust, en proxy-core. Una sola tubería, en un orden fijo, sirve tanto a las miniaturas de la pantalla como a las imágenes impresas, de modo que lo que ves es lo que se imprime. El módulo PDF dibuja un PagePlan (véase Motor de maquetación) y nunca calcula posiciones.

Código fuente: src-tauri/crates/proxy-core/src/imaging/ y src-tauri/crates/proxy-core/src/pdf/; el pegamento del lado de la app es src-tauri/src/render.rs y src-tauri/src/commands/export.rs (véase Exportación).

La tubería

imaging::pipeline::render(bytes, &RenderSpec) -> Result<Rendered, ImagingError>:

flowchart TD
    A["bytes (blob)"] --> PT{"passthrough?"}
    PT -->|"sí: JPEG, proporción correcta, sin retoques, sin sangrado, sin borde"| OUT0["Rendered::Passthrough (bytes originales)"]
    PT -->|no| B["decode.rs: decodifica a RGBA8 + orientación EXIF"]
    B --> C{"fillCorners?"}
    C -->|activado| C1["corners::fill"]
    C -->|desactivado| C2["corners::sobre_blanco"]
    C1 --> D["frame::fit: rota, caja de recorte, estira con Lanczos3 a los px exactos del corte"]
    C2 --> D
    D --> E["adjust::apply (11 retoques)"]
    E --> F["edges::apply (oscurecido de bordes opcional)"]
    F --> G["bleed::extend (b px en los 4 lados)"]
    G --> H["encode::jpeg / encode::png"]
    H --> OUT1["Rendered::Encoded {bytes, format, width, height}"]
Loading

La cabecera del módulo (imaging/mod.rs) fija el orden: decodificar, esquinas, encuadre (rotación, zoom, desplazamiento, remuestreo), retoques, oscurecido de bordes, sangrado, codificar.

RenderSpec (pipeline.rs)

Todo lo que cambia el resultado de un renderizado. Su key() es el SHA-256 (hex, 64 caracteres) de (PIPELINE_VERSION, spec) en JSON, de modo que especificaciones idénticas comparten un único renderizado y cualquier cambio da otra clave.

Campo Significado
blob, origin Imagen de origen en el almacén de blobs (Almacenamiento y blobs) y su ImageOrigin.
edits CardEdits { rotation, crop, adjust }.
edge Option<EdgeDarkening>: ya resuelto a None si no se aplica a esta imagen (véase edge_for).
card CardSize en mm: el tamaño de corte de la celda (las cartas grandes usan el suyo).
bleedMm, bleedMode Sangrado que añadir y cómo rellenarlo (clamp = repetir el píxel del borde, mirror).
fillCorners Pinta las esquinas redondeadas con el color del borde.
target SpecTarget::Dpi(f64) para el PDF o SpecTarget::Width(u32) (ancho del corte en px) para las miniaturas.
format, jpegQuality Codificación de salida.
diseno Option<DisenoRef { huella, cara, lang }>: la celda es una carta diseñada, pintada por el motor del diseñador en lugar de leerse del blob.

PIPELINE_VERSION (actualmente 5) forma parte de la clave: subirla invalida todos los renderizados cacheados (caché renders/) cuando cambia cualquier salida. Tests: misma_especificacion_misma_clave_y_cualquier_cambio_otra.

Por qué una sola tubería

Decisión: las miniaturas y las imágenes del PDF pasan por el mismo render; solo difiere target (Width frente a Dpi). Los radios de los retoques están en milímetros (px_por_mm), así que una miniatura de 488 px y una impresión a 600 dpi se ven igual (cabecera de adjust.rs).

Paso directo (passthrough)

Un JPEG se incrusta sin tocar cuando se cumple todo lo siguiente (passthrough()): sin sangrado, retoques de identidad, sin oscurecido de bordes, el objetivo es Dpi, el fichero es un JPEG (probe) y su proporción está a menos de un 0,5 % de la de la carta. Motivo: sin recompresión, PDF más pequeños y rápidos. Test: un_jpeg_63_88_sin_tocar_y_sin_sangrado_va_tal_cual.

Decodificación (decode.rs)

image::ImageReader con formato adivinado y límites de LADO_MAXIMO (12 000 px por lado, imaging/mod.rs); aplica la orientación EXIF; devuelve RGBA8. Errores: Unsupported, TooLarge, Corrupt(msg). probe.rs identifica JPEG/PNG/WebP y mide sin decodificar (imagesize); es lo que acepta el almacén de blobs.

Esquinas (corners.rs)

Los PNG de Scryfall tienen esquinas redondeadas transparentes y muchos escaneos las tienen blancas; sangrar o recortar con ellas dejaría triángulos claros. Dos comportamientos, elegidos por PrintSettings.fillCorners:

  • Apagado (valor de fábrica). sobre_blanco: los píxeles transparentes se componen sobre blanco, el color del papel.
  • Encendido. fill pinta con el color del borde (mediana por canal de los píxeles opacos en un anillo entre el 1 % y el 2,5 % del ancho desde el borde, color_de_borde). Pasos: (0) esquinas redondeadas con alfa: el radio se estima desde el primer píxel opaco de la diagonal, y todo lo que queda fuera del arco (más 1,5 px del borde suavizado) recibe el color del borde; (1) cualquier alfa parcial restante se compone sobre el color del borde; (2) en las imágenes opacas, relleno por inundación desde cada esquina dentro de una caja del 4 % del ancho, solo si la región inundada parece una esquina redondeada.

Trampa (protección del paso 2): la inundación se rechaza salvo que sea aproximadamente simétrica a lo largo de los dos bordes y cubra como máximo MAX_ESQUINA (30 %) de la caja. Sin la protección, una carta de arte completo con una franja oscura abajo inundaba toda la franja e imprimía un cuadrado del color del borde en cada esquina inferior.

Decisión: el valor de fábrica es «apagado» porque que las esquinas transparentes salgan del color del papel es lo que la mayoría espera al cortar, y fill solo merece la pena con sangrado.

Encuadre (frame.rs)

fit(img, rotation, crop, dst_w, dst_h): rota sin pérdida (rotar), calcula una caja de recorte (caja) y remuestrea con Lanczos3 (fast_image_resize, use_alpha(false)) directamente al tamaño exacto del corte.

  • trim_px(card, target) es la única función que calcula tamaños en píxeles: mm_to_px por eje para Dpi, width x height/width redondeado para Width. px_por_mm(card, trim_w) da los píxeles por mm para los radios de los retoques y el sangrado.
  • Decisión: la imagen se estira hasta el tamaño exacto de la celda, aunque se deforme un poco, y solo se recorta si su proporción difiere de la de la celda en más de MAX_ESTIRADO = 15 % (se_estira: |src_aspect/dst_aspect - 1| <= 0.15). Motivo: un escaneo de carta con una proporción ligeramente distinta perdería la línea del nombre o el pie con un recorte; un estiramiento de unos pocos puntos porcentuales es invisible, mientras que una foto apaisada estirada a vertical quedaría destrozada, así que por encima del 15 % se recorta para cubrir (la mayor caja con la proporción de la celda). El mismo estiramiento es lo que hace funcionar el tamaño de carta de 65 x 90 mm: una imagen de 63 x 88 lo llena con menos de un 1 % de deformación en vez de necesitar sangrado (véase Exportación).
  • Crop { zoom >= 1, offsetX, offsetY in [-1, 1] }: el zoom divide la caja; el desplazamiento mueve la caja dentro del margen que deja el zoom, de modo que nunca quedan franjas vacías (el_desplazamiento_no_deja_franjas_vacias).
  • Tests en frame.rs: una_proporcion_parecida_se_estira_entera, la_linea_del_nombre_no_se_pierde, una_imagen_ancha_se_recorta_por_los_lados, medidas_del_trim (63 x 88 mm a 600 dpi = 1488 x 2079 px; con ancho 488 = 488 x 682).

Las dos ilustraciones de una carta de dos mitades (mitades.rs)

Scryfall da un solo art_crop para las cartas con layout: "split" (partidas, Fusionar, Habitaciones y Secuelas), con las ilustraciones de las dos mitades una al lado de la otra, siempre de pie (la de la primera mitad a la izquierda), aunque en la carta vayan giradas. El diseñador quiere una ilustración por mitad, así que imaging::mitades (puro, sin red) corta esa imagen por la costura:

  • costura(img, esperada) -> u32: la columna donde empieza la segunda ilustración, buscada a ±3 % del ancho (VENTANA) alrededor de la fracción esperada. Para cada columna candidata toma, fila a fila, el salto de color con la de su izquierda (suma de las diferencias RGB) y se queda con la mediana de las filas; gana la columna de mayor mediana (en un empate, la más cercana a lo esperado). Por qué la mediana y no la suma: entre dos cuadros distintos salta casi cada fila, mientras que un borde fuerte dentro de un cuadro solo salta en unas pocas; con la suma, Commit // Memory se iba 12 px.
  • mitad(img, esperada, cual): la mitad 0 (izquierda) o 1 (derecha), sin unos píxeles a cada lado de la costura (max(w / 200, 2)), porque el JPEG la emborrona. Si no hay sitio para cortar, devuelve la imagen entera.
  • Constantes: COSTURA_PARTIDA = 0.5 (partidas, Fusionar, Habitaciones: dos mitades iguales) y COSTURA_SECUELA = 0.612 (Secuelas: la de arriba es más ancha; medido en Dusk // Dawn, Cut // Ribbons, Never // Return y Commit // Memory).

Lo llama diseno::arte::desde_scryfall de la app (véase Capa de aplicación del diseñador). Tests: la_costura_se_encuentra_aunque_no_este_justo_donde_se_espera, cada_mitad_lleva_solo_su_cuadro, una_imagen_minima_no_rompe.

Retoques (adjust.rs, blur.rs)

Once ajustes en model::Adjustments (brightness, exposure, contrast, saturation, warmth, vibrance de -100 a 100; sharpness, clarity, noiseReduction, vignette, fade de 0 a 100). Orden fijo: reducción de ruido, exposición, brillo, contraste, calidez, saturación, vibrance, claridad, nitidez, desvanecido, viñeta. Los ajustes de identidad devuelven de inmediato. Los desenfoques son una aproximación gaussiana de 3 pasadas de caja (blur.rs, coste constante por píxel con independencia del radio); los radios están en mm por px_mm.

Oscurecido de bordes (edges.rs)

Para escaneos viejos con bordes negros agrisados. EdgeDarkening { enabled, mode, amount, widthPct, autoDetect, applyScryfall, applyUploads, applyBacks }; modos ContrastEdges (escala el color por 1 - amount dentro de la zona del borde), SolidBlack, Threshold.

  • autoDetect: tiene_borde_oscuro muestrea un anillo exterior del 1,5 %; una luma media > 0,55 (borde claro) o una desviación típica > 0,12 (arte hasta el borde) significa «no tocar» (cartas de borde blanco, sin borde y de arte completo).
  • La zona se busca por fila/columna desde cada borde hacia dentro mientras los píxeles «parecen de borde» (luma < 0,40 y poca saturación), dentro de widthPct del ancho.
  • A qué imágenes se aplica lo decide pipeline::edge_for(settings, origin, is_back): nunca a ImageOrigin::Designed (esas ya llevan su marco); en los demás casos, según el origen (applyScryfall para los dos orígenes de Scryfall, applyUploads para las subidas, ninguno para las generadas), y para los reversos solo applyBacks.

Sangrado (bleed.rs)

extend(img, b, mode) añade b = round(bleedMm * px_mm) píxeles en los cuatro lados, repitiendo el píxel del borde (Clamp) o reflejando (Mirror), paralelizado con rayon. El renderizado siempre lleva el sangrado completo en los cuatro lados; el PDF (y la pantalla) lo recortan con el rectángulo bleed de cada casilla, que reduce el sangrado hacia las cartas vecinas (véase Motor de maquetación). A 600 dpi, 1 mm son 24 px (a_600_ppp_un_milimetro_son_24_px_por_lado).

Codificación (encode.rs)

jpeg(img, quality): RGB (se descarta el alfa, las esquinas ya están rellenas), calidad limitada a 1..=100, submuestreo 4:2:0 (lo que da image). png(img): RGBA, CompressionType::Fast, filtro adaptativo. Ambos son deterministas (mismos píxeles, mismos bytes), de lo que dependen las cachés de renderizado y los PDF idénticos byte a byte (tests el_png_es_determinista_y_sin_perdida, el_jpeg_es_determinista_y_pierde_el_alfa).

en_formato(img, FormatoImagen, calidad) escribe los formatos de una carta suelta (PNG, JPEG, WebP sin pérdida, TIFF, BMP); los que no llevan alfa (JPEG, BMP) reciben la carta sobre blanco. render_rgba es la tubería hasta los píxeles (lo que render ejecuta antes de codificar), y sirve para guardar una carta en cualquiera de esos formatos.

Cartas diseñadas: render_desde_imagen

Para una celda cuya carta tiene diseño, el motor del diseñador pinta la carta al tamaño de corte y dpi exactos (Salida::Impresion, véase Capa de aplicación del diseñador), y entonces solo se ejecuta el final de la tubería: render_desde_imagen hace el sangrado y la codificación, sin esquinas, encuadre, retoques ni oscurecido de bordes.

Pegamento del lado de la app (src-tauri/src/render.rs)

Función Papel
spec_de_casilla(slot, img, edits, settings) Construye el RenderSpec de impresión a partir de una Slot del plan: los retoques solo si slot.applyEdits; edge mediante edge_for (una SlotFace::Back es un reverso, una DfcFace no); card = el tamaño de corte de la casilla (así las cartas grandes usan el suyo); bleedMm = slot.bleedFullMm; target = Dpi(settings.quality.dpi()); formato y calidad de settings.encoding.
spec_de_miniatura(blob, img, params, width) Especificación de miniatura: Width(width), siempre JPEG calidad 85, retoques y sangrado de PreviewParams.
AppState::miniatura(blob, params, width) Renderiza una miniatura con caché en disco en app_cache_dir/renders/<key>.jpg. Borrar la caché no pierde nada (test la_miniatura_se_guarda_en_la_cache_y_se_sirve_de_ella).
render_para_pdf(state, spec) Renderizado de impresión, sin caché en disco (demasiado grande; test el_render_para_imprimir_no_deja_nada_en_la_cache).
AppState::leer_blob(hash) Lee los bytes de un blob, sondea su tamaño y lee su origen de la tabla blobs. Los hashes desconocidos o mal formados dan ErrorCode::NotFound.
impl From<ImagingError> for AppError Unsupported -> ErrorCode::Unsupported; TooLarge -> ErrorCode::Image con motivo=too_large; los demás -> ErrorCode::Image con detalle.

Decisión: las imágenes de los mazos viven en el almacén de blobs (son datos de los que depende el mazo), mientras que renders/ en el directorio de caché guarda solo miniaturas derivadas y se puede borrar en cualquier momento (Almacenamiento y blobs).

El PDF (pdf/)

Escrito con krilla (versión en crates/proxy-core/Cargo.toml, características raster-images y simple-text). pdf/mod.rs documenta cómo dibuja krilla, verificado contra su código: el origen de la Surface es arriba a la izquierda en puntos; draw_image(img, size) coloca la esquina superior izquierda de la imagen en el origen actual; un giro de 180 grados alrededor de un punto es Transform::from_rotate_at(180, cx, cy); un JPEG se incrusta tal cual con DCTDecode; las imágenes con contenido idéntico se escriben una sola vez.

compose (pdf/compose.rs)

pub fn compose(
    plan: &PagePlan, guides: &CutGuides, meta: &PdfMeta,
    imagen: impl FnMut(&Page, &Slot) -> Result<Option<Image>, PdfError>,
    pagina_hecha: impl FnMut(usize) -> bool,
) -> Result<Vec<u8>, PdfError>

Para cada Page del plan: inicia una página krilla de widthMm x heightMm (convertida con mm_to_pt), para cada casilla pide su imagen a la función de retrollamada (None = en blanco), llama a dibujar_casilla, luego a dibujar_lineas para las guías de la página, y termina. Tras cada página se llama a pagina_hecha(i); si devuelve false se aborta con PdfError::Cancelled (así funcionan la cancelación y el progreso de la exportación).

dibujar_casilla(surface, slot, image):

  1. Apila un trazado de recorte: el rectángulo bleed de la casilla (así los sangrados vecinos nunca se solapan).
  2. Traslada a (trim.x - bleedFullMm, trim.y - bleedFullMm) y dibuja la imagen a (trim.w + 2*bleedFullMm) x (trim.h + 2*bleedFullMm): la imagen lleva el sangrado completo en todos los lados; el recorte conserva solo lo que permite el plan.
  3. Desapila los dos estados.

dibujar_lineas: todos los segmentos de guía de una página como un único trazado con LineCap::Butt; el color sale de color_hex (#rrggbb; lo que no se pueda interpretar es negro); el grosor del trazo es css_px_to_pt(widthPx) con un mínimo de 0,05 pt.

documento(meta): sin metadatos XMP; el título sale del mazo; productor y creador «Proxy Builder»; document_id de PdfMeta. No hay fecha, así que el mismo mazo produce los mismos bytes (test dos_veces_lo_mismo_dan_los_mismos_bytes); la capa de exportación deriva document_id de un hash del plan y de las claves de las imágenes.

Errores (PdfError): Geometry, Image(msg), Write(msg), Cancelled. La app los convierte en commands/export.rs: Cancelled -> ErrorCode::Cancelled, los demás -> ErrorCode::Pdf con detalle.

DPI y tamaño de fichero

  • Los DPI son una propiedad del renderizado, no del PDF: Quality::{Dpi300, Dpi600 (por defecto), Dpi900} -> RenderSpec.target = Dpi(..). La página PDF está en puntos; la imagen se dibuja escalada al tamaño físico, así que los dpi efectivos = píxeles / pulgadas físicas.
  • pdf/estimate.rs bytes_por_imagen(card, dpi, bleed, format, quality) estima los bytes por imagen: número de píxeles (corte más sangrado) por un factor por píxel (0.15 + 0.30 * (quality/100)^3 para JPEG, 2.2 para PNG). commands/export.rs lo multiplica por el número de imágenes distintas para avisar antes de una exportación enorme (véase Exportación). Test: a_600_ppp_un_jpeg_95_ronda_el_megabyte.

Hoja de calibración (pdf/calibration.rs)

calibration(settings, CalibrationTexts) -> Vec<u8> escribe una hoja de alineación duplex de dos páginas (fuerza duplex por el borde largo si el ajuste está apagado, y desactiva el intercalado de DFC y las guías). Construye un mazo de cartas vacías que llena una hoja y ejecuta el plan normal, de modo que la geometría es exactamente la de impresión. Anverso: el contorno de cada celda, su etiqueta y dos reglas en mm (±5 mm) que se cruzan en el centro. Reverso: una cruz roja en el centro de cada celda, colocada con el mismo espejo, rotación y desplazamientos de fila que los reversos reales. Al sostener la hoja a contraluz por el anverso, el desplazamiento de la cruz respecto a las reglas es el desajuste de la impresora por fila. La letra de las etiquetas es Caladea (pdf/mod.rs LETRA, incluida en assets/fuentes/). El flujo de la interfaz (medir, «Corregir con lo medido») está en src/features/export/alinear-filas.tsx. Tests: dos_paginas_y_las_cruces_del_dorso_siguen_el_plan, el_desplazamiento_de_dorso_mueve_la_cruz_en_espejo.

Tests que protegen esta área

Fichero / nombre del test Qué protege
tests de imaging/pipeline.rs sensibilidad de la clave de caché, paso directo de JPEG, PNG con sangrado a 300 dpi da 768 x 1063, reglas de edge_for
tests de imaging/frame.rs estirar frente a recortar, línea del nombre conservada, límites de la caja de recorte, tamaños del corte
imaging/mitades.rs la costura se encuentra aunque no esté justo donde se espera, cada mitad lleva solo su cuadro, una imagen mínima no rompe
imaging/corners.rs, edges.rs, bleed.rs, encode.rs, decode.rs, probe.rs cada etapa
tests de pdf/compose.rs dos páginas A4 para 10 cartas, una sola imagen incrustada para copias idénticas (DCT), bytes deterministas, imágenes exactamente donde dice el plan, guías solo en los anversos
pdf/calibration.rs, pdf/estimate.rs hoja de calibración, estimación
tests de src-tauri/src/render.rs especificaciones a partir de casillas y vistas previas, comportamiento de la caché, conversión de errores

Por dónde empezar para ampliarlo

  • Nuevo retoque: añade el campo a model::Adjustments (+ is_identity), impleméntalo en adjust.rs en el orden fijo, sube PIPELINE_VERSION, ejecuta pnpm bindings y añade la interfaz en el editor de cartas (Diálogo Editar carta).
  • Todo lo que cambie los píxeles de un renderizado debe subir PIPELINE_VERSION, o sobrevivirán miniaturas cacheadas obsoletas.
  • No calcules posiciones en pdf/: añádelas a layout::plan.

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally