Skip to content

Imagen y PDF

Pablo Martínez López edited this page Nov 10, 2026 · 7 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, sin sello"| 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 --> ST["sello::pintar (solo si RenderSpec.sello)"]
    ST --> 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. El sello de Tapete, cuando se pide, se pinta en la carta a su medida de corte después del oscurecido y antes del sangrado, así que nunca cae en lo que se corta (El sello de las cartas).

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.
sello Option<DesignLang>: pintar el sello de Tapete en este idioma (miniaturas de las hojas). None en el PDF, donde el sello es texto vectorial encima, y en todas las demás imágenes. Con None no se serializa (skip_serializing_if), así que una especificación sin sello conserva la clave que tenía antes de existir el campo.

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, sin sello, 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, es_dorso): 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 (y el sello, si sello está puesto), 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; sello: None (el PDF pone el sello encima, así que un JPEG puede seguir pasando tal cual).
spec_de_miniatura(blob, img, params, width) Especificación de miniatura: Width(width), siempre JPEG calidad 85, retoques, sangrado y sello 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,
    casilla: impl FnMut(&Page, &Slot) -> Result<Option<Casilla>, PdfError>,
    pagina_hecha: impl FnMut(usize) -> bool,
) -> Result<Vec<u8>, PdfError>

Casilla { imagen: Image, sello: Option<SelloCasilla> } es lo que va en una casilla: la imagen preparada y su sello (SelloCasilla { lang, colocacion }, que decide sello::colocar mirando la imagen; véase El sello de las cartas). PdfMeta { title, document_id, hoja }: hoja es el idioma de la línea del margen de cada hoja (el de la app), None solo en la hoja de calibración, que no lleva cartas.

Para cada Page del plan: inicia una página krilla de widthMm x heightMm (convertida con mm_to_pt), para cada casilla pide su Casilla a la función de retrollamada (None = en blanco), llama a dibujar_casilla y luego, si lo lleva, a dibujar_sello (el sello como texto vectorial sobre el corte de la carta, con una pastilla semitransparente si hace falta), después a dibujar_lineas para las guías de la página y, si meta.hoja está puesto, a dibujar_linea_hoja (la línea de la hoja, en el margen de abajo o el de arriba, pdf::sello::base_hoja), y termina. Las letras del sello se cargan una vez por documento (Letras) y krilla incrusta solo los glifos usados. 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 «Tapete» (el nombre visible de la app); 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, un sello por carta y una línea por hoja sin tocar la imagen, la línea de la hoja fuera de cartas y guías
tests de sello.rs, con_sello_ya_no_va_tal_cual_y_solo_cambia_la_franja_de_abajo el sello: textos y glifos, colocación, contraste, pastilla, que solo pinta su franja (véase El sello de las cartas)
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

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally