Skip to content

Designer Engine

Pablo Martínez López edited this page Oct 26, 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. On cards with two printed faces (caras_impresas > 1), each face with FaceDesign.ajustes also gets its own DisenoEfectivo in caras[i]: CardDesign::de_la_cara(i) puts the face's AjustesCara (style, pack style and variant, frame, emblem, options) in place of the card's and resolves it the same way, so each field still falls back to the collection and the factory. DisenoEfectivo::de_cara(i) returns it (or the card's own). render and render_capa call it right after clamping cara; huella hashes only that face's effective design (the other faces' settings are left out, so changing one does not repaint the other, and designs without own settings keep their old fingerprints: both fields are skipped when empty). The app (pintar.rs) also takes the pack from de_cara(cara).frame.pack. Composed layouts (split, aftermath, flip…) are one printed face and ignore it.
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, respaldos, cargar_cjk, tiene, tiene_escritura} letras.rs Font catalogue; cargar_emoji registers the colour emoji font as the generic emoji family; respaldos and cargar_cjk register per-script fallbacks (see below).
textos_motor, tipos_idiomas textos_motor.rs, tipos_idiomas.rs Fixed words the engine paints and offline type lines, per design language (see Design languages).
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; art_fade 0..DIFUMINADO_MAX_MM (25 mm); art_fade_extension 0..EXTENSION_DIFUMINADO_MAX_MM (35 mm); rules_box_height +-ALTO_CAJA_MAX_MM (10 mm); text sizes (and cost_size, the mana symbols, which share the range) 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, FaltanLetras { lang }.
  • Per-language values (DesignLang, ByLang<T>, FaceData.printed_names, CardData.sin_texto) are described in Design languages.
  • 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), checks that a pre-2.0 DeckCard JSON still parses without a design key (una_carta_1x_se_lee_igual_y_no_escribe_design), and the language compatibility tests listed below.

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.
  • reglas_efectivas looks for the original names to replace in the face name, every face name and the name printed in that language (FaceData::printed_name(lang)); the Spanish article rule only applies to DesignLang::Es.
  • nombre_efectivo: thematic name of that language (FaceDesign::textos(lang)), else the printed name in that language (FaceData::printed_name(lang)), else the English name. nombre_original: all face names joined with //.
  • tipo_en_espanol(en) (Spanish grammar) and tipo_traducido(en, lang) (tipos_idiomas::tipo_en first, which also covers Spanish, then the Spanish grammar; None in English or when the types are unknown) build a type line without network. A subtype nobody knows stays in English («Creature — Eldrazi» becomes «Criatura — Eldrazi»), because an almost-translated line is better than one entirely in English. arreglar_tipos only writes a line that differs from the English one. arreglar_tipos(card, lang) fills the type of faces that still carry the English one (designs saved before the translation was known); arreglar_tipos_es is the Spanish shortcut.
  • 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): Noto Color Emoji in COLRv1 (assets/fuentes/Noto-COLRv1.ttf, the 2D/fonts/Noto-COLRv1.ttf file of googlefonts/noto-emoji at a fixed commit whose SHA-256 is noted next to the constant, about 5 MB; OFL 1.1, licence in Noto-COLRv1-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).
  • Latin and Cyrillic fallback. Motor::de_fabrica calls Letras::respaldos(&["Spectral", "Montserrat"]), which appends those families as fontique fallbacks for the Latn and Cyrl scripts. A pack or imported font that lacks «ș», «ł» or the Cyrillic alphabet takes only those characters from the house fonts instead of painting boxes; characters the requested font has are untouched. The house fonts cover the Latin of every app language and Russian/Ukrainian Cyrillic.
  • CJK fonts. Japanese, Korean and Chinese need fonts that are too big to bundle (Noto Sans JP/KR/SC/TC, downloaded by the app; see Designer-App-Layer). Letras::cargar_cjk(datos, escritura) registers one as a fallback for its scripts both with its language (ja, ko, zh-Hans, zh-Hant) and without one, so the same ideograph gets the Japanese or the Chinese form depending on the text's locale, and a stray ideograph in a Latin-script card still has a font. tiene_escritura(e) is always true for Escritura::Latina/Cirilica and true for a CJK script once its font is loaded; the painter raises Aviso::FaltanLetras { lang } when it is false.
  • 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.locale (Option<parley::Language>) is passed to parley as the text language: it picks Japanese versus Chinese glyph forms and locale-specific forms. componer::preparar sets it from DesignLang::locale() only for languages other than Spanish and English; for those two it stays None, so their layout (and pixels) are exactly what they were before more languages existed.
    • 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 (Pintor in emoji.rs). It uses the same baseline, size and escala_x as the text. In shadow mode (Pincel.color) every layer takes the shadow colour.
    • Brushes: solid colours from CPAL palette 0 (0xFFFF = the text colour), and linear, radial and sweep gradients as tiny_skia LinearGradient / RadialGradient / SweepGradient shaders. Almost every Noto emoji uses gradients. A gradient tiny-skia cannot build (degenerate, negative radius) falls back to the average colour of its stops (medio).
    • Composite layers: push_layer opens an offscreen Pixmap the size of the canvas; fills go to the topmost one; pop_layer draws it onto the layer below (or the canvas) with the BlendMode matching the COLR CompositeMode (fusion).
    • Clip versus brush transform: a glyph clip (Recorte::Glifo) stores the transform active when it was pushed. Noto pushes a further transform for the gradient after the clip, so the fill paints the path with the clip's transform and gives the shader only the difference (current transform relative to the clip's). Using the current transform for the path would move the outline.
    • es_emoji(c): whether the emoji font has c (and c >= U+2190, below which any text font has it). Used by the render hash, see Render hash and emoji.

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. Another deck card size (63.5 x 88.9 or 65 x 90 mm) only changes the canvas in pixels, so the 63 x 88 geometry is stretched to it (kx and ky differ by less than 1 %). 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 for a 6-digit hex; an 8-digit #rrggbbaa uses its alpha as the opacity). 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). Just before it, the art fade (FineTuning.art_fade or the style's difuminadoArte, plus the extension) is painted over the artwork.
  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_trozo (full stretch; with desde only that piece of the PNG, on its own spot unless en says otherwise; with mascara cut by that PNG's alpha) or png_en_tramos (tramos: top and bottom slices keep proportions, only the middle stretches); blend (mezcla) and opacity apply through pintura_con. The sized result is cached by imagenes_png::trozo (key: pack hash, path, piece, mask, size, scaling), so dragging the art does not redo the crop and mask; the frame colour effects (gradient, custom tint) apply to the piece the same way;
    • 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. The zones are clipped to start below the type bar (the yReglas variable), because with a short dynamic box the bar sits lower than the zones' fixed y.
  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, missing fonts for the script, no official text in the design language) 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, cost_size, type_size, rules_size, pt_size, footer_size Multiply the font size of that role (escala_texto; the ribbon follows title_size). cost_size scales the side of the mana symbols in Carta::coste: they stay right-aligned and centred on the slot's height, and the title gets the room they free (fin_coste). 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.
art_fade (mm) Fades the bottom edge of the artwork into black (difuminar_abajo, painted over the artwork and under the pack layers: smooth at both ends, fully black 1.5 mm before its end because the frame usually covers that edge). None uses the style's difuminadoArte (the built-in extended frame: 12 mm with 7 mm of extension); 0 turns it off.
art_fade_extension (mm) How far the artwork continues below its window, fading out (the variable extensionArte, so the frame's black starts that much lower; the fade ends there). With an own art_fade the extension is the card's own (or none); without it, the card's if set, else the style's extensionDifuminado.
rules_box_height (mm) Raises (positive) or lowers (negative) the top edge of the rules box; exposed to packs as the variable subeCaja. Dynamic box: alto_max and alto_comodo grow by the positive amount, the requested height is the natural one plus the offset, and with a negative value the text is refitted into the lower box (smaller font if needed, never below padding + 3 mm). Fixed frames (built-in extended and classic) move their art, type line, emblem and box edge with subeCaja. Image frames ignore it, except those that declare a caja (the Original pack, see Frame-Packs); Balatro ignores it.
rules_box_dynamic None = as the frame: dynamic in the house borderless, fixed in the built-in extended and classic and in the three Original styles (CajaDinamica.fija_de_serie). Some(true): dynamic everywhere; in a fixed frame that uses subeCaja the box is shortened automatically when the text is short (recorte, at most ALTO_CAJA_MAX_MM, 10 mm) by writing a negative subeCaja, re-evaluating the reglas hueco and refitting the text (undone if the box did not really shrink). Some(false): the box always keeps alto_max (the natural-height clamp is skipped and alto_comodo is ignored) and the rules text is centred vertically in it, like in the fixed boxes (caja_creciente_fija in pintar_cara; a growing box at its natural height keeps the text at the top). Balatro does not change.

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 the design language has no printing (CardData::sin_texto_en(lang): Spanish via sin_texto_es, other languages via sin_texto; always for Romanian, Polish, Dutch and Ukrainian, which Scryfall does not print); rules are the English Oracle
FaltanLetras { lang } the language's script has no loaded font (Letras::tiene_escritura false: Japanese, Korean or Chinese before their font is downloaded)
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

Design languages

A design can be painted in any of 15 languages. The codes are Scryfall's and the same as the interface (src/lib/idiomas.ts), so the UI, design and deck languages share keys.

Item Where Meaning
DesignLang modelo.rs Es En Fr De It Pt Ro Pl Nl Ru Uk Ja Ko Zhs Zht. TODOS lists them in that order, which also decides the key order when saving a ByLang and must not change. codigo() / de_codigo() (Scryfall code), impresas() (false for Ro, Pl, Nl, Uk: Scryfall has no cards in them, rules come from the English Oracle), escritura() → Escritura (Latina, Cirilica, Japonesa, Coreana, ChinaSimplificada, ChinaTradicional: which fonts are needed), locale() (BCP-47, e.g. zh-Hant-TW).
ByLang<T> modelo.rs es and en always, plus otros: BTreeMap<DesignLang, T> flattened into the same object. Saved as { "es": …, "en": …, "fr": … }: es and en first and always (2.5.x requires them and ignores the rest), the others only if present, so a design that only uses Spanish and English is saved byte for byte as before. Deserialize is hand-written and tolerant: missing es/en become defaults, unknown codes (from a newer version) are ignored. get(lang) falls back to en; get_mut(lang) creates the entry; tiene(lang) says whether it has its own value. ts-rs cannot flatten a generic map, so otros is #[ts(skip)] and the frontend reads/writes the extra keys through src/lib/por-idioma.ts.
FaceDesign::textos(lang) modelo.rs The face texts in lang. Languages other than Spanish and English do not fall back to the English texts: without their own, the name is the printed one and the rules the official ones (the app copies a thematic name into a new language when it is chosen). reglas_a_mano(): any language has rules_override.
FaceData.printed_names modelo.rs Printed name per code for the other languages (printed_name_es stays for Spanish, so 2.5.x keeps reading it; English is name). Read and write with printed_name(lang) / poner_printed_name(lang, nombre). Not serialised when empty.
CardData.sin_texto modelo.rs Codes of the other languages without a printing (sin_texto_es stays for Spanish). sin_texto_en(lang), poner_sin_texto(lang, sin) (kept sorted), tiene_idioma(lang) (es/en always; otherwise known to be unprinted or every face has rules in that language).

Engine text that is not card data:

  • textos_motor.rs: palabra_nivel(lang) (the level word of a leveler, «NIVEL», «STUFE», «等级»…), palabras_nivel() (all of them, to recognise ranges in any language), recordatorio_fusionar(lang) / palabra_fusionar(lang) / es_linea_fusionar(linea) (Fuse keyword and reminder), and codigo_pie(lang) (the {lang} code of the footer template). Printed languages take their words from real cards on Scryfall (noted next to each value); Romanian, Polish, Dutch, Ukrainian and Korean Fuse have no official source. The footer codes were checked on real cards (FR, DE, IT, JP, KR, CS, CT, RU); Spanish stays ES although Spanish cards print SP, because changing it would change every Spanish card already made.
  • tipos_idiomas.rs: offline type lines for every design language except English (tipo_en(en, lang)). The English line always has a closed vocabulary (Scryfall's catalogs: 7 supertypes, 19 card types and about 500 subtypes), so the data is a fixed table, not a request. It has two layers. (1) assets/tipos-impresos.json, generated by scripts/tipos-impresos.mjs: for each language with printed cards, the whole type part in English → how Scryfall printed it («Legendary Artifact Creature» → «Criatura artefacto legendaria»; whole phrases, because order, gender and case change per language), and each subtype alone («Elf» → «Elfo»). It is read once (OnceLock) and compiled into the binary; the app never calls Scryfall for it. Subtypes no card carries alone («Druid») are deduced from lines with several («Elf Druid»). Rerun the script when a set adds types: it merges into the existing file and saves after each language. (2) The hand-written tables (FR, DE, …) with the separator and subtype joiner per language, each phrase noting the real card it comes from; they are the only source for Romanian, Polish, Dutch and Ukrainian (no printed cards, no official source) and for the types no foreign card shows (Token, Emblem, Dungeon, Conspiracy, Vanguard…). Order: JSON phrase, then table phrase; per subtype, table, then JSON, then the English word. Italian changes word order with several subtypes, so unknown ones go in English order (union: None). If the types themselves are unknown the result is None (Scryfall's printing, or English, wins). Spanish has no table (ES only gives the subtype joiner); borrador::tipo_en_espanol remains as its last resort.
  • estructura.rs: level ranges (palabras_nivel), class levels and case headers («To solve», «À résoudre», «解明条件»… from Case of the Shattered Pact) are recognised in every printed language, so sections and badges work whatever the design language.

Render hash and emoji

efectivo::huella hashes solo_su_idioma(e, lang): the design without the texts of languages that are not painted, so editing one language never re-renders another. For Spanish and English the reduced design is exactly what it was before more languages existed (the other languages are dropped too), so their hashes, and their cached renders, did not change. When the serialised design contains an emoji (emoji::es_emoji), |emoji-noto is added to the hash: switching the emoji font re-renders only cards with emoji, instead of every card as bumping VERSION_MOTOR would.

Compatibility tests

  • efectivo::tests::las_huellas_en_espanol_e_ingles_son_las_de_antes: fixed hashes produced by 2.5.9 for Spanish and English.
  • modelo::tests::un_diseno_de_antes_se_reescribe_igual: tests/compatibilidad/diseno-2.5.9.json is read and written back byte-identical.
  • modelo::tests::otro_idioma_se_guarda_tras_es_y_en_y_lo_lee_la_version_de_antes: a design with another language saves it after es and en, the old two-field struct still reads it, and an unknown language key is ignored.

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.
  • dorados_en_otros_idiomas: the same card in French, Russian and Romanian against tests/dorados/idioma-fr.png, idioma-ru.png, idioma-ro.png, asserting no FaltanLetras and SinTextoOficial only for Romanian (no printing). dorado_en_japones (ignored) compares idioma-ja.png; it needs the downloaded Japanese font: PB_CJK=<NotoSansJP[wght].ttf> cargo test -p proxy-core --test dorados -- --ignored dorado_en_japones.
  • 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; renders classic, extended and borderless), original_colores (same three styles with every frame colour and rules box), 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 (palettes, gradients, styles, layouts, footers, tinting, sizes), componer.rs, cuerpos.rs, texto.rs, pintar.rs, pack.rs, efectivo.rs, textos_motor.rs, tipos_idiomas.rs, estructura.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