Skip to content

Motor de maquetación

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

Motor de maquetación

English · Español

El motor de maquetación decide dónde va cada carta en cada cara de cada hoja, en milímetros. Su salida, PagePlan, es la única geometría del producto: la vista previa la pinta y el escritor de PDF la dibuja.

Código fuente: 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, el envoltorio IPC src-tauri/src/commands/layout.rs y la caché del lado de la pantalla src/stores/plan-store.ts.

Una sola geometría para pantalla y papel

Decisión: proxy_core::layout::plan es el único sitio que calcula posiciones. La vista previa (src/features/preview/) y el PDF (src-tauri/crates/proxy-core/src/pdf/compose.rs) nunca calculan una coordenada por su cuenta.

Motivo: si la pantalla y el PDF derivaran cada uno sus posiciones, acabarían desviándose (redondeos, espejo del duplex, recorte del sangrado, desplazamientos de fila). Por eso todo lo que debe coincidir entre «lo que veo» y «lo que se imprime» se decide una sola vez, en Rust, como datos.

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) otra vez al exportar"| C["pdf::compose (mm a pt)"]
    C --> PDF["PDF"]
Loading

Consecuencias:

  • La pantalla solo multiplica milímetros por una escala (escala = zoom * PX_POR_MM, en src/features/preview/preview-pane.tsx). No hay código de maquetación en TypeScript.
  • El PDF convierte milímetros a puntos PDF con units::mm_to_pt (1 pt = 1/72 de pulgada); units.rs es el único sitio que convierte unidades.
  • Un test de compose.rs (cada_imagen_cae_donde_dice_el_plan) vuelve a leer el flujo de contenido del PDF escrito y comprueba que cada imagen cae donde dice el plan, en simplex, en duplex y en duplex en horizontal.

Unidades y papel

Elemento Fichero Notas
MM_POR_PULGADA, mm_to_pt, mm_to_px, css_px_to_pt units.rs Todo en el core son milímetros f64. mm_to_px redondea al píxel más cercano. css_px_to_pt (1 px = 0,75 pt) se usa para el grosor del trazo de las guías de corte, que la interfaz expresa en px CSS.
PaperFormat paper.rs A3, A4, A5, Letter, Legal, Tabloid, Photo4x6, Photo5x7, Photo8x10, Photo8x12. size_mm() es siempre vertical (ancho < alto); oriented_mm(landscape) los intercambia. PaperFormat::TODOS es el orden que ofrece la interfaz.

Modelo de datos (plan.rs, grid.rs)

Todos los tipos derivan TS y se exportan a src/bindings/ (véase IPC y manejo de errores).

Tipo Campos (camelCase en el cable) Significado
RectMm (grid.rs) x, y, w, h Rectángulo en mm, con origen arriba a la izquierda.
Grid (grid.rs) cols, rows, originX, originY, marginMm Cartas por página y esquina superior izquierda de la primera carta. marginMm es el margen efectivo usado para contar las cartas. capacity() = cols x rows.
Slot index, row, col, cardId, face, image, applyEdits, trim, bleed, bleedFullMm Una celda ocupada en una página.
SlotFace front, dfcFace, back Qué cara de la carta muestra la celda: el anverso, la segunda cara de una carta de doble cara colocada como carta propia, o el reverso en una hoja de reversos.
Page index, sheet, side, widthMm, heightMm, slots, guides, big Una página del PDF. index es el número de página del PDF, sheet la hoja física (el anverso y el reverso de una hoja la comparten). side es SheetSide::Front o Back.
LineMm x1, y1, x2, y2 Un segmento de guía de corte.
PlanWarning etiquetado por kind: dfcWithoutBack{count}, missingDefaultBack{count}, bigCardsDoNotFit{count} Cosas que conviene avisar al usuario.
PagePlan grid, cells, pages, sheets, placed, warnings, bigGrid, bigCells El resultado completo.

Detalles de los campos de Slot:

  • index es la posición entre las celdas de anverso de la página (fila a fila, de izquierda a derecha). row es siempre la fila del ANVERSO, también en las hojas de reversos, para poder emparejar los desplazamientos de fila y las miniaturas de los reversos con el anverso.
  • image es None para una casilla en blanco, una imagen pendiente o un reverso que falta.
  • trim es el rectángulo de corte. bleed es hasta dónde se pinta realmente la imagen (el corte más el sangrado recortado). bleedFullMm es el sangrado completo que lleva la imagen renderizada en cada lado (la imagen siempre se renderiza con el sangrado completo en los cuatro lados; el plan solo dice cuánto sobrevive, véase Imagen y PDF).
  • applyEdits es verdadero solo en las caras de anverso. La «otra cara» de una carta de doble cara y los reversos nunca reciben los retoques de la carta (edits).

PagePlan.cells guarda los cortes sin desplazar de todas las celdas de anverso. La vista previa lo usa para dibujar las celdas vacías de la última página. bigGrid/bigCells es lo mismo para las hojas de cartas grandes.

Algoritmo

Punto de entrada: plan(deck: &Deck) -> Result<PagePlan, LayoutError>.

  1. Papel y tamaño de carta. (ancho, alto) = settings.paper.oriented_mm(settings.landscape); el tamaño de carta es settings.cardSize (63 x 88 mm por defecto; 63,5 x 88,9 y 65 x 90 mm como alternativas; los tres son constantes de CardSize en model.rs, véase Exportación).
  2. Rejilla (grid::grid). Margen efectivo m = max(minMarginMm, bleedMm), de modo que el sangrado exterior siempre quepa en el papel. Cartas por eje: floor((paper - 2m + gap) / (card + gap) + EPS). EPS = 1e-9 evita perder una columna por error de coma flotante (el comentario de grid.rs cita 215.9 - 2*5 + 2 dividido entre 65). La rejilla se centra en la página. Cero columnas o filas da LayoutError::CardDoesNotFit; un tamaño de carta no positivo o una separación, margen o sangrado negativos dan LayoutError::InvalidSettings. A4 vertical con los valores por defecto es de 3 x 3 con origen (8.5, 14.5), y sigue siendo de 3 x 3 con cartas de 65 x 90 mm; A4 horizontal es de 4 x 2 (tests en grid.rs). US Letter vertical con cartas de 65 x 90 mm y los valores por defecto es solo de 3 x 2 ((279.4 - 10 + 2) / 92 = 2.95); tres filas piden minMarginMm + gapMm <= 4.7.
  3. Elementos (items). Construye la secuencia de celdas de anverso a partir de deck.cards siguiendo las reglas de cartas de doble cara de más abajo. También recoge los avisos.
  4. Separación en cartas normales y grandes (es_grande), y paginar cada grupo en hojas de grid.capacity() celdas.
  5. Cartas grandes (véase más abajo) tienen su propia rejilla calculada con el mismo papel, margen, separación y sangrado.

paginar por hoja: para cada elemento coloca la casilla de anverso en su celda de la rejilla más el desplazamiento de anverso de la fila, luego (si hay duplex) la casilla de reverso, después construye las guías, y por último añade una página Front y, en duplex, una página Back justo detrás (secuencia F1, B1, F2, B2...).

Cartas de doble cara y duplex: tabla de verdad

De la cabecera de plan.rs:

duplex interleaveDfc qué produce una carta de doble cara reverso de una carta de una sola cara
apagado apagado solo la cara 0 (aviso dfcWithoutBack) ninguno
apagado encendido la cara 0 y la cara 1 en celdas consecutivas ninguno
encendido apagado la cara 0 en el anverso, la cara 1 en su reverso reverso por defecto del mazo
encendido encendido celdas consecutivas; ambas con el reverso del mazo reverso por defecto del mazo

DfcFilter::HideDfc (settings.dfcFilter == "hideDfc") quita del plan (y por tanto del PDF) toda carta cuyo back sea BackSpec::Dfc. La barra de herramientas de la vista previa lo expone como «Ocultar DFC».

Resolución del reverso: gana BackSpec::Custom; si no, el defaultBack del mazo. En duplex, una carta que no sea en blanco ni grande y no tenga reverso resoluble genera missingDefaultBack y su casilla de reverso queda con image: None (reverso en blanco). Las cartas en blanco (CardSource::Blank) ocupan una celda de anverso pero no tienen casilla de reverso.

Espejo en duplex

Tras cada hoja de anverso viene su hoja de reverso. La celda del reverso es siempre la celda del anverso reflejada en horizontal (x' = W - x - w, misma fila), en vertical y en horizontal, y la imagen del reverso no se gira nunca: todos los reversos salen derechos en el PDF, como si se viera el anverso a través del papel. No hay modo «borde corto» (uno anterior giraba la página de reversos 180 grados, y todos los reversos salían boca abajo en el PDF); Duplex es Off o LongEdge, y el valor guardado shortEdge de los decks y .pbdeck antiguos se lee como LongEdge. En el diálogo de la impresora, voltear por el borde que refleja de izquierda a derecha (el largo en hojas verticales).

Lo cubren los tests el_dorso_nunca_se_gira_ni_en_vertical_ni_en_apaisado, borde_corto_de_un_deck_antiguo_se_lee_como_borde_largo y el_espejo_aplicado_dos_veces_vuelve_al_sitio.

Desplazamientos de fila (alineación de la impresora)

PrintSettings.rowOffsets (RowOffsets { front: Vec<Offset>, back: Vec<Offset> }, Offset { xMm, yMm }) desplaza filas enteras. Las filas que faltan valen 0 (front_row/back_row).

Decisión: los desplazamientos del reverso se expresan en el marco del ANVERSO y se indexan por la fila del ANVERSO (lo que se ve al sostener la hoja a contraluz por el anverso). paginar los suma ANTES del espejo (pre = base + back offset, y luego refleja pre). Motivo: quien mide la hoja de calibración lee las desviaciones en el marco del anverso; convertirlas según la orientación es trabajo del motor, no del usuario. Tests: el_desplazamiento_del_dorso_se_mide_desde_el_frente, el_desplazamiento_del_frente_mueve_solo_su_fila. Véase Exportación para la interfaz de alineación y la hoja de calibración.

Separación y sangrado

  • gapMm (2 mm por defecto) es el espacio en blanco entre cartas, de modo que cada carta conserva su tamaño exacto de 63 x 88 mm (bueno para fundas) y el sangrado no cubre la separación.
  • bleedMm (0 por defecto). Por cada lado de cada celda (lados_de_sangrado): el sangrado completo hacia el exterior de la rejilla, min(bleed, gap/2) hacia una carta vecina, de modo que dos sangrados contiguos se tocan sin pisarse (dos_sangrados_contiguos_se_tocan_sin_pisarse, sin_hueco_no_hay_sangrado_interior).
  • ampliar recorta el rectángulo de sangrado a la página, de modo que ningún sangrado sale del papel (ningun_sangrado_sale_del_papel_en_ningun_formato).
  • bleedOnBacks (encendido por defecto): si se apaga, las casillas de reverso tienen sangrado cero en los cuatro lados (bleedFullMm = 0). Al reflejar, se intercambian los lados de sangrado izquierdo/derecho (o superior/inferior) para que el lado exterior siga siendo el exterior.

Guías de corte

guias(...) produce segmentos LineMm en los márgenes, solo cuando cutGuides.enabled y lengthMm > 0. Las marcas verticales van encima de la primera fila y debajo de la última en el borde izquierdo y derecho de cada carta; las marcas horizontales van a izquierda y derecha de cada fila en su borde superior e inferior, así que siguen los desplazamientos de fila. offsetMm es la distancia al borde de la carta, lengthMm la longitud de la marca; todo se recorta a la página. Las guías solo van en las páginas de anverso: en una hoja de reverso, a contraluz, se confundirían con las marcas del anverso (comentario en paginar; test las_guias_solo_van_en_el_frente_y_dentro_del_papel). El color y el grosor (widthPx, px CSS) los dibujan los consumidores, no el plan.

Cartas grandes (planos de Planechase, conspiraciones, Vanguard)

es_grande(card) es verdadero solo para cartas diseñadas (card.design) cuyo indicador oversized está activo y cuya maquetación Maquetacion::es_grande() (Maquetacion::Plano, Plan, Vanguard; Deducido: planos de Planechase y conspiraciones de Archenemy; véase Composición del diseñador). Un plano de Scryfall sin diseño se imprime a tamaño normal.

Las cartas grandes se apartan y se maquetan al final, en sus propias hojas (Page.big = true), con una segunda Grid calculada para maquetacion::medida_grande() = 88,9 x 127 mm (3,5 x 5 pulgadas) con el mismo papel, margen, separación y sangrado, guardada en PagePlan.bigGrid/bigCells. Si esa rejilla no cabe en el papel, las cartas normales se imprimen igualmente y se lanza PlanWarning::BigCardsDoNotFit. Una carta grande sin reverso propio recibe el reverso del mazo solo si settings.bigCardsBack está activo; si no, no tiene casilla de reverso (reverso en blanco), porque los reversos de las cartas grandes son distintos de los de las normales. 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.

Ordenar automáticamente (arrange.rs)

arrange(cards: &[DeckCard]) -> Vec<String> devuelve el nuevo orden de los ids de carta («Auto ordenar» en la barra de herramientas).

  • Descarta las cartas en blanco que no están bloqueadas.
  • Agrupa las copias de la misma carta por orden de primera aparición. Clave: el nombre en minúsculas para las cartas de Scryfall y las fichas, el blob de la imagen para las cartas propias, el id de la carta para las en blanco.
  • Las cartas de doble cara van después de las de una sola cara, igualmente agrupadas.
  • Las cartas bloqueadas nunca se mueven: cada una conserva su índice de posición si aún cabe; si el mazo se ha quedado más corto que la posición, va al final en su orden original.

Lo envuelve arrange_cards de commands/layout.rs; en la pantalla, autoOrdenar de src/features/preview/acciones-carta.ts llama a Rust y luego sustituye el orden de las cartas en el store del mazo (así se autoguarda y se puede deshacer). Los tests están en arrange.rs.

Superficie IPC

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

Comando Firma Notas
compute_page_plan (deck: Deck) -> AppResult<PagePlan> Puro; recibe el mazo entero desde la pantalla.
arrange_cards (cards: Vec<DeckCard>) -> Vec<String> Nuevo orden de ids.

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

Lado de la pantalla: plan-store.ts

usePlan guarda { plan, error, deDeck }. iniciarPlan(retardoMs = 100) se suscribe al store del mazo:

  • Un id de mazo distinto, o ningún mazo, se pide sin espera.
  • Los cambios en cards, settings o defaultBack se agrupan con 100 ms de espera (una ráfaga de ediciones pregunta a Rust una sola vez). Renombrar el mazo no recalcula.
  • Las respuestas pueden llegar desordenadas; un contador de peticiones hace que gane la última.
  • Si hay error se conserva el último plan bueno y se rellena error (el panel de vista previa lo muestra en la barra de aviso ámbar).
  • deDeck es la instantánea del mazo con la que se calculó el plan.

Tests: src/stores/__tests__/plan-store.test.ts (primera petición inmediata, una sola petición tras una ráfaga, petición inmediata al cambiar de mazo, se ignora el cambio de nombre, gana la que llega desordenada, un error conserva el último plan, detener cancela).

Trampas conocidas

  • El plan se calcula a partir de la instantánea del mazo en pantalla, no de la base de datos (el autoguardado puede ir con retraso). La exportación hace lo mismo: véase Exportación.
  • Slot.row es la fila del anverso incluso para las casillas de reverso; no indexes los desplazamientos del reverso por la fila reflejada.
  • PagePlan.cells ignora los desplazamientos de fila; solo Slot.trim los incluye.
  • placed cuenta las celdas de anverso ocupadas (normales más grandes).
  • Un ajuste nuevo que mueva cosas debe pasar por plan(), nunca por el código de la vista previa ni de pdf.

Por dónde empezar para ampliarlo

  • Nuevo tamaño de papel: añádelo a PaperFormat, TODOS y size_mm, regenera los bindings (pnpm bindings) y añade una etiqueta en src/features/export/export-panel.tsx.
  • Nueva regla de colocación: cambia items() o paginar() y añade un test en el módulo de tests de plan.rs; comprueba que los tests de pdf/compose.rs siguen pasando.

Véase también

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