Repository navigation
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).
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}"]
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.
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.
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).
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.
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.
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.
fillpinta 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.
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_pxpor eje paraDpi,width x height/widthredondeado paraWidth.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).
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) yCOSTURA_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.
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.
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_oscuromuestrea 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
widthPctdel ancho. - A qué imágenes se aplica lo decide
pipeline::edge_for(settings, origin, is_back): nunca aImageOrigin::Designed(esas ya llevan su marco); en los demás casos, según el origen (applyScryfallpara los dos orígenes de Scryfall,applyUploadspara las subidas, ninguno para las generadas), y para los reversos soloapplyBacks.
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).
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.
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.
| 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).
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.
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):
- Apila un trazado de recorte: el rectángulo
bleedde la casilla (así los sangrados vecinos nunca se solapan). - 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. - 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.
- 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.rsbytes_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)^3para JPEG,2.2para PNG).commands/export.rslo 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.
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.
| 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 |
- Nuevo retoque: añade el campo a
model::Adjustments(+is_identity), impleméntalo enadjust.rsen el orden fijo, subePIPELINE_VERSION, ejecutapnpm bindingsy 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 alayout::plan.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia