Repository navigation
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.
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"]
Consequences:
- The screen only multiplies millimetres by a scale (
escala = zoom * PX_POR_MM, insrc/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.rsis 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.
| 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. |
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:
-
indexis the position among the front cells of the page (row by row, left to right).rowis always the FRONT row, also on back sheets, so row offsets and back thumbnails can be matched to the front. -
imageisNonefor a blank slot, a pending image, or a missing back. -
trimis the cut rectangle.bleedis how far the image is actually painted (trim plus the clipped bleed).bleedFullMmis 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). -
applyEditsis 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.
Entry point: plan(deck: &Deck) -> Result<PagePlan, LayoutError>.
-
Paper and card size.
(ancho, alto) = settings.paper.oriented_mm(settings.landscape); card size issettings.cardSize(63 x 88 mm by default; 63.5 x 88.9 and 65 x 90 mm as alternatives; all three are constants ofCardSizeinmodel.rs, see Export). -
Grid (
grid::grid). Effective marginm = max(minMarginMm, bleedMm)so the outer bleed always fits on the paper. Cards per axis:floor((paper - 2m + gap) / (card + gap) + EPS).EPS = 1e-9prevents losing a column to floating point error (the comment ingrid.rscites 215.9 - 2*5 + 2 divided by 65). The grid is centred on the page. Zero columns or rows givesLayoutError::CardDoesNotFit; non-positive card size or negative gap/margin/bleed givesLayoutError::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 ingrid.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 needminMarginMm + gapMm <= 4.7. -
Items (
items). Builds the sequence of front cells fromdeck.cardsfollowing the double-faced-card rules below. Also collects warnings. -
Split into normal and large cards (
es_grande), andpaginareach group into sheets ofgrid.capacity()cells. - 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...).
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.
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.
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.
-
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). -
ampliarclamps 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.
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.
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.
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.
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).
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,settingsordefaultBackare 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
erroris set (the preview pane shows it in the amber warning bar). -
deDeckis 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).
- The plan is computed from the deck snapshot on screen, not from the database (autosave can lag). Export does the same: see Export.
-
Slot.rowis the front row even for back slots; do not index back offsets by the mirrored row. -
PagePlan.cellsignores row offsets; onlySlot.trimincludes them. -
placedcounts occupied front cells (normal plus large). - Adding a new setting that moves things must go through
plan(), never through the preview orpdfcode.
- New paper size: add to
PaperFormat,TODOSandsize_mm, regenerate bindings (pnpm bindings), add a label insrc/features/export/export-panel.tsx. - New placement rule: change
items()orpaginar()and add a test in theplan.rstest module; checkpdf/compose.rstests still pass.
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
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