Skip to content

Designer Engine

Pablo Martínez López edited this page Oct 20, 2026 · 14 revisions

Designer Engine

English · Español

The designer engine is the part of proxy-core::diseno that turns one effective design into pixels: the data model, the effective-design resolver and render hash, text shaping with parley, glyph painting with tiny-skia, symbols, and the per-face painter in render.rs. Multi-piece cards (split, flip, meld…) are assembled on top of it and covered in Designer-Composition.

For concepts (CardDesign, Collection, card > collection > factory) read Designer-Overview first.

Public surface

Crate path: src-tauri/crates/proxy-core/src/diseno/.

Item Where Meaning
efectivo(d, coleccion) -> DisenoEfectivo efectivo.rs Resolve card > collection > factory.
huella(e, lang, cara, recursos) -> String efectivo.rs SHA-256 hex render key.
Motor::de_fabrica() render.rs Per-thread typesetting state: Letras (fonts) plus a Maquetador. Loads the built-in fonts.
Recursos { pack, simbolos, imagenes } render.rs What a render borrows: the frame Pack, the Simbolos, and an Imagenes provider.
trait Imagenes { fn imagen(&self, blob) -> Option<Arc<RgbaImage>> } render.rs The core never reads disk; the caller supplies decoded images. SinImagenes is the empty implementation.
render(motor, e, cara, lang, rec, card, ancho_px, salida) -> Result<Render, DisenoError> componer.rs (re-exported from render.rs) Paint one printed face. Render { imagen: RgbaImage, avisos: Vec<Aviso> }.
render_capa(...) -> Option<CapaVista> componer.rs The card without its artwork, on transparency, plus where the artwork goes. For live artwork dragging.
Salida::{Pantalla, Impresion} render.rs Screen: rounded transparent corners. Print: opaque, square corners (the cutter rounds them; bleed repeats the edge).
reducida(img, lado) render.rs Downscale an artwork to at most lado px on the long side (the image the live preview moves).
Letras::{nuevas, cargar, cargar_emoji, tiene} letras.rs Font catalogue; cargar_emoji registers the colour emoji font as the generic emoji family.
Simbolos, SimbolosPropios simbolos.rs Scryfall symbol SVGs and pack-own symbols.

Dependencies (src-tauri/crates/proxy-core/Cargo.toml): parley (layout/shaping), skrifa (glyph outlines), tiny-skia (rasteriser), resvg (SVG: frames and symbols), fast_image_resize (Lanczos3 and pixel resizing), image, regex, sha2.

Pipeline at a glance

flowchart TD
  A["efectivo(): DisenoEfectivo"] --> B["componer::render: pick Maquetacion + Cuerpo"]
  B --> C["pintar_cara (render.rs): one face on a Lienzo"]
  C --> C1["colour choice: colores_marco + Tonos + vars"]
  C1 --> C2["fit rules text: cuerpos::ajustar, dynamic box"]
  C2 --> C3["fondo layers, artwork, ventana_svg"]
  C3 --> C4["pack layers: capas with si conditions"]
  C4 --> C5["texts: cost, title, type, label, emblem, rules, P/T, footer"]
  C5 --> D["corner mask, de_pixmap: RgbaImage + avisos"]
Loading

modelo.rs: the contract

See Designer-Overview for the type table. Engine-relevant details:

  • FineTuning::limitado() clamps everything: offsets to +-AJUSTE_MAX_MM (3 mm); rules_box_opacity to 0..1; art_radius 0..20 mm; card_radius 0..6 mm; text sizes to TAMANO_TEXTO_MIN..MAX (0.5..1.6); line spacing to INTERLINEADO_MIN..MAX (0.7..1.6). efectivo always applies it.
  • Title squeeze constants: ESTRECHADO_DEFECTO 0.65, range ESTRECHADO_MIN 0.55 to ESTRECHADO_MAX 0.75, ESTRECHADO_BASE_DEFECTO 1.0.
  • Aviso variants: SinTextoOficial, TextoNoCabe { hueco, sobra }, TituloReducido { hueco }, ReglasEditadas, FaltaNombre, LetraNoEncontrada, FaltanSimbolos, PackSinLayout, EstiloNoEnElPack, LayoutDesconocido, ColeccionPerdida, SinCinta.
  • Tests: modelo.rs round-trips a two-face design, checks the 108-card deck stays under 2 MB of JSON (peso_de_un_deck_disenado), and checks that a pre-2.0 DeckCard JSON still parses without a design key (una_carta_1x_se_lee_igual_y_no_escribe_design).

borrador.rs (core): text before painting

Not to be confused with src-tauri/src/diseno/borrador.rs (the app-side Scryfall-to-CardData builder, see Designer-App-Layer). The core one is pure text logic:

  • reglas_efectivas(d, cara, lang): the rules text that is painted. A hand-written rules_override wins, but the original card name is still replaced by the thematic one.
  • sustituir_nombre(texto, originales, propio, espanol): replaces the original names (full names first, longest first, then their short forms such as the part before the comma) by the thematic name. Whole words only (cambiar_palabra). It uses private-use marker characters so an already-substituted name is never replaced twice. In Spanish, quitar_articulos drops the article before the substituted name (del becomes de, al becomes a, el/la/los/las removed) because the thematic name is usually a character name, not an object.
  • nombre_efectivo: thematic name, else the printed Spanish name (es) or the English name (en). nombre_original: all face names joined with //.
  • siguiente_numero / numero: footer numbering, 0001... for cards and T001... for tokens, above the highest already in the deck.

letras.rs: fonts without the system

Letras wraps a parley::FontContext built with CollectionOptions { shared: false, system_fonts: false }. Only fonts passed to cargar(Vec<u8>) (TTF/OTF, variable fonts included) exist. Rationale: a card must look the same on every machine, and a missing system font must never silently change layout.

  • Built-in fonts (LETRAS_DE_FABRICA in render.rs, via include_bytes! from assets/fuentes/): Spectral Regular/Italic/SemiBold/Bold and Montserrat (variable). Their licences are alongside (Spectral-LICENSE.txt, Montserrat-LICENSE.txt).
  • Emoji font (LETRA_DE_EMOJI in render.rs): Twemoji Mozilla (COLR, solid-colour layers, about 1.4 MB; graphics CC BY 4.0, licence in Twemoji-LICENSE.txt). Motor::de_fabrica loads it with Letras::cargar_emoji, which sets it as GenericFamily::Emoji. parley already asks for that family whenever a cluster is an emoji (ZWJ sequences, skin tones, flags, U+FE0F), after the text family, so no text font needs to carry emoji and a card never shows an empty box for them. It is bundled (not downloaded) because emoji can appear in any text of any card. Caladea is in assets/fuentes/ only as a test fixture (letras.rs and recursos.rs tests).
  • Imported and downloaded fonts are loaded by the app into every thread's Motor (Recursos::con_motor in src-tauri/src/diseno/recursos.rs, rebuilt when the font "generation" counter changes). Font ids are the SHA-256 of the file. See Frame-Packs for the downloadable OFL catalogue.
  • A collection can ask for another title/rules family (font_title, font_rules). If it is not loaded, Carta::letra_sin_peso falls back to the pack's font and records Aviso::LetraNoEncontrada.

texto.rs: rich text and layout

  1. Parsing to Parrafos (Vec<Trozo>, Trozo::Texto { texto, cursiva } or Trozo::Simbolo):
    • reglas(texto): one paragraph per line. Text inside parentheses is italic (reminder text); {X} becomes a symbol; [[word]] marks a highlight span; the short "ability word" before — at the start of a line (at most 4 words, no { : ( . , •) becomes italic and coloured (palabra_de_habilidad).
    • sabor(texto): flavour text, all italic.
    • con_simbolos(texto): symbols only (type line, footer). llano(texto): plain.
    • Highlight and ability-word spans use private-use markers (RESALTE, HABILIDAD), stripped in maquetar and painted in the pack's coloresTexto colours if the pack has them; otherwise the markers vanish and the text is the normal colour.
  2. Maquetador::maquetar(letras, parrafo, estilo, ancho) lays one paragraph out with parley: default family/size/weight/line height, forced lining numerals ("lnum", plus "smcp" for small caps), italics as ranges, colour spans, then break_all_lines(ancho) and align. ancho = None means a single line.
    • Symbols are inline boxes of side estilo.tam * LADO_SIMBOLO (0.80), gap HUECO_SIMBOLO (0.05), dropped BAJA_SIMBOLO (0.09) below the baseline. Consecutive symbols ({2}{U}{U}:) form one box with the trailing punctuation (: , . ; )) attached as a nested Bloque (Grupo.cola), so a cost never splits across lines.
    • Maquetador.sustituir is a list of (char, replacement) pairs set from the pack's sustituir map (componer::preparar), for characters a pack font lacks.
  3. Estilo (family, weight, size px, interlinea, colour, Alinear, versalitas, resalte, habilidad) is the paint-ready style. Bloque is a laid-out paragraph (ancho, alto, base, plus ultima_linea_ancho()).
  4. Fitting:
    • linea_ajustada_con_base (title and type line): see Title squeeze below.
    • caja_de_reglas: stacks rules paragraphs and flavour text with Separaciones { parrafo: 0.32, sabor: 0.75 } (multiples of the font size) and records where the divider line goes between rules and flavour.
    • ajustar_reglas: if the box is too tall, binary-searches the font size down in steps of 0.1 pt (paso) to tam_min; at the minimum it tightens spacing (parrafo 0.12, sabor 0.45) and, if it still does not fit, returns the overflow in Ajuste.sobra.

pintar.rs: glyphs to pixels

pintar_bloque(lienzo, bloque, x, y, pincel, simbolos) walks the parley lines. For every glyph run it fetches the outline with skrifa unhinted (works with variable fonts via normalised coordinates), accumulates one tiny_skia path per run through the Lapiz outline pen, and fills it with anti-aliasing. Details:

  • Pincel { escala_x, color }: escala_x is the horizontal squeeze factor (text is condensed by scaling x, not by switching fonts); color overrides the text colour (used for the drop shadow).
  • Synthetic italic (synthesis().skew()) is applied as a shear in the pen; synthetic bold draws an extra stroke of 0.035 * size.
  • Inline boxes paint their symbols through Simbolos::pintar; in shadow mode a symbol becomes a circle of the shadow colour. Missing symbols are returned as codes (painted grey) and surface as Aviso::FaltanSimbolos.
  • pintar_caja paints a rules box and the flavour divider; tiene_glifo(datos, c) checks a font for a character.
  • Colour glyphs (emoji): before taking an outline, each glyph is offered to emoji::pintar_glifo, which asks skrifa for its colour layers (color_glyphs) and paints them in place with a ColorPainter (emoji.rs): per layer, a clip with the layer's glyph and a fill from the CPAL palette 0. It uses the same baseline, size and escala_x as the text. In shadow mode (Pincel.color) every layer takes the shadow colour. Limits: gradients (COLRv1) are painted with the colour of their first stop and layer compositing is ignored; Twemoji only uses solid colours. Bump VERSION_MOTOR if the emoji font changes.

simbolos.rs: mana symbols

Simbolos holds parsed usvg::Trees by code ("T", "2/U", "W/P"), keyed by codigo() (braces stripped, upper case). Scryfall's SVGs are downloaded and stored by the app (symbols_status command, refreshed after 30 days, CADUCA_SIMBOLOS); see Designer-App-Layer. Simbolos::nuevos() always includes two built-in footer icons from assets/iconos/: {PINCEL} (brush) and {ESTRELLA} (star). The footer text maps the characters ★ and ✎ to them.

SimbolosPropios is the pack-supplied layer (SVG or PNG per code, pixel flag for nearest-neighbour scaling). Simbolos::con_propios(Arc<SimbolosPropios>) returns a view where own symbols shadow Scryfall's; codes the pack lacks fall through to Scryfall. PNG symbols are fitted without distortion, centred in the square.

render.rs: painting one face

pintar_cara(motor, e, lang, rec, estilo, avisos, opciones) -> Pieza paints a face on a Lienzo (a tiny_skia::Pixmap plus the mm-to-px factors kx, ky and the canvas size in mm). Everything is specified in millimetres on a 63 x 88 card (or the style's own tamano); the canvas converts. The same function paints a normal card, half of a split card, the aftermath strip, a landscape plane… because Opciones (cara, cuerpo, transparente, ancho_px, alto_px, indicador, otra_cara, sin_pie, sin_arte, aventura) and the EstiloDef passed in say what to draw.

Step by step

  1. Canvas. Lienzo::nuevo(w_mm, h_mm, ancho_px, alto_px). Filled with opaque black unless opciones.transparente.
  2. Colour choice. colores_marco → eleccion_marco:
    • FrameColor::Palette / Gradient / Custom are taken as given.
    • Auto: one card colour gives its palette; two give a gradient of the two (in order); three or more give gold; none gives artifact (type contains Artifact) or colorless. A colourless land uses its colour identity if any (so a dual land is coloured), else the land palette. Face colours win over card colours.
    • Palettes (PaletaDef) become Tonos { claro, medio, oscuro, tinte, caja, tinta }; a custom colour is turned into tones by lightening/darkening; a gradient mixes the two (Tonos::mezcla). A palette missing from the pack renders grey.
  3. Rules-box mode. From FrameOptions.rules_box (Auto uses the style's cajaPorDefecto: Tinte = colour tint at 0.80 opacity, Clara = light box at 1.0; Dark = near-black 0.85; Light = cream 0.92; Tint{hex} = 0.80). The fine-tuning rules_box_opacity pushes the opacity toward 1. The text ink (tintaCaja) is white or the palette ink depending on the perceived luminance of the box.
  4. SVG variables. A BTreeMap<String,String> (vars) with defs (the gradients) and the colours listed in Frame-Packs, later extended with the dynamic-box geometry.
  5. Rules text first. The body is laid out before anything is painted because the growing rules box moves the title bar and the artwork edge:
    • contenido_de_la_cara → a Contenido (plain text, sections with badges, or adventure columns; see Designer-Composition).
    • Dynamic box (style has caja): try the "comfortable" height (alto_comodo) letting the font shrink down to 75 % (LETRA_COMODA) unless the user fixed a rules size; if that does not fit, use alto_max with the full font and shrink as needed down to a 6 pt floor. If the last line would run into the power/toughness box, the bottom padding is raised and the fit redone. The resulting geometry becomes numeric variables for pack expressions (yReglas, altoReglas, yTipo) and SVG variables (yReglas, altoReglas, yTipo, yTipo04). Gotcha: yReglas / altoReglas mean the text area in hueco expressions (nums) but the whole box in SVG vars.
    • Fixed box (no caja): fit inside the reglas hueco (minus the MDFC strip); the text is vertically centred (alinearV can change that).
    • Overflow at the floor raises Aviso::TextoNoCabe { hueco: "reglas", sobra } (mm).
  6. Background layers (EstiloDef.fondo) are painted under the artwork. Skipped when sin_arte (live-drag layer), because they would cover the artwork the UI places underneath.
  7. Artwork. pintar_arte(l, img, hueco, framing):
    • No rotation: one step crop plus Lanczos3 resize (reescalar with ResizeOptions::crop). Zoom >= 1; offsets in [-1, 1] move within the slack on each axis.
    • With rotation: scale up to cover the rotated rectangle, then draw with a bicubic rotation transform.
    • No image: a neutral dark gradient (sin_arte), unless the style has fondo layers. The hueco may use box variables so the art reaches the type bar.
    • The result is returned to the caller as Pieza.arte (fractions of the canvas) for render_capa.
  8. Art window (ventanaArte): ventana_svg paints black outside the window with rounded corners (radius from the style, or FineTuning.art_radius).
  9. Pack layers (Carta::capas). Each Capa is skipped unless its si condition holds for the Hechos (corona/fr/cinta/emblema set here, the rest from hechos_de). Then:
    • cajaReglas layers paint the requested box tint in their zone (tinte_caja) and nothing else;
    • PNG layers (capa_png) resolve {color}/{color1}/{color2} in the path (Capa::png_para), evaluate the zone expressions with the box variables (a zone that references a variable the card lacks is skipped), and paint with png_del_pack (full stretch) or png_en_tramos (tramos: top and bottom slices keep proportions, only the middle stretches); blend (mezcla) and opacity apply through pintura_con;
    • SVG layers are rellenar-ed with the variables and rendered by resvg over the whole canvas (Lienzo::svg). A layer not found in the pack falls back to the builtin pack's files.
    • If the style has ventanaArte.repintar and the user set art_radius, layers are split at repintarTras: after the first group, repintar_ventana re-paints the artwork (or black) over the zones where the frame image drew a baked-in curve, then the remaining layers follow.
  10. Texts, in this order: cost (coste: right-aligned symbols, with a dark circle behind each unless the pack has own symbols; the title width is reduced to stay left of the cost when both are on the same row), face indicator (sun/moon/triangles for transform and MDFC, pushes the title right), title, ribbon with the original name (cinta), type line (width reduced by the label pill), label pill (etiqueta: rarity/legendary/basic land type), emblem (imagen, fitted undistorted), fullArt colour symbol, rules content (pintar_contenido) or dungeon rooms (pintar_mazmorra), Vanguard hand/life, MDFC other-face strip (franja_otra_cara), power/toughness (or loyalty, or defense; fr_texto), footer (pie).
  11. Warnings (missing name, hand-edited rules, no Spanish text) are added and the Pieza returned.

Afterwards componer::render removes duplicate warnings, adds FaltanSimbolos (excluding the built-in icons), and applies the corner mask: FineTuning.card_radius when set (screen and print), else the style's radio on screen and square for print. de_pixmap un-premultiplies to RgbaImage.

Title and type-line squeeze

Carta::linea(nombre, parrafo, ancho_max, squeeze) → linea_ajustada_con_base:

  1. Lay the text out on one line. The line is always squeezed to at least squeeze_base (collection/card "minimum squeeze", 1.0 = none, only for the titulo and tipo huecos).
  2. If it still does not fit, squeeze horizontally (Pincel.escala_x) down to squeeze_min (the limit, 55-75 %).
  3. If even that does not fit, shrink the font size just enough, and report Aviso::TituloReducido { hueco }.

Decision rationale: condensing the glyphs a little keeps the pack's font size and card look; shrinking is the last resort. Helper texts (texto_centrado, texto_izquierda, used by badges and the MDFC strip) use a fixed 0.6 limit.

Fine tuning (FineTuning)

Applied inside Carta::hueco, escala_texto, estilo_texto, pintar_cara and pie:

Field Effect
title, cost, type_line, emblem, pt, footer (mm) Added to the hueco y (Carta::hueco).
rules (mm) Added when the rules content is painted (the hueco also measures the box, so it is not moved in hueco).
title_size, type_size, rules_size, pt_size, footer_size Multiply the font size of that role (escala_texto; the ribbon follows title_size). With rules_size set the comfortable-height shrink is skipped: the box grows as needed and the font only drops if even the maximum box is too small.
rules_line_spacing, footer_line_spacing Multiply the line height / footer row pitch.
rules_box_opacity See step 3.
art_radius, card_radius Corner radii (mm), see steps 8 and the corner mask.

Colour tinting for image frames (ExtraMarco)

Image-based packs (Original, Balatro) have one PNG per colour instead of a gradient SVG. For layers whose path contains {color} only:

  • ExtraMarco::Degradado(b): paint the first palette's PNG, then the second palette's PNG with fundir_a_la_derecha (alpha ramp from 35 % to 65 % of the card width).
  • ExtraMarco::Tinte(color): take the neutral (colorless) PNG and tenir it, an overlay-style blend driven by luminance that keeps highlights and shadows.
  • ExtraMarco::Nada: the PNG of the chosen palette. Two-colour cards in Auto stay on gold (clave_marco).

PNG caches

render::imagenes_png keeps two process-wide LRU lists (ORIGINALES: decoded PNGs; A_MEDIDA: resized to the exact pixel size), each capped at 160 MiB of RGBA. Keys include Pack.huella, so a modified pack never reuses old pixels. Repainting the same frame while dragging artwork therefore costs only the artwork.

Rules box and secondary text helpers

Carta exposes texto_centrado, texto_izquierda, simbolos_en_fila and forma (an SVG fragment in millimetres sharing the card's gradients). cuerpos.rs uses them to draw badges (Designer-Composition).

Warnings produced by the engine

Aviso Raised by
TextoNoCabe rules text overflows at the 6 pt floor
TituloReducido title/type had to shrink after squeezing
FaltaNombre no thematic name for the face and language
ReglasEditadas rules_override present
SinTextoOficial Spanish requested, CardData.sin_texto_es
LetraNoEncontrada collection font not loaded
FaltanSimbolos symbol code with no SVG/PNG
SinCinta original name in ribbon requested, style has no cinta hueco
PackSinLayout, EstiloNoEnElPack, LayoutDesconocido see Designer-Composition and Frame-Packs
ColeccionPerdida added by the app, not the engine

Golden tests

Directory: src-tauri/crates/proxy-core/tests/ (dorados.rs, layouts.rs, comun/mod.rs) and reference images under tests/dorados/.

  • dorados.rs → dorados_de_los_casos_base: 13 base cases (legendary creature, ribbon variant, extended legendary, two-colour wall, long title, sorcery, creature in classic, artifact in borderless and classic, extended land, full-art basic, extended and classic tokens) rendered at ANCHO = 420 px with the builtin pack, built-in fonts, synthetic art and synthetic symbols (comun: hand-drawn so there are no rights issues), print output. Each image is compared to tests/dorados/<name>.png; a case fails at 0.5 % of pixels differing by more than 8/255 in any channel (comun::diferencia). The test also asserts no FaltanSimbolos warning.
  • dos_renders_iguales_dan_los_mismos_bytes: identical inputs give identical bytes; print output is opaque; screen output has a transparent corner pixel; poker size renders at the right aspect.
  • cambiar_un_color_del_pack_cambia_el_dorado: editing a palette changes Pack.huella and the image.
  • tests/layouts.rs → dorados_de_los_layouts, las_cartas_grandes_salen_a_su_medida (see Designer-Composition).
  • Ignored, run on demand with environment variables: tiempos (timings), original_con_sus_imagenes (PB_ORIGINAL, folder of Card Conjurer frames), original_colores, balatro_con_sus_imagenes and balatro_contra_el_autor (PB_BALATRO, PB_AUTOR, PB_SALIDA), letras_libres_a_ojo (PB_GF), balatro_matriz in layouts.rs. These need external assets and are for eyeballing, not CI.
  • Updating goldens: PB_ACTUALIZAR_DORADOS=1 cargo test -p proxy-core --test dorados (and --test layouts) rewrites the images. Do it only for intended visual changes and review the diff; also bump VERSION_MOTOR when pixels change on purpose so caches refresh.
  • Unit tests live next to the code: render.rs (41, covering palettes, gradients, styles, layouts, footers, tinting, sizes), componer.rs (22), cuerpos.rs (18), texto.rs, pintar.rs, pack.rs, efectivo.rs.

Also src-tauri/crates/proxy-core/examples/disenar.rs renders sample cards to disk for eyeballing, and examples/prototipo.rs is the early prototype kept as a reference (not app code).

Failure modes

Failure Behaviour
Canvas of impossible size DisenoError::Lienzo (pixel width is clamped to at least 16 in render; tiny-skia pixmap creation can still fail)
Invalid font file DisenoError::LetraNoValida
Corrupt symbol DisenoError::SimboloNoValido
Pack layer file missing at paint time the layer is skipped silently (packs are validated on load, so this means a builtin-fallback case)
Style missing from the pack builtin pack's style is used; then the pack's borderless; finally DisenoError::Lienzo

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