Skip to content

Layout Engine

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

Layout Engine

English · Español

The layout engine decides where every card goes on every side of every sheet, in millimetres. Its output, PagePlan, is the only geometry in the product: the live preview paints it and the PDF writer draws it.

Source: src-tauri/crates/proxy-core/src/layout/ (grid.rs, plan.rs, arrange.rs), src-tauri/crates/proxy-core/src/paper.rs, src-tauri/crates/proxy-core/src/units.rs, the IPC wrapper src-tauri/src/commands/layout.rs, and the screen-side cache src/stores/plan-store.ts.

One geometry for screen and paper

Decision: proxy_core::layout::plan is the single place that computes positions. The preview (src/features/preview/) and the PDF (src-tauri/crates/proxy-core/src/pdf/compose.rs) never compute a coordinate on their own.

Why: if the screen and the PDF each derived positions, they would drift (rounding, mirroring for duplex, bleed clipping, row offsets). Everything that has to agree between "what I see" and "what prints" is therefore decided once, in Rust, as data.

flowchart LR
    D["Deck.settings + Deck.cards"] --> P["layout::plan(deck)"]
    P --> PP["PagePlan (mm)"]
    PP -->|"compute_page_plan"| PS["plan-store.ts"]
    PS --> PV["PageView / SlotView (x mm * scale)"]
    PP -->|"plan(deck) again inside export"| C["pdf::compose (mm to pt)"]
    C --> PDF["PDF"]
Loading

Consequences:

  • The screen only multiplies millimetres by a scale (escala = zoom * PX_POR_MM, in src/features/preview/preview-pane.tsx). There is no layout code in TypeScript.
  • The PDF converts millimetres to PDF points with units::mm_to_pt (1 pt = 1/72 inch); units.rs is the only place that converts units.
  • A test in compose.rs (cada_imagen_cae_donde_dice_el_plan) re-reads the written PDF content stream and checks that each image lands where the plan says, for simplex, duplex and landscape duplex.

Units and paper

Item File Notes
MM_POR_PULGADA, mm_to_pt, mm_to_px, css_px_to_pt units.rs Everything in the core is f64 millimetres. mm_to_px rounds to the nearest pixel. css_px_to_pt (1 px = 0.75 pt) is used for the cut-guide stroke width, which the UI expresses in CSS px.
PaperFormat paper.rs A3, A4, A5, Letter, Legal, Tabloid, Photo4x6, Photo5x7, Photo8x10, Photo8x12. size_mm() is always portrait (width < height); oriented_mm(landscape) swaps. PaperFormat::TODOS is the order offered in the UI.

Data model (plan.rs, grid.rs)

All types derive TS and are exported to src/bindings/ (see IPC and Error Handling).

Type Fields (camelCase on the wire) Meaning
RectMm (grid.rs) x, y, w, h Rectangle in mm, origin top-left.
Grid (grid.rs) cols, rows, originX, originY, marginMm Cards per page and the top-left corner of the first card. marginMm is the effective margin used to count cards. capacity() = cols x rows.
Slot index, row, col, cardId, face, image, applyEdits, trim, bleed, bleedFullMm One occupied cell on one page.
SlotFace front, dfcFace, back Which face of the card the cell shows: the front, the second face of a double-faced card placed as its own card, or the back on a back sheet.
Page index, sheet, side, widthMm, heightMm, slots, guides, big One PDF page. index is the PDF page number, sheet the physical sheet (front and back of one sheet share it). side is SheetSide::Front or Back.
LineMm x1, y1, x2, y2 A cut-guide segment.
PlanWarning tagged by kind: dfcWithoutBack{count}, missingDefaultBack{count}, bigCardsDoNotFit{count} Things worth telling the user.
PagePlan grid, cells, pages, sheets, placed, warnings, bigGrid, bigCells The whole result.

Slot field details:

  • index is the position among the front cells of the page (row by row, left to right). row is always the FRONT row, also on back sheets, so row offsets and back thumbnails can be matched to the front.
  • image is None for a blank slot, a pending image, or a missing back.
  • trim is the cut rectangle. bleed is how far the image is actually painted (trim plus the clipped bleed). bleedFullMm is the full bleed the rendered image carries on each side (the image is always rendered with the full bleed on all four sides; the plan only says how much of it survives, see Imaging and PDF).
  • applyEdits is true only for front faces. The "other face" of a double-faced card and backs never receive the card's retouches (edits).

PagePlan.cells holds the un-offset trims of every front cell. The preview uses it to draw the empty cells of the last page. bigGrid/bigCells are the same for the large-card sheets.

Algorithm

Entry point: plan(deck: &Deck) -> Result<PagePlan, LayoutError>.

  1. Paper and card size. (ancho, alto) = settings.paper.oriented_mm(settings.landscape); card size is settings.cardSize (63 x 88 mm by default; 63.5 x 88.9 and 65 x 90 mm as alternatives; all three are constants of CardSize in model.rs, see Export).
  2. Grid (grid::grid). Effective margin m = max(minMarginMm, bleedMm) so the outer bleed always fits on the paper. Cards per axis: floor((paper - 2m + gap) / (card + gap) + EPS). EPS = 1e-9 prevents losing a column to floating point error (the comment in grid.rs cites 215.9 - 2*5 + 2 divided by 65). The grid is centred on the page. Zero columns or rows gives LayoutError::CardDoesNotFit; non-positive card size or negative gap/margin/bleed gives LayoutError::InvalidSettings. A4 portrait with defaults is 3 x 3 with origin (8.5, 14.5), and still 3 x 3 with 65 x 90 mm cards; A4 landscape is 4 x 2 (tests in grid.rs). US Letter portrait with 65 x 90 mm cards and the defaults is only 3 x 2 ((279.4 - 10 + 2) / 92 = 2.95); three rows need minMarginMm + gapMm <= 4.7.
  3. Items (items). Builds the sequence of front cells from deck.cards following the double-faced-card rules below. Also collects warnings.
  4. Split into normal and large cards (es_grande), and paginar each group into sheets of grid.capacity() cells.
  5. Large cards (see below) get their own grid computed with the same paper, margin, gap and bleed.

paginar per sheet: for each item it places the front slot at its grid cell plus the row's front offset, then (if duplex) the back slot, then builds guides, then pushes a Front page and, in duplex, a Back page right after (sequence F1, B1, F2, B2...).

Double-faced cards and duplex: truth table

From the header of plan.rs:

duplex interleaveDfc what a double-faced card produces back of a single-faced card
off off only face 0 (warning dfcWithoutBack) none
off on face 0 and face 1 in consecutive cells none
on off face 0 in front, face 1 on its back deck default back
on on consecutive cells; both with the deck back deck default back

DfcFilter::HideDfc (settings.dfcFilter == "hideDfc") removes every card whose back is BackSpec::Dfc from the plan (and therefore from the PDF). The preview toolbar exposes it as "Hide DFC".

Back resolution: BackSpec::Custom wins, otherwise the deck's defaultBack. In duplex, a non-blank, non-large card with no resolvable back raises missingDefaultBack and its back slot has image: None (blank back). Blank cards (CardSource::Blank) occupy a front cell but get no back slot.

Duplex mirroring

After each front sheet comes its back sheet. The back cell is always the front cell mirrored horizontally (x' = W - x - w, same row), in portrait and in landscape, and the back image is never rotated: every back comes out upright in the PDF, as if the front were seen through the paper. There is no short-edge mode (an earlier one rotated the back page 180 degrees, which left every back upside down in the PDF); Duplex is Off or LongEdge, and the saved value shortEdge of old decks and .pbdeck files is read as LongEdge. In the printer dialog, flip on the edge that mirrors left to right (long edge for portrait sheets).

The tests el_dorso_nunca_se_gira_ni_en_vertical_ni_en_apaisado, borde_corto_de_un_deck_antiguo_se_lee_como_borde_largo and el_espejo_aplicado_dos_veces_vuelve_al_sitio cover it.

Row offsets (printer alignment)

PrintSettings.rowOffsets (RowOffsets { front: Vec<Offset>, back: Vec<Offset> }, Offset { xMm, yMm }) shifts whole rows. Missing rows are 0 (front_row/back_row).

Decision: back offsets are expressed in the FRONT frame and indexed by the FRONT row (what you see holding the sheet up to the light from the front). paginar adds them BEFORE the mirror (pre = base + back offset, then mirrors pre). Why: a user measuring the calibration sheet reads deviations in the front frame; converting per orientation is the engine's job, not the user's. Tests: el_desplazamiento_del_dorso_se_mide_desde_el_frente, el_desplazamiento_del_frente_mueve_solo_su_fila. See Export for the alignment UI and the calibration sheet.

Gap and bleed

  • gapMm (default 2 mm) is blank space between cards, so each card keeps its exact 63 x 88 mm size (good for sleeves) and bleed does not cover the gap.
  • bleedMm (default 0). Per side of each cell (lados_de_sangrado): the full bleed towards the outside of the grid, min(bleed, gap/2) towards a neighbouring card, so two adjacent bleeds touch without overlapping (dos_sangrados_contiguos_se_tocan_sin_pisarse, sin_hueco_no_hay_sangrado_interior).
  • ampliar clamps the bleed rectangle to the page, so no bleed ever leaves the paper (ningun_sangrado_sale_del_papel_en_ningun_formato).
  • bleedOnBacks (default on): if off, back slots get zero bleed on all sides (bleedFullMm = 0). When mirrored, the left/right (or top/bottom) bleed sides are swapped so the outer side stays the outer side.

Cut guides

guias(...) produces LineMm segments in the margins, only when cutGuides.enabled and lengthMm > 0. Vertical ticks sit above the first row and below the last row at each card's left and right edge; horizontal ticks sit left and right of each row at its top and bottom edge, so they follow row offsets. offsetMm is the distance from the card edge, lengthMm the tick length; everything is clamped to the page. Guides are only on front pages: on a back sheet, held to the light, they would be confused with the front's marks (comment in paginar; test las_guias_solo_van_en_el_frente_y_dentro_del_papel). Colour and width (widthPx, CSS px) are drawn by the consumers, not by the plan.

Large cards (Planechase planes, schemes, Vanguard)

es_grande(card) is true only for designed cards (card.design) whose oversized flag is set and whose layout Maquetacion::es_grande() (Maquetacion::Plano, Plan, Vanguard; Inferred: Planechase planes and Archenemy schemes; see Designer Composition). A Scryfall plane without a design prints at normal size.

Large cards are partitioned out and laid out last, on their own sheets (Page.big = true), with a second Grid computed for maquetacion::medida_grande() = 88.9 x 127 mm (3.5 x 5 in) using the same paper, margin, gap and bleed, stored in PagePlan.bigGrid/bigCells. If that grid does not fit the paper, normal cards still print and PlanWarning::BigCardsDoNotFit is raised. A large card without its own back gets the deck back only if settings.bigCardsBack is on; otherwise it has no back slot (blank back), because large-card backs are different from normal-card backs. Tests: las_cartas_grandes_van_al_final_en_sus_hojas, sin_cartas_grandes_el_plan_no_cambia, si_las_grandes_no_caben_las_normales_salen.

Auto arrange (arrange.rs)

arrange(cards: &[DeckCard]) -> Vec<String> returns the new order of card ids ("Auto Arrange" in the toolbar).

  • Drops blank cards that are not locked.
  • Groups copies of the same card in order of first appearance. Key: lowercased name for Scryfall/token cards, image blob for custom cards, card id for blanks.
  • Double-faced cards go after single-faced ones, still grouped.
  • Locked cards never move: each keeps its position index if it still fits; if the deck became shorter than the position, it goes at the end in its original order.

Wrapped by commands/layout.rs arrange_cards; the UI side is autoOrdenar in src/features/preview/acciones-carta.ts, which calls Rust and then replaces the card order in the deck store (so it autosaves and is undoable). Tests are in arrange.rs.

IPC surface

src-tauri/src/commands/layout.rs:

Command Signature Notes
compute_page_plan (deck: Deck) -> AppResult<PagePlan> Pure; takes the whole deck from the screen.
arrange_cards (cards: Vec<DeckCard>) -> Vec<String> New order of ids.

LayoutError maps to AppError { code: InvalidInput, params: { motivo: "card_does_not_fit" | "invalid_settings" } } (impl From<LayoutError> for AppError in src-tauri/src/error.rs).

Screen side: plan-store.ts

usePlan holds { plan, error, deDeck }. iniciarPlan(retardoMs = 100) subscribes to the deck store:

  • A different deck id, or no deck, is requested with no delay.
  • Changes to cards, settings or defaultBack are debounced by 100 ms (a burst of edits asks Rust once). Renaming the deck does not recompute.
  • Responses can arrive out of order; a request counter makes the latest request win.
  • On error the previous good plan is kept and error is set (the preview pane shows it in the amber warning bar).
  • deDeck is the deck snapshot the plan was computed from.

Tests: src/stores/__tests__/plan-store.test.ts (immediate first request, single request after a burst, immediate request on deck change, name change ignored, out-of-order wins, error keeps last plan, stop cancels).

Gotchas

  • The plan is computed from the deck snapshot on screen, not from the database (autosave can lag). Export does the same: see Export.
  • Slot.row is the front row even for back slots; do not index back offsets by the mirrored row.
  • PagePlan.cells ignores row offsets; only Slot.trim includes them.
  • placed counts occupied front cells (normal plus large).
  • Adding a new setting that moves things must go through plan(), never through the preview or pdf code.

Where to start when extending

  • New paper size: add to PaperFormat, TODOS and size_mm, regenerate bindings (pnpm bindings), add a label in src/features/export/export-panel.tsx.
  • New placement rule: change items() or paginar() and add a test in the plan.rs test module; check pdf/compose.rs tests still pass.

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