Skip to content

Imaging and PDF

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

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).

The pipeline

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}"]
Loading

The module header (imaging/mod.rs) states the order: decode, corners, framing (rotation, zoom, offset, resampling), retouches, edge darkening, bleed, encode.

RenderSpec (pipeline.rs)

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.

Why one pipeline

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).

Passthrough

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.

Decode (decode.rs)

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.

Corners (corners.rs)

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. fill paints 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.

Framing (frame.rs)

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_px per axis for Dpi, width x height/width rounded for Width. 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).

The two artworks of a two-half card (mitades.rs)

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) and COSTURA_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.

Retouches (adjust.rs, blur.rs)

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.

Edge darkening (edges.rs)

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_oscuro samples 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 widthPct of the width.
  • Which images it applies to is decided by pipeline::edge_for(settings, origin, is_back): never for ImageOrigin::Designed (those already carry their frame); otherwise per origin (applyScryfall for the two Scryfall origins, applyUploads for uploads, none for generated), and for backs only applyBacks.

Bleed (bleed.rs)

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).

Encode (encode.rs)

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.

Designed cards: render_desde_imagen

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.

App-side glue (src-tauri/src/render.rs)

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).

The PDF (pdf/)

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.

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>

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):

  1. Push a clip path: the slot's bleed rectangle (so neighbouring bleeds never overlap).
  2. 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.
  3. 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 and file size

  • 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.rs bytes_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)^3 for JPEG, 2.2 for PNG). commands/export.rs multiplies 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 sheet (pdf/calibration.rs)

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.

Tests that guard this area

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

Where to start when extending

  • New retouch: add the field to model::Adjustments (+ is_identity), implement in adjust.rs in the fixed order, bump PIPELINE_VERSION, run pnpm 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 to layout::plan.

See also

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