Repository navigation
Imaging and PDF
English · Español
All image processing and all PDF writing happens in Rust, in proxy-core. One pipeline, in a fixed order, serves both the on-screen thumbnails and the printed images, so what you see is what prints. The PDF module draws a PagePlan (see Layout Engine) and never computes positions.
Source: src-tauri/crates/proxy-core/src/imaging/ and src-tauri/crates/proxy-core/src/pdf/; the app-side glue is src-tauri/src/render.rs and src-tauri/src/commands/export.rs (see Export).
imaging::pipeline::render(bytes, &RenderSpec) -> Result<Rendered, ImagingError>:
flowchart TD
A["bytes (blob)"] --> PT{"passthrough?"}
PT -->|"yes: JPEG, right aspect, no edits, no bleed, no edge"| OUT0["Rendered::Passthrough (original bytes)"]
PT -->|no| B["decode.rs: decode to RGBA8 + EXIF orientation"]
B --> C{"fillCorners?"}
C -->|on| C1["corners::fill"]
C -->|off| C2["corners::sobre_blanco"]
C1 --> D["frame::fit: rotate, crop box, Lanczos3 stretch to exact trim px"]
C2 --> D
D --> E["adjust::apply (11 retouches)"]
E --> F["edges::apply (optional edge darkening)"]
F --> G["bleed::extend (b px on all 4 sides)"]
G --> H["encode::jpeg / encode::png"]
H --> OUT1["Rendered::Encoded {bytes, format, width, height}"]
The module header (imaging/mod.rs) states the order: decode, corners, framing (rotation, zoom, offset, resampling), retouches, edge darkening, bleed, encode.
Everything that changes the result of a render. Its key() is the SHA-256 (hex, 64 chars) of (PIPELINE_VERSION, spec) as JSON, so identical specs share one render and any change gives a different key.
| Field | Meaning |
|---|---|
blob, origin
|
Source image in the blob store (Storage and Blobs) and its ImageOrigin. |
edits |
CardEdits { rotation, crop, adjust }. |
edge |
Option<EdgeDarkening>: already resolved to None if it does not apply to this image (see edge_for). |
card |
CardSize in mm: the cell's trim size (large cards use their own). |
bleedMm, bleedMode
|
Bleed to add and how to fill it (clamp = repeat the edge pixel, mirror). |
fillCorners |
Paint rounded corners with the border colour. |
target |
SpecTarget::Dpi(f64) for the PDF or SpecTarget::Width(u32) (trim width in px) for thumbnails. |
format, jpegQuality
|
Output encoding. |
diseno |
Option<DisenoRef { huella, cara, lang }>: the cell is a designed card, painted by the designer engine instead of read from the blob. |
PIPELINE_VERSION (currently 5) is part of the key: bumping it invalidates every cached render (renders/ cache) when any output changes. Tests: misma_especificacion_misma_clave_y_cualquier_cambio_otra.
Decision: thumbnails and PDF images go through the same render; only target differs (Width vs Dpi). Retouch radii are in millimetres (px_por_mm), so a 488 px thumbnail and a 600 dpi print look the same (adjust.rs header).
A JPEG is embedded untouched when all hold (passthrough()): no bleed, identity edits, no edge darkening, target is Dpi, the file is a JPEG (probe), and its aspect ratio is within 0.5 % of the card's. Why: no re-compression, smaller and faster PDFs. Test: un_jpeg_63_88_sin_tocar_y_sin_sangrado_va_tal_cual.
image::ImageReader with guessed format and limits of LADO_MAXIMO (12 000 px per side, imaging/mod.rs); applies EXIF orientation; returns RGBA8. Errors: Unsupported, TooLarge, Corrupt(msg). probe.rs identifies JPEG/PNG/WebP and measures without decoding (imagesize); this is what the blob store accepts.
Scryfall PNGs have transparent rounded corners and many scans have white ones; bleeding or cropping them would leave light triangles. Two behaviours, selected by PrintSettings.fillCorners:
-
Off (factory default).
sobre_blanco: transparent pixels are composited over white, the colour of the paper. -
On.
fillpaints with the border colour (median per channel of opaque pixels in a ring between 1 % and 2.5 % of the width from the edge,color_de_borde). Steps: (0) rounded corners with alpha: radius estimated from the first opaque pixel on the diagonal, everything outside the arc (plus 1.5 px of the anti-aliased rim) gets the border colour; (1) any remaining partial alpha is composited over the border colour; (2) for opaque images, flood-fill from each corner inside a box of 4 % of the width, only if the flooded region looks like a rounded corner.
Gotcha (guard in step 2): the flood is rejected unless it is roughly symmetric along the two edges and covers at most MAX_ESQUINA (30 %) of the box. Without the guard, a full-art card with a dark strip at the bottom flooded the whole strip and printed a border-coloured square in each bottom corner.
Decision: factory default is "off" because transparent corners coming out as paper-coloured is what most people expect when cutting, and fill is only worth it with bleed.
fit(img, rotation, crop, dst_w, dst_h): rotate losslessly (rotar), compute a crop box (caja), resample with Lanczos3 (fast_image_resize, use_alpha(false)) straight to the exact trim size.
-
trim_px(card, target)is the only function that computes pixel sizes:mm_to_pxper axis forDpi,width x height/widthrounded forWidth.px_por_mm(card, trim_w)gives pixels per mm for retouch radii and bleed. - Decision: the image is stretched to the exact cell size, even if that distorts it slightly, and only cropped if its aspect ratio differs from the cell's by more than
MAX_ESTIRADO= 15 % (se_estira:|src_aspect/dst_aspect - 1| <= 0.15). Why: a card scan with a slightly different ratio would otherwise lose the name line or the footer to a crop; a stretch of a few percent is invisible, while a landscape photo stretched into portrait would be destroyed, so beyond 15 % it is cropped to cover (largest box with the cell's aspect ratio). The same stretch is what makes the 65 x 90 mm card size work: a 63 x 88 image fills it with less than 1 % of distortion instead of needing bleed (see Export). -
Crop { zoom >= 1, offsetX, offsetY in [-1, 1] }: zoom divides the box; the offset moves the box inside the margin the zoom leaves, so it never leaves empty strips (el_desplazamiento_no_deja_franjas_vacias). - Tests in
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 at 600 dpi = 1488 x 2079 px; at width 488 = 488 x 682).
Scryfall gives a single art_crop for cards with layout: "split" (split, Fuse, Room and aftermath cards), with both halves' illustrations side by side, always upright (the first half on the left), even though on the card they are rotated. The designer wants one artwork per half, so imaging::mitades (pure, no network) cuts that image at the seam:
-
costura(img, esperada) -> u32: the column where the second illustration starts, searched within ±3 % of the width (VENTANA) around the expected fraction. For each candidate column it takes, row by row, the colour jump against its left neighbour (sum of the RGB differences) and keeps the median over the rows; the column with the largest median wins (ties go to the one nearest the expected place). Why the median and not the sum: between two different pictures almost every row jumps, while a strong edge inside one picture only jumps on a few rows; with the sum, Commit // Memory missed the seam by 12 px. -
mitad(img, esperada, cual): half 0 (left) or 1 (right), leaving out a few pixels on each side of the seam (max(w / 200, 2)), because JPEG blurs it. With no room to cut, the whole image is returned. - Constants:
COSTURA_PARTIDA = 0.5(split, Fuse, Room: two equal halves) andCOSTURA_SECUELA = 0.612(aftermath: the top half is wider; measured on Dusk // Dawn, Cut // Ribbons, Never // Return and Commit // Memory).
The caller is the app's diseno::arte::desde_scryfall (see Designer-App-Layer). Tests: la_costura_se_encuentra_aunque_no_este_justo_donde_se_espera, cada_mitad_lleva_solo_su_cuadro, una_imagen_minima_no_rompe.
Eleven adjustments in model::Adjustments (brightness, exposure, contrast, saturation, warmth, vibrance in -100..100; sharpness, clarity, noiseReduction, vignette, fade in 0..100). Fixed order: noise reduction, exposure, brightness, contrast, warmth, saturation, vibrance, clarity, sharpness, fade, vignette. Identity adjustments return immediately. Blurs are a 3-pass box approximation of a gaussian (blur.rs, constant cost per pixel regardless of radius); radii are in mm times px_mm.
For old scans with greyish black borders. EdgeDarkening { enabled, mode, amount, widthPct, autoDetect, applyScryfall, applyUploads, applyBacks }; modes ContrastEdges (scale colour by 1 - amount inside the border zone), SolidBlack, Threshold.
-
autoDetect:tiene_borde_oscurosamples a 1.5 % outer ring; mean luma > 0.55 (light border) or standard deviation > 0.12 (art to the edge) means "do not touch" (white-border, borderless and full-art cards). - The zone is found per row/column from each edge inward while pixels are "border-like" (luma < 0.40 and low saturation), within
widthPctof the width. - Which images it applies to is decided by
pipeline::edge_for(settings, origin, is_back): never forImageOrigin::Designed(those already carry their frame); otherwise per origin (applyScryfallfor the two Scryfall origins,applyUploadsfor uploads, none for generated), and for backs onlyapplyBacks.
extend(img, b, mode) adds b = round(bleedMm * px_mm) pixels on all four sides, repeating the edge pixel (Clamp) or reflecting (Mirror), parallelised with rayon. The render always carries the full bleed on all four sides; the PDF (and the screen) crop it with each slot's bleed rectangle, which shrinks the bleed towards neighbouring cards (see Layout Engine). At 600 dpi, 1 mm is 24 px (a_600_ppp_un_milimetro_son_24_px_por_lado).
jpeg(img, quality): RGB (alpha dropped, corners are already filled), quality clamped to 1..=100, 4:2:0 subsampling (what image gives). png(img): RGBA, CompressionType::Fast, adaptive filter. Both are deterministic (same pixels, same bytes), which the render caches and byte-identical PDFs rely on (tests el_png_es_determinista_y_sin_perdida, el_jpeg_es_determinista_y_pierde_el_alfa).
en_formato(img, FormatoImagen, quality) writes the single-card formats (PNG, JPEG, WebP lossless, TIFF, BMP); formats without alpha (JPEG, BMP) get the card composited on white. render_rgba is the pipeline up to pixels (what render runs before encoding), used to save a card in any of those formats.
For a cell whose card has a design, the designer engine paints the card at the exact trim size and dpi (Salida::Impresion, see Designer App Layer), and then only the tail of the pipeline runs: render_desde_imagen does bleed and encoding, with no corners, framing, retouches or edge darkening.
| Function | Role |
|---|---|
spec_de_casilla(slot, img, edits, settings) |
Builds the print RenderSpec from a plan Slot: edits only if slot.applyEdits; edge via edge_for (a SlotFace::Back is a back, a DfcFace is not); card = the slot's trim size (so large cards use their own size); bleedMm = slot.bleedFullMm; target = Dpi(settings.quality.dpi()); format and quality from settings.encoding. |
spec_de_miniatura(blob, img, params, width) |
Thumbnail spec: Width(width), always JPEG quality 85, edits and bleed from PreviewParams. |
AppState::miniatura(blob, params, width) |
Renders a thumbnail with a disk cache at app_cache_dir/renders/<key>.jpg. Deleting the cache loses nothing (test la_miniatura_se_guarda_en_la_cache_y_se_sirve_de_ella). |
render_para_pdf(state, spec) |
Print render, no disk cache (too large; test el_render_para_imprimir_no_deja_nada_en_la_cache). |
AppState::leer_blob(hash) |
Reads a blob's bytes, probes its size and reads its origin from the blobs table. Unknown or malformed hashes give ErrorCode::NotFound. |
impl From<ImagingError> for AppError |
Unsupported -> ErrorCode::Unsupported; TooLarge -> ErrorCode::Image with motivo=too_large; others -> ErrorCode::Image with detalle. |
Decision: the decks' images live in the blob store (they are data the deck depends on), while renders/ in the cache directory holds only derived thumbnails and can be deleted at any time (Storage and Blobs).
Written with krilla (version in crates/proxy-core/Cargo.toml, features raster-images and simple-text). pdf/mod.rs documents how krilla draws, verified against its code: the Surface origin is top-left in points; draw_image(img, size) puts the image's top-left at the current origin; a 180 degree turn about a point is Transform::from_rotate_at(180, cx, cy); a JPEG is embedded as-is with DCTDecode; images with identical content are written once.
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>For each Page in the plan: start a krilla page of widthMm x heightMm (converted with mm_to_pt), for each slot ask the callback for its image (None = blank), call dibujar_casilla, then dibujar_lineas for the page's guides, finish. After each page pagina_hecha(i) is called; returning false aborts with PdfError::Cancelled (this is how export cancel and progress work).
dibujar_casilla(surface, slot, image):
- Push a clip path: the slot's
bleedrectangle (so neighbouring bleeds never overlap). - Translate to
(trim.x - bleedFullMm, trim.y - bleedFullMm)and draw the image at(trim.w + 2*bleedFullMm) x (trim.h + 2*bleedFullMm): the image has the full bleed on all sides; the clip keeps only what the plan allows. - Pop the two states.
dibujar_lineas: all guide segments of a page as a single path stroked with LineCap::Butt; colour from color_hex (#rrggbb; anything unparsable is black); stroke width css_px_to_pt(widthPx) with a floor of 0.05 pt.
documento(meta): no XMP metadata; title from the deck; producer and creator "Proxy Builder"; document_id from PdfMeta. There is no date, so the same deck produces the same bytes (test dos_veces_lo_mismo_dan_los_mismos_bytes); the export layer derives document_id from a hash of the plan and the image keys.
Errors (PdfError): Geometry, Image(msg), Write(msg), Cancelled. The app maps them in commands/export.rs: Cancelled -> ErrorCode::Cancelled, others -> ErrorCode::Pdf with detalle.
- DPI is a property of the render, not of the PDF:
Quality::{Dpi300, Dpi600 (default), Dpi900}->RenderSpec.target = Dpi(..). The PDF page is in points; the image is drawn scaled to the physical size, so effective dpi = pixels / physical inches. -
pdf/estimate.rsbytes_por_imagen(card, dpi, bleed, format, quality)estimates bytes per image: pixel count (trim plus bleed) times a per-pixel factor (0.15 + 0.30 * (quality/100)^3for JPEG,2.2for PNG).commands/export.rsmultiplies it by the number of distinct images to warn before a huge export (see Export). Test:a_600_ppp_un_jpeg_95_ronda_el_megabyte.
calibration(settings, CalibrationTexts) -> Vec<u8> writes a two-page duplex alignment sheet (forces long-edge duplex if the setting is off, disables DFC interleaving and guides). It builds a deck of empty cards filling one sheet and runs the normal plan, so the geometry is exactly the print geometry. Front: each cell outline, its label and two mm rulers (+-5 mm) crossing at the centre. Back: a red cross at the centre of each cell, placed with the same mirror, rotation and row offsets as real backs. Holding the sheet to the light from the front, the cross' offset from the rulers is the printer misalignment per row. The label font is Caladea (pdf/mod.rs LETRA, bundled in assets/fuentes/). The UI flow (measure, "Correct with the measured values") is in 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.
| Test file / name | Guards |
|---|---|
imaging/pipeline.rs tests |
cache key sensitivity, JPEG passthrough, PNG with bleed at 300 dpi gives 768 x 1063, edge_for rules |
imaging/frame.rs tests |
stretch vs crop, name line preserved, crop box bounds, trim sizes |
imaging/mitades.rs tests |
seam found off the expected place, each half keeps only its picture, tiny images do not break |
imaging/corners.rs, edges.rs, bleed.rs, encode.rs, decode.rs, probe.rs
|
each stage |
pdf/compose.rs tests |
two A4 pages for 10 cards, one embedded image for identical copies (DCT), deterministic bytes, images exactly where the plan says, guides only on fronts |
pdf/calibration.rs, pdf/estimate.rs
|
calibration sheet, estimate |
src-tauri/src/render.rs tests |
specs from slots and previews, cache behaviour, error mapping |
- New retouch: add the field to
model::Adjustments(+is_identity), implement inadjust.rsin the fixed order, bumpPIPELINE_VERSION, runpnpm bindings, add the UI in the card editor (Card Editor Dialog). - Anything that changes the pixels of a render must bump
PIPELINE_VERSION, or stale cached thumbnails will survive. - Do not compute positions in
pdf/: add them tolayout::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