Skip to content

Designer App Layer

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

Designer App Layer

English · Español

The Tauri-side half of the card designer ("Editor de cartas", the "Diseñar" view): everything that needs the network, the disk or the database around the pure engine in proxy_core::diseno. It builds drafts from Scryfall, owns the installed fonts/packs/symbols, registers designs for live preview, paints thumbnails for the deck, exports PNGs, and feeds designed cards into the PDF export.

The pure rendering engine (models, effective design, layout, text, painting) is documented in Designer Engine and Designer Composition; frame pack format in Frame Packs. The React side is in Designer UI. A general tour is in Designer Overview.

Boundaries

Layer Lives in Knows about
Engine (pure) src-tauri/crates/proxy-core/src/diseno/ CardDesign, Collection, DisenoEfectivo, render, render_capa; no Tauri, no network, no DB
App layer (this page) src-tauri/src/diseno/*.rs, src-tauri/src/commands/diseno.rs Scryfall, blob store, SQLite (presets, blob_refs), disk resources, pbimg://
Protocol src-tauri/src/protocol.rs /d/, /c/, /b/, /x/ routes that call into diseno::vista
Screen src/features/disenar/ Only talks to Rust through call() in src/lib/ipc.ts

src-tauri/src/diseno/mod.rs declares: arte, borrador, descargables, idioma, letras_cjk, letras_libres, lote, original, pintar, png, portapapeles, recursos, traduccion, vista. The downloadable-resource modules (descargables, letras_cjk, letras_libres, original) are summarised below; the pack format they install is in Frame Packs.

File Responsibility
diseno/borrador.rs Scryfall card (+ Spanish printing, + the printing in the deck's design language) to CardData, then to a DesignDraft
diseno/idioma.rs "Complete a design language": fetches the missing language for every designed card of the deck, as a cancellable job
diseno/letras_cjk.rs Downloads the Japanese, Korean and Chinese fonts (Noto Sans JP/KR/SC/TC) on demand
diseno/recursos.rs Recursos: symbols, fonts, packs on disk; per-thread Motor; the decoded-image cache
diseno/pintar.rs Effective design, render hash (huella_de), pintar/pintar_capa, DisenosDelDeck for the PDF
diseno/vista.rs Live preview registry, design_save/rerender/renumber logic, overlay layer, reduced art
diseno/arte.rs The four sources of artwork (upload/blob, clipboard, Scryfall art_crop, crop of a finished card); for two-half cards, the requested half of Scryfall's double art_crop (costura_de)
diseno/lote.rs "Design the whole deck": drafts in bulk as a cancellable job
diseno/png.rs Export designed cards as PNG files
diseno/traduccion.rs Experimental "suggested translation" into any printed language by matching identical official phrases
diseno/portapapeles.rs Reads an image from the system clipboard (GTK on Linux, arboard elsewhere)
commands/diseno.rs The thin Tauri command layer over all of the above

Commands

All are registered in src-tauri/src/lib.rs and typed in src/lib/ipc.ts. Errors are AppError { code, params } (see IPC and Error Handling).

Drafts, collections, saving

Command Purpose
design_draft(card, usados, collection, lang?) Build a DesignDraft for a deck card; lang (default Spanish) is the deck's design language, whose printing is fetched too (borrador::datos_de) and whose texts are pre-filled (borrador::poner_textos). Fails with InvalidInput { motivo: "sin_vincular" } if the card has no Scryfall id (borrador::id_scryfall). usados = footer numbers already in the deck, so the next free number can be chosen.
collection_list / collection_save / collection_delete CRUD on collections, stored in presets (kind = 'collection') by src-tauri/src/store/colecciones.rs.
design_save(card, design, lang) Paint the thumbnail(s), store them in the blob store and return the new DeckCard. Does not write the deck; the screen applies it.
design_remove(card) Restore the card from design.before. Registered, but the screen currently uses its own quitarDiseno in src/features/disenar/grupos.ts (same logic), so this command is unused by the UI.
design_rerender(cards, lang) Re-paint thumbnails whose thumb_key no longer matches the current render hash; returns only changed cards.
design_renumber(cards, lang) Renumber footer numbers by deck order; returns only changed cards (already re-thumbnailed).
design_drafts_for_deck(...) Job: drafts for all undesigned cards (see Bulk drafts). Returns a job_id.
design_complete_lang(cards, lang) Job: complete the design language lang on the designed cards (see Completing a design language). Returns a job_id.
design_export_png(...) Job: PNG export. Returns a job_id.

Preview, structure and warnings

Command Purpose
design_register(design, cara, lang) Register the design being edited; returns the render hash used in pbimg://…/d/<hash>/<width>.
design_overlay(design, cara, lang, ancho) The card without its artwork plus the artwork hole (CapaInfo), or None. Rejects widths outside protocol::ANCHOS with InvalidInput.
design_fit_report(design, lang) List of Aviso (text does not fit, missing symbols or fonts, collection lost, ...).
design_structure(design, cara, lang) proxy_core::diseno::maquetacion::resumen: layout kind and how the text is split (abilities, chapters, levels). Synchronous and pure.
design_suggest_translation(design, cara, lang) traduccion::sugerir for one face into lang. Rejected with InvalidInput { motivo: "idioma" } for English and for the languages without printed cards (DesignLang::impresas() false: Romanian, Polish, Dutch, Ukrainian), where there is nothing to match against.

Resources

Command Purpose
fonts_list, font_import(path), font_remove(id) User fonts (.ttf/.otf), identified by SHA-256 of the file.
packs_list, pack_import(path), pack_remove(id) Frame packs from a folder or .zip.
fonts_free_list, font_free_download(id) The OFL catalogue in src-tauri/assets/letras-libres.json (diseno/letras_libres.rs).
original_info, original_download "Original" frames (Card Conjurer layers), downloaded on demand; progress on event original://progress (diseno/original.rs).
packs_downloadable_list, pack_downloadable_download(id), pack_extra_use(id, ruta) Packs from the packs repository (Balatro), manifest src-tauri/assets/packs-descargables.json; progress on packs://progress (diseno/descargables.rs). pack_extra_use copies an extra (back, token, finished card) into the blob store and returns a FaceImage.
symbols_status(refresh) Ensure Scryfall mana symbols are present (downloads if missing or older than 30 days, or if refresh), then return SimbolosInfo { cargados, fecha }.

Artwork

Command Source (ArtSource)
art_from_blob(blob) Upload: a file already imported with blob_import_files (the screen imports the picked file first).
art_from_clipboard Upload: image from the system clipboard, read by Rust because WebKitGTK refuses navigator.clipboard.read(); None if the clipboard has no image.
art_from_scryfall(scryfallId, cara) ScryfallArtCrop { url }: the face's art_crop, falling back to the card-level one, then face 0; NotFound { motivo: "sin_arte" } if none. For a two-half card (Scryfall layout: "split": split, Fuse, Room, aftermath) whose face has no art_crop of its own, the card-level image holds both illustrations side by side, so diseno::arte::desde_scryfall cuts out the requested half (cara 0 or 1) with imaging::mitades::mitad and stores it as a PNG blob; the source is still ScryfallArtCrop { url }. costura_de picks the seam: COSTURA_SECUELA for aftermath (keyword Aftermath, or a face whose Oracle text starts with "Aftermath"), COSTURA_PARTIDA otherwise; see Imaging-and-PDF. Flip, adventure and Preparar cards are not split, so every face gets the whole image. Ignored live test mitades_en_vivo (Fire // Ice, Dusk // Dawn, Akki Lavarunner).
art_from_card(blob, x, y, w, h) CardCrop { blob }: crops a fraction rectangle out of a finished card image; crops smaller than 8 px on a side fail with InvalidInput { motivo: "recorte_vacio" }.

Every ArtRef starts with ArtFraming::default() (zoom 1, offsets 0, rotation 0). The fresh image goes to the blob store with ImageOrigin::Upload.

Heavy work off the async runtime

commands/diseno.rs → en_hilo(app, f) runs closures on spawn_blocking with the AppState, because renders are CPU heavy. Preview-path commands (design_register, design_overlay, design_fit_report, design_save, design_rerender, design_renumber, art_from_clipboard's decode step, art_from_card) go through it. Network-bound ones (design_draft, art_from_scryfall, design_suggest_translation) are plain async.

Drafts (borrador.rs)

flowchart LR
  A["design_draft"] --> B["carta_completa (cache or Scryfall)"]
  B --> C["impresion_es"]
  B --> D["fundida (meld only)"]
  C --> E["datos -> CardData"]
  D --> F["con_fundida"]
  E --> F
  F --> L["impresion(lang) + anadir_idioma (other languages only)"]
  L --> G["montar -> DesignDraft"]
  G --> T["poner_textos(lang)"]
Loading
  • carta_completa(db, api, id) reads the Scryfall cache and uses it only if ScryCard::completa_para_disenar(); otherwise it fetches the card again and re-caches it (old cache entries lacked fields such as full_art).
  • impresion(db, api, card, lang) returns the printing in lang used as the source of that language's official text; impresion_es is the same call for Spanish (kept because the Spanish data are always fetched, as before there were more languages). It returns None without searching for English (the Oracle is the English text) and for the four languages without printed cards. Order in the code (buscar_impresion): the card itself if its lang is already that code; otherwise the most recent printing found with oracleid:<id> lang:<code> that has printed text (the current wording, which fixes old templates); otherwise the same set + collector number (cache, then card_lang). With none, the draft falls back to the Oracle text. Some Spanish printings carry the printed text but no printed_type_line (Scryfall leaves it empty, e.g. the Final Fantasy Commander printings); completar_tipo then fills each missing line from another card in the same language whose English type_line is identical (traduccion::traducir_tipo), so a land is not shown as "Land". Offline, or with no such card, the line comes from proxy_core::diseno::borrador::tipo_traducido(en, lang): Spanish uses tipo_en_espanol (a single card type with Basic/Legendary and known subtypes), the other languages the whole-phrase tables of proxy_core::diseno::tipos_idiomas (see Designer Engine); both return None when something is unknown (creatures, tokens, mixed types), in which case the English line stays. Designs saved with the English line in a translated slot are repaired by arreglar_tipos_es and arreglar_tipos(card, lang) when vista::rehacer runs on opening a deck. Tests: una_impresion_sin_linea_de_tipo_la_toma_de_otra_carta_con_el_mismo_tipo, en_frances_se_busca_la_impresion_francesa_y_en_rumano_nada.
  • datos(en, es, ahora) builds CardData: a face per entry of card_faces for the layouts in por_caras (split, flip, transform, modal_dfc, adventure, meld, battle, double_faced_token, reversible_card) when there are at least two faces; otherwise a single face. Spanish type line, rules and name come from the Spanish printing's printed_* fields; missing Spanish falls back to the English text. sin_texto_es is true when there is no Spanish printing or it has no printed text while the Oracle has text.
  • anadir_idioma(card_data, en, lang, printing) adds one more language (never Spanish or English, which datos does) to an existing CardData: per face the printed type line (or tipo_traducido, or English), the printed rules (without a printing the language gets no rules of its own and ByLang::get falls back to the Oracle), and the printed name (FaceData::poner_printed_name); then CardData::poner_sin_texto(lang, …) with the same rule as sin_texto_es. The fused face of a meld stays in English. Only the deck's design language is added, not all 15: each language is one more Scryfall search per card.
  • datos_de(db, api, en, lang) is the whole snapshot used by design_draft and bulk drafts: English, Spanish, the fused meld card and, when lang is another language, that language.
  • poner_textos(design, lang) gives a design the FaceTexts of a new language (not Spanish or English) on faces that lack them: a themed name, if one was typed in English or Spanish (a name different from the official one), is copied; otherwise the printed name in lang, or the English one. Flavour text is copied from English, or from Spanish. Reason: a themed card renamed by the user keeps its name when the deck switches language; rules are never copied, so they come from the official printing.
  • Gotchas handled in cara_de/sin_pestana_mdfc: some printings put the whole card name ("Dusk // Dawn") in each face's printed_name (ignored); Spanish MDFC printings append the other face's type tab to each face's rules (stripped, because the engine paints the tab itself).
  • Meld: fundida fetches the meld_result part, and con_fundida appends it as a second face and sets CardData.meld_arriba (whether this part carries the top half; decided by matching collector numbers like 15a/15b).
  • montar(card, datos, coleccion, usados) produces the initial CardDesign:
    • style: FullArt for "Basic Land" cards, else the collection's default_style, else Borderless. estilo_pack comes from the collection's estilo_pack_defecto except for basic lands.
    • One FaceDesign per face with the name pre-filled in Spanish and English (Spanish uses printed_name_es); design_draft then calls poner_textos for any other design language.
    • before snapshots source/front/back/edits of the deck card so "Remove design" can restore it.
    • footer.number = proxy_core::diseno::borrador::siguiente_numero(usados, es_ficha) (0001…, tokens T001…).
    • options.show_flavor = true, status = Draft, thumb_key = None.
    • Aviso::SinTextoOficial is added when datos.sin_texto_es.

borrador::en_vivo (ignored network tests)

#[cfg(test)] mod en_vivo holds #[ignore] tests that need the network:

  • datos_de_layouts downloads a fixed list of ~28 real cards (Liliana of the Veil, Urza's Saga, Fire // Ice, Bonecrusher Giant, Valakut Awakening, Invasion of Zendikar, Bruna the Fading Light, planes, a vanguard, a dungeon, an emblem, ...) and writes their CardData JSON to src-tauri/crates/proxy-core/tests/dorados/layouts/datos/. Those fixtures feed the golden layout tests of the engine. Run with cargo test --lib borrador::en_vivo::datos_de_layouts -- --ignored from src-tauri/.
  • progenitus_y_arcades_en_espanol checks the Spanish-printing search live; llanowar_elves_en_aleman checks the German name and type line.

Resources (recursos.rs)

Recursos (held as AppState.diseno) is opened once from app_data_dir by Recursos::abrir, reading only the disk (no network).

On disk (app_data_dir) Content
simbolos/ One SVG per Scryfall symbol plus indice.json ({ fecha, simbolos: code -> file }). File name = sanitised code + 8 hex chars of its SHA-256.
fuentes/<sha256>.<ext> Imported fonts (ttf/otf), id = SHA-256 of the file contents.
marcos/<id>/ One folder per imported or downloaded pack (pack.json, SVG and PNG layers).
letras-cjk/<code>.ttf Downloaded Japanese/Korean/Chinese fonts (ja, ko, zhs, zht), plus Noto-CJK-OFL.txt.

Internals:

  • State is an RwLock<Estado> with the symbols (Arc<Simbolos> + a content hash), packs (BTreeMap<String, Arc<Pack>>) and fonts. The built-in pack ("builtin") comes from Pack::de_fabrica() and cannot be removed or overwritten (InvalidInput { motivo: "pack_id" }).
  • instantanea(pack) returns an Instantanea { simbolos, pack, huella, generacion, huellas_cjk }. An unknown pack id silently falls back to builtin (the caller is responsible for warning; the engine emits Aviso::EstiloNoEnElPack/others). The pack's own symbols, if any, are layered on top of Scryfall's (Simbolos::con_propios). huella = pack.huella | symbols-hash | comma-joined font ids and feeds the render hash, so changing a font, pack or the symbol set invalidates every cached render. The CJK fonts are deliberately not in huella: Instantanea::huella_para(lang) appends |cjk:<hash> only for the language whose font it is, so downloading the Japanese font re-renders only the Japanese cards (which were showing boxes) and leaves every other card's hash, and cached render, untouched.
  • generacion (an AtomicU64) is bumped whenever fonts change. The text/paint Motor is kept in a thread_local! (MOTORES, a small stack of motors per thread); con_motor pops one, drops those whose stored generation differs, builds a new one if none is left (reloading all user fonts and every downloaded CJK font, Letras::cargar_cjk with the language's Escritura) and pushes it back after the render. The motor is out of the cell while it paints because the engine resizes with fast_image_resize on rayon: while a worker of the export pool waits for that resize, rayon can hand it another card of the same job, which calls con_motor again on the same thread. Holding a RefCell borrow across the render made that a RefCell already borrowed panic that left the export stuck; now the nested render gets a second motor, kept for later. leer_cjk loads letras-cjk/<code>.ttf at start-up; guardar_cjk writes a new one atomically, adds it to the state and bumps generacion, so every thread's motor picks it up. Reason: the engine holds mutable font state that cannot be shared across threads cheaply.
  • asegurar_simbolos(api, forzar): skips the download if the index is under 30 days old and non-empty (CADUCA_SIMBOLOS); with no network it logs a warning and keeps whatever exists (never an error). Writes are atomic (store::blobs::escribir_atomico).
  • importar_pack(ruta) accepts a folder or .zip (leer_zip strips a single wrapping folder by locating pack.json; reads only .json/.svg up to 8 MiB and .png up to 32 MiB per file, skipping paths with ..; oversize gives InvalidInput { motivo: "pack_grande" }). The pack is validated by Pack::cargar_con_imagenes first; any PackError becomes InvalidInput { motivo: "pack_invalido", ... } with the detail in params (de_pack). The id is sanitised to [A-Za-z0-9_-].
  • leer_packs re-applies the current definition of the "Original" frames on every load (original::definicion_al_dia) so positions/layers match this app version even if the images were downloaded by an older one. That pack is loaded with original::cargar (here and in recargar_pack): when images the new definition asks for are not on disk yet, it keeps the styles that are complete instead of failing, and original::completar (spawned from setup) downloads the rest. See Frame Packs.
  • quitar_letra / quitar_pack delete files then bump state; cards that used them fall back to house resources (and show a warning).
  • ImagenesDelAlmacen { db, blobs, cache } implements the engine's Imagenes trait: blob hash -> Arc<RgbaImage> via BlobStore::locate, decoding with proxy_core::imaging::decode::decode. CacheImagenes keeps the last 12 decoded images (MAX_CACHE), FIFO eviction, because artwork is large.

PackInfo (exposed to the screen) lists estilos and estilos_propios: Vec<EstiloPropio> — the pack's own styles (those not in proxy_core::diseno::pack::ESTILOS_DE_SERIE) with id, nombres, a base built-in style and variantes (VariantePack { id, nombres }). nombres is the pack's map language code -> name, passed through as is; the screen picks the interface language, then English, then the id (nombreEnIdioma in src/features/disenar/estilos.ts). See Frame Packs.

Painting and the render hash (pintar.rs)

  • colecciones_por_id(state) loads all collections once per job; efectivo_de(design, cols) resolves a design against its collection (proxy_core::diseno::efectivo::efectivo). A deleted collection yields factory values and coleccion_perdida = true.
  • huella_de(state, e, cara, lang) = efectivo::huella(e, lang, cara, inst.huella_para(lang)): SHA-256 over the serialised effective design, language, face, resource hash and the engine version VERSION_MOTOR. It is the identity of "what this render looks like".
  • pintar(...) takes the Instantanea, builds Recursos { pack, simbolos, imagenes } and calls the engine's render inside con_motor; any engine error is mapped to ErrorCode::Image { detalle }. pintar_capa is the same for render_capa.
  • Salida::Pantalla yields transparent rounded corners (preview); Salida::Impresion yields opaque square corners (thumbnails, PDF, PNG). A custom card corner radius (FineTuning.card_radius) applies to both outputs (componer.rs).

Live preview and pbimg:// URLs (vista.rs + protocol.rs)

Design: no image bytes cross IPC. The screen registers the design being edited, gets a hash, and sets an <img src="pbimg://…/d/<hash>/<width>">. WebKit caches by URL, and the protocol response carries Cache-Control: max-age=31536000, immutable, which is safe because the URL contains everything that changes the image.

Route Handler Content
/d/<huella>/<ancho> vista::imagen PNG of the registered design, Salida::Pantalla
/c/<huella>/<ancho> vista::imagen_capa PNG of the card without artwork (overlay)
/b/<blob>/<lado> vista::arte_reducido Artwork reduced to lado px on its long side (LADOS_ARTE = [1000, 2000])
/x/<pack>/<n> descargables::extra_por_indice An extra image of a downloadable pack
/r/…, /t/…, /f/… other modules Not part of the designer
  • Allowed widths are protocol::ANCHOS = [146, 244, 488, 672, 745, 1000] (mirrored by ANCHOS in src/lib/image-url.ts); anything else is a 400. Hashes are validated with es_huella (64 hex chars). The frontend builds URLs with urlDiseno, urlCapa, urlArte in src/lib/image-url.ts.
  • Each request takes a permit of AppState.renders (a tokio Semaphore) so many parallel <img> requests do not saturate the CPU, then renders in spawn_blocking.
  • Vistas is a FIFO map hash -> (DisenoEfectivo, cara, lang) capped at MAX = 64 entries. registrar stores it; /d/ looks the hash up. An unknown hash returns NotFound (HTTP 404). Hence the registry is the reason a pbimg://…/d/ URL only works while the design is registered in this process.
  • Disk cache: app_cache_dir/renders/d/<hash>-<width>.png, renders/c/…, renders/b/…. It is a derived cache and may be deleted at any time (architecture rule: the cache holds only derivatives). It is not invalidated explicitly: new inputs mean a new hash.
sequenceDiagram
  participant UI as use-borrador
  participant C as design_register
  participant V as Vistas (64 entries)
  participant P as pbimg:// /d/
  participant R as pintar + Motor
  UI->>C: design, cara, lang (as fast as the last call finishes)
  C->>V: meter(huella, efectivo, cara, lang)
  C-->>UI: huella
  UI->>P: img src = /d/huella/ancho
  P->>V: get(huella)
  P->>R: render Salida::Pantalla
  R-->>P: PNG (cached under renders/d)
  P-->>UI: bytes
Loading

Moving the artwork without waiting for Rust: design_overlay + render_capa

Dragging the artwork would otherwise cost a full render per mouse move. Instead:

  1. vista::capa(state, design, cara, lang, ancho) resets the ArtFraming of the face being moved (cara) to default on the effective design before computing the hash, so the layer stays valid while only that framing changes. The other faces keep theirs: on a composed card the layer of one part has the other part's artwork painted in it, and resetting it made part 1 jump back to its initial framing while part 2 was selected.
  2. It registers that design in Vistas (so /c/<hash>/<w> can paint it), and if renders/c/<hash>-<w>.png and renders/c/<hash>.json exist it returns the cached CapaInfo.
  3. Otherwise pintar_capa calls the engine's render_capa ("the card painted once without that part's artwork, plus where the artwork goes"; render_capa in src-tauri/crates/proxy-core/src/diseno/componer.rs). Composed and big cards get a layer too (the whole card with that part's art window transparent, see Designer-Composition); it returns None only when the part has no artwork of its own (part 2 of a flip or adventure), for the meld back, or when the face has no artwork. Otherwise the app writes the PNG and the JSON. The hash includes the requested part, so the two parts of a split card never share a cached layer.
  4. CapaInfo { huella, arte: [x, y, w, h], extension, fondo, radio: [rx, ry], cara, giro } is in fractions of the card: the artwork hole as seen on the card, the corner radii and giro, the rotation of the artwork inside the hole in degrees (90 aftermath bottom half, -90 split halves; #[serde(default)], so layers cached before it read as 0).

The frontend composition (see Designer UI): a black box with rounded corners, the artwork <img src=/b/<blob>/<1000|2000>> placed in the hole with CSS using the same maths as the engine's pintar_arte (geometriaArte in src/features/disenar/encuadre.ts), and the overlay <img src=/c/<hash>/<w>> on top. When dragging stops, the exact Rust render replaces it.

Warnings (design_fit_report)

vista::avisos paints every face (0..faces.len()) with Salida::Impresion at width 300 and merges each render's avisos without duplicates, adding Aviso::ColeccionPerdida if the collection was deleted. It renders without caching, which is why the screen debounces it by 400 ms.

Saving, re-thumbnailing and renumbering (vista.rs)

aplicar(state, card, design, lang):

  1. Resolve the effective design; clave = huella_de(.., cara 0, ..).
  2. Paint face 0 at ANCHO_MINIATURA = 1000 px with Salida::Impresion, encode JPEG quality 92 and store it with ImageOrigin::Designed.
  3. If the layout prints two faces (d.faces.len() >= 2 && maquetacion(&d.card).0.caras_impresas() == 2), paint face 1 too and set back = BackSpec::Dfc { image }. Otherwise keep d.before.back, except a former Scryfall DFC back becomes BackSpec::DeckDefault.
  4. Return a clone of the card with source = CardSource::Custom { scryfall_id: Some(..), externa: None }, front = thumbnail, edits = default, meta.is_dfc updated and design = Some(design with thumb_key = clave).

The deck itself is not written: the screen swaps the card in with one setCartas so undo works.

  • Consequence: a designed card behaves in the deck like a custom card whose image is the 1000 px thumbnail. Layout, stats and preview need no special case; only the PDF re-paints from the design.
  • thumb_key records the hash that produced the thumbnail. rehacer compares it with the current hash (so a changed collection, font, pack, symbol set or engine version triggers a re-thumbnail) and only re-applies mismatches. The screen calls rehacerMiniaturas() when opening a deck and when the collections/resources dialogs close.
  • renumerar walks the deck in order, skips designs with footer.number_manual, assigns 0001… to cards and T001… to tokens (CardData::es_ficha), and gives copies (same scryfall_id) the same number. It re-applies only cards whose number changes.
  • quitar(card) restores source/front/back/edits from design.before. con_estado (set Ready/Draft) exists but has no callers.

Bulk drafts: "Design the deck"

design_drafts_for_deck(cards, usados, collection, lang) allocates a job (state.jobs.alta() → (job_id, cancelado)), spawns an async task holding the state.trabajo() guard (marks a long job running, which blocks e.g. blob_gc with Busy), and returns the job_id. lote::borradores then, in deck order:

  • Stops with Cancelled if the flag is set (job_cancel).
  • For each card with no design and a Scryfall id: builds the draft with borrador_de (same pipeline as design_draft plus Scryfall art_crop artwork asked for each face index: halves of split, Fuse, Room and aftermath cards each get their own piece, while flip and adventure faces get the same image because Scryfall has a single one; for melds the second face has no art of its own), applies it with vista::aplicar in spawn_blocking, and emits design://progress (DesignProgressEvent { jobId, done, total, card?, failed }).
  • Copies of the same Scryfall card reuse the first draft (hechos map) so they share the design and number; usados grows with each new number.
  • Network and RateLimited errors abort the whole job; other per-card errors are logged, reported as failed: true, and the job continues.
  • At the end design://done (DesignDoneEvent { jobId, error? }) is emitted.

The dialog src/features/disenar/lote-dialog.tsx collects all cards and applies them in one setCartas (even when cancelled), so one undo removes them all. src/lib/trabajos.ts → lanzarTrabajo listens before launching so short jobs do not lose their final event.

Completing a design language (idioma.rs)

A design keeps Spanish and English always, plus only the languages it has been shown in (ByLang.otros, see Designer Engine). When the deck's design language changes to a third language, the cards already designed have neither that language's official data nor its texts, so design_complete_lang(cards, lang) fills them in as a job (same jobs.alta() + state.trabajo() pattern as bulk drafts):

  1. If the language needs a downloaded font (letras_cjk::necesita: ja, ko, zhs, zht), letras_cjk::descargar runs first, so that the thumbnails painted afterwards do not show boxes. For Spanish and English nothing else happens.
  2. Only designs for which idioma::le_falta(design, lang) holds are processed: !card.tiene_idioma(lang) (no printing data and not known to lack one) or some face without texts in that language.
  3. completar: if the data are missing, borrador::carta_completa + borrador::impresion(lang) + borrador::anadir_idioma; then borrador::poner_textos. The result is applied with vista::aplicar (new thumbnail in that language) in spawn_blocking.
  4. Copies sharing the same design (same JSON) are completed once (hechos).
  5. Each card is emitted on designlang://progress (DesignProgressEvent, the same shape as bulk drafts); the end on designlang://done (DesignDoneEvent). Network and RateLimited errors stop the job (it can simply be launched again later; the cards keep the English fallback and the warning meanwhile); other per-card errors are logged and reported as failed: true. job_cancel stops it with Cancelled.

On screen this is completarIdioma in src/features/disenar/renumerar.ts (job type "idioma" in trabajo-store), which applies the returned cards without history, even after a cancel. ponerIdiomaDiseno (the design-language selectors) and the PNG dialog call it. Test: falta_mientras_no_tenga_datos_ni_textos_del_idioma.

Fonts for Japanese, Korean and Chinese (letras_cjk.rs)

The house fonts cover Latin and Cyrillic, but not CJK. Noto Sans JP, KR, SC and TC (OFL) weigh between about 9.6 and 17.8 MB each, so they are not bundled in the installer: they are downloaded the first time a deck chooses one of those design languages (or from the "missing fonts" warning, which re-selects the language).

  • Catalogue src-tauri/assets/letras-cjk.json: a fixed commit of the google/fonts repository, base ofl/, and per language its family, path (notosansjp/NotoSansJP[wght].ttf…), SHA-256 and size, plus the licence file and its SHA-256. Only the Sans: the Serif would double the size.
  • descargar(state, lang) does nothing for a language without an entry or whose font is already on disk; otherwise it downloads from raw.githubusercontent.com (brackets escaped in the URL), reading in chunks with a 32 MiB cap (TOPE), and rejects anything whose SHA-256 is not the catalogue's (Network { motivo: "huella" }). The licence goes to letras-cjk/Noto-CJK-OFL.txt and the font to letras-cjk/<code>.ttf via Recursos::guardar_cjk (under app_data_dir, see Storage and Blobs).
  • The engine registers each font as a fallback for its script and locale, so the same ideograph gets the Japanese or the Chinese form depending on the card's language (see Designer Engine). Without the font the render carries Aviso::FaltanLetras { lang }.
  • Tests: el_catalogo_trae_los_cuatro_idiomas_de_asia; ignored live test baja_la_japonesa.

PNG export (png.rs)

design_export_png(cards, folder, card_size, dpi, langs) accepts only 300, 600 or 900 dpi and a non-empty langs (InvalidInput { motivo: "png" }), then runs in spawn_blocking as a job emitting designpng://progress and designpng://done (files, error?).

  • tareas builds the work list. Two copies with the same design count once: the key is the design JSON with thumb_key, status and before neutralised. For each language and each printed face (caras_impresas) one task.
  • Size: componer::medida(&effective, card_size) gives millimetres; width px = mm / 25.4 * dpi. Painted with Salida::Impresion at exactly the cut size.
  • Layout on disk: one folder per language, named by its code (<folder>/es/, <folder>/en/, <folder>/fr/…, carpeta); file name "<number> - <name>.png" (nombre_de_fichero replaces " // " with " - " and OS-illegal characters with -); multi-face cards add (1), (2); collisions get ~2, ~3.
  • Test png_a_su_medida_y_siempre_iguales pins 744×1039 px at 300 dpi for 63×88 mm, byte-identical re-exports and a third language in its own folder.
  • Rust paints whatever data the design has: a language that was never completed falls back to English. That is why the PNG dialog (src/features/disenar/png-dialog.tsx) first runs completarIdioma for each chosen language other than Spanish and English, and only then launches the export.

How the PDF paints designed cards

flowchart TD
  X["commands/export.rs exportar"] --> D["DisenosDelDeck::de(state, deck)"]
  D --> E["encargo(): per slot RenderSpec"]
  E -->|"referencia() returns DisenoRef"| S["spec.diseno = Some; edits default; edge None"]
  S --> R["render_diseno_para_pdf"]
  R --> P["pintar(Salida::Impresion, width = trim px)"]
  P --> B["render_desde_imagen: bleed + encode"]
Loading
  • DisenosDelDeck::de computes the effective design of every designed card (loading collections only if any card has a design) and records deck.settings.design_lang.
  • cara_de_casilla(card, slot) says which face goes on a slot: Front -> face 0; DfcFace/Back -> face 1 only if the card has BackSpec::Dfc and the design has two faces; otherwise None (the deck's default back is not part of the design, and a DFC back with a single-face design is not repainted).
  • referencia produces a DisenoRef { huella, cara, lang } for the slot's RenderSpec.diseno, so identical designs/faces dedupe through RenderSpec::key() and render once.
  • render_diseno_para_pdf paints at the trim width in pixels for the export dpi (trim_px(spec.card, spec.target)), then render_desde_imagen adds bleed and encodes. The deck thumbnail is not used for printing; the 1000 px JPEG only serves screens.
  • Designed cards bypass user retouches and edge darkening (spec.edits = default, spec.edge = None). The deck preview, however, requests the thumbnail through /r/… with card.edits (src/features/preview/slot-view.tsx, spec_de_miniatura), so a retouch made with "Edit card" (Card Editor Dialog) on a designed card shows on screen but never reaches the PDF.
  • The language used is the deck's settings.design_lang (English for new decks, Spanish for decks saved without it; it travels in .pbdeck). It is written by the "Language of designed cards" selector of the export panel and by the language selector of the Diseñar preview, both through ponerIdiomaDiseno (src/features/disenar/renumerar.ts), which completes the language and re-thumbnails the deck.

More on the PDF pipeline in Imaging and PDF and Export.

Suggested translation (traduccion.rs)

Experimental, never applied automatically. For a face with no printing in the requested language (any language with printed cards; the code is passed as codigo):

  1. frases(texto) splits the Oracle text per line into sentences at a period followed by a space, outside parentheses and quotes; a whole reminder text in parentheses is one sentence. Japanese and Chinese end sentences with 「。」 (and use full-width parentheses), so that character also ends a sentence, and when the result is reassembled the sentences are joined without a space in ja, zhs and zht.
  2. The card name (and its legendary short name from proxy_core::diseno::borrador::nombre_corto) is replaced by ~ so searches match other cards.
  3. For each sentence, traducir_frase searches Scryfall o:"<sentence without final period>" lang:<code> order:released (30 results), keeps prints in that language with printed text, newest first, and alinear takes the sentence at the same index in that card's printed text. Alignment only counts when the Oracle and printed texts split into the same number of sentences.
  4. A bare keyword list ("Vigilance, reach") that finds nothing is translated word by word.
  5. The type line is searched as t:"<type>" lang:<code>; the result must have an identical English type line.
  6. Output Sugerencia { tipo?, reglas, frases: [{ en, traducida? }], traducidas, total } (FraseSugerida.traducida is the sentence in the requested language); untranslated sentences stay in English and are flagged in the UI. 4xx responses from Scryfall's query parser are treated as "not found" for that sentence.

Cost: one search per distinct sentence (memoised by hechas), so it is slow. Tests: frases_fuera_de_parentesis_y_comillas, listas_de_palabras_clave, el_nombre_va_como_tilde, alinear_por_posicion, en_frances_se_busca_en_frances, el_japones_acaba_las_frases_en_su_punto, and a live veinte_cartas_sin_espanol.

Protecting images from garbage collection (blob_refs)

The blob store deletes orphaned images in BlobStore::gc (command blob_gc, refused with Busy while a long job runs). A blob is orphaned if it is older than 24 h (GRACIA_MS) and not referenced by any deck_cards.front_blob/back_blob, decks.default_back_blob, the generic-back setting, or any row of blob_refs. See Storage and Blobs.

Designs reference images that are neither a card's front nor back, so the table blob_refs (owner_kind, owner_id, blob) (migration src-tauri/src/store/migrations/0002_blob_refs.sql) records them:

owner_kind owner_id Written by Blobs
deck deck id store/decks.rs on every deck save (refs_del_deck -> escribir_refs) CardDesign::blobs() of every designed card: each face's artwork image, the source blob of a CardCrop art, an own emblem, and before.front / before.back images
preset collection id store/colecciones.rs save the collection's emblem

Details and invariants:

  • Deck save rejects the whole save with InvalidInput { motivo: "blob_missing", blob } if any referenced blob does not exist; collection save does the same for its emblem.
  • Refs are rewritten from scratch inside the save transaction (DELETE then INSERT OR IGNORE), and removed on deck/collection delete.
  • rellenar_refs backfills the table once from already stored decks, tracked by an app_settings key blob_refs_v1 (an "is the table empty" check would be wrong, since a deck without designs leaves it empty).
  • The .pbdeck package includes the same set of blobs (src-tauri/src/package.rs uses d.blobs()), see Deck Package Format.
  • New artwork has a 24 h grace period (imported before the card is saved), so drafts survive being edited for a while without a deck referencing them.
  • The designer's thumbnails are stored as normal front blobs of the card, so the regular deck references protect them.

Failure modes

Situation Result
Card not linked to Scryfall InvalidInput { motivo: "sin_vincular" } from design_draft
No network while building a draft Network; bulk job aborts, single card shows an error toast
Unknown render hash on /d/ or /c/ NotFound -> HTTP 404 (the design was evicted from the 64-entry registry or never registered)
Width/side not in the allowed lists InvalidInput (command) or HTTP 400 (protocol)
Engine render error Image { detalle }
Invalid pack InvalidInput { motivo: "pack_invalido" } + detail params
Font not .ttf/.otf or unparsable Unsupported
Symbols missing and offline No error; render shows Aviso::FaltanSimbolos
CJK design language without its font Render shows Aviso::FaltanLetras { lang } (boxes instead of text); choosing the language again downloads it
CJK font download corrupted or too large Network { motivo: "huella" } / Network { motivo: "tamano" }; nothing is written
Offline while completing a design language Network; the job stops, the cards keep the English fallback and Aviso::SinTextoOficial
Suggested translation for English or an unprinted language InvalidInput { motivo: "idioma" }
Clipboard has no image art_from_clipboard returns null (the screen shows an info toast)

Tests that guard this layer

  • src-tauri/src/diseno/borrador.rs: dusk_dawn_en_espanol_da_dos_caras_con_secuela, sin_impresion_espanola_avisa, una_basica_va_en_paisaje, una_impresion_sin_linea_de_tipo_la_toma_de_otra_carta_con_el_mismo_tipo, en_frances_se_busca_la_impresion_francesa_y_en_rumano_nada.
  • src-tauri/src/diseno/idioma.rs: falta_mientras_no_tenga_datos_ni_textos_del_idioma; src-tauri/src/diseno/letras_cjk.rs: el_catalogo_trae_los_cuatro_idiomas_de_asia.
  • src-tauri/src/diseno/vista.rs: vista_previa_guardar_y_quitar, capa_para_mover_la_ilustracion, numeros_de_un_deck_y_renumerar.
  • src-tauri/src/diseno/pintar.rs: slot-to-face mapping, effective design lookup, hash sensitivity (la_huella_cambia_con_la_cara_y_el_idioma), DisenosDelDeck, pintar_una_cara_da_una_imagen_del_ancho_pedido.
  • src-tauri/src/diseno/png.rs, arte.rs (pegar_y_recortar; ignored mitades_en_vivo), recursos.rs (letras_y_packs_importados), traduccion.rs.
  • src-tauri/src/protocol.rs: la_ruta_buena_se_entiende, las_rutas_raras_se_rechazan, and others.
  • src-tauri/src/commands/export.rs tests that designed cards get a DisenoRef per face in the export plan.
  • src-tauri/src/store/colecciones.rs: rename keeps id, empty name or missing emblem are rejected.

Run with pnpm test:rust (see Testing and CI).

Where to start when extending

  • New artwork source: add a function in diseno/arte.rs returning ArtRef with a new ArtSource variant (in proxy-core), a command in commands/diseno.rs, register it in src-tauri/src/lib.rs, add it to src/lib/ipc.ts, then a button in PestanaArte (src/features/disenar/formulario.tsx). Remember CardDesign::blobs() must list any new blob so it is protected.
  • Anything that changes how a card looks: it must be part of the effective design or of Instantanea.huella, otherwise caches and thumb_key will not invalidate.
  • New preview route: add to protocol::Ruta, leer_ruta (validate hashes and sizes), and atender.

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