Repository navigation
Card Identification and OCR
English · Español
A user's own card image (a custom proxy, a scan, a fan-made card) is just a picture: the app does not know which real card it represents. "Link with a card" answers that. Rust proposes candidates from the file name and, optionally, from an OCR reading of the whole card image matched against a local catalogue of Scryfall names; the user reviews and confirms; the card keeps its own image but gains the real card's data, so it counts in statistics, card info and token detection.
Boundaries: Scryfall access is Scryfall-Integration; the Cockatrice/other-language fallbacks used here are External-Sources; the card model is Deck-Model.
| File | Responsibility |
|---|---|
src-tauri/src/commands/identify.rs |
Commands ocr_ready, card_identify, identify_batch, card_link, card_link_externa; the pipeline identificar(); scoring (puntuar, parecido, normalizar); ordering of typed searches. |
src-tauri/src/ocr.rs |
OCR engine (ocrs + rten), model download, line reading, zone split (repartir), footer/type/token heuristics. |
src-tauri/src/originales.rs |
Finding the original English name anywhere on the card (buscar) and reading set / number / language from the footer (pie_leido). |
src-tauri/src/scryfall/nombres.rs |
The local name and set-code indexes these two use (Indice, codigos_de_sets). |
src-tauri/src/scryfall/fichas.rs |
Spanish token type -> English token name (candidatas). |
src/features/variants/vincular.ts |
vincular(), desvincular(), propiasPorImagen(), estaVinculada(): the deck mutation. |
src/features/variants/vincular-dialog.tsx |
Single-card dialog "Link with a card". |
src/features/variants/vincular-lote.tsx |
Bulk dialog "Link in bulk" (phases: choose, read, review, apply). |
CardSource::Custom (src-tauri/crates/proxy-core/src/model.rs) has two optional fields:
| Field | Meaning |
|---|---|
scryfall_id (JSON scryfallId) |
Linked to a real Scryfall printing. |
externa (RefExterna { fuente, id }) |
Linked to a card from another source (a Cockatrice token that Scryfall lacks). |
Both absent = a plain custom image. Design decisions:
-
The image stays the user's. Linking never replaces the picture. The linked card provides identity: name, set, collector number, type line, layout, language, which feed the info panel, the statistics (
id_scryfall()incommands/stats.rsreturns a custom card'sscryfall_id, so cached oracle data, colours, mana value, prices and function tags apply) and "Detect tokens" (tokens created by linked cards). -
externais an optional field, not a new enum variant, so an older app version still opens such a deck and sees a normal custom card. -
CardMeta.image_langrecords the language of the image, which may differ from the linked printing'slang(a Spanish proxy linked to the English printing).vincular()sets it from the footer reading when OCR provides it; it is also editable in the card menu; the library uses it for deck language details (src-tauri/src/biblioteca.rs). -
Copies share the link.
vincular()applies to the chosen card and to every custom copy with the samefront.blob, so one decision links all copies of that image. The bulk dialog also shows one row per distinct image (propiasPorImagen). Relinking a designed card to a different card drops its design:vincular()(viacambiaDeCarta) runsquitarDisenoon it first (back to its pre-design image, back and edits) and then writes the newsourceandmeta. Otherwise the designer kept showing the design built from the previous card, and "Remove design" restoredbefore.source(the old link) over the new one, leavingsourceandmetapointing at different cards. The same link keeps the design. -
Linking keeps the image-specific state. The new
metacomes from the linked card butimageStatusis cleared,highresis kept from the custom card, andlangFallbackis reset (the Scryfall image state does not describe the user's picture). -
Unlinking (
desvincular) only resetssourceto{ kind: "custom" }on the card and its same-image copies; the metadata that linking wrote is left in place (verified invincular.ts: onlysourcechanges).
card_link(scryfall_id) -> CardMeta: from the local cache (cache::leer) if present, else from Scryfall, saving the card into scryfall_cards so statistics work offline; NotFound if Scryfall does not have it. card_link_externa(fuente, id) -> CardMeta reads the Cockatrice catalogue (meta_cockatrice). The front end then writes the result into the deck (vincular() in vincular.ts), so the Rust side does not mutate the deck.
Input: the image blob hash, an optional hint (the file name, or what the user typed), ocr: bool, and escrito: bool ("the hint was typed by a person"). card_identify (single dialog) always passes escrito = true; identify_batch passes false.
Output CardIdentify { lines, candidates, best, confidence, pie }.
flowchart TD
A["hint, blob, ocr, escrito"] --> B["pista = ocr::limpiar(hint), len >= 3"]
B --> C{"ocr?"}
C -- yes --> D["models present? else descargar_modelos<br/>decode blob, spawn_blocking leer_carta"]
C -- no --> E
D --> E["Lectura: titulo, pie, resto, tipo, ficha, lineas"]
E --> F["name index: seguros / aproximados<br/>(pista exact, originales::buscar)"]
E --> G["pie_leido(lineas, set codes)"]
G --> H["card_lang(set, number, en) = del_pie"]
E --> I{"ficha?"}
I -- yes --> J["fichas::candidatas then<br/>search ! name t:token include:extras"]
F --> K["search up to 6 distinct texts<br/>exact name else multilingual in-name"]
H --> K
J --> K
K --> L{"no candidates?"}
L -- yes --> M["fuentes::nombres_ingleses on pista/titulo"]
K --> N{"escrito?"}
N -- yes --> O["multilingual search up to 700<br/>ordenar_por_lo_escrito"]
K --> P["+ Cockatrice tokens"]
P --> Q["puntuar(candidates, seguros + principales)"]
Q --> R["fallbacks: del_pie or first token = Probable"]
R --> S["best moved to index 0"]
Step by step:
-
Hint.
ocr::limpiarstrips symbols, quotes and trailing single letters (mana symbols read as letters); fewer than 3 characters means no hint. -
OCR (only if requested). If the model files are missing they are downloaded first (
descargar_modelos). The blob is read withAppState::leer_blob, decoded to RGBA at full resolution, andleer_cartaruns inspawn_blocking(it is CPU-heavy and synchronous). Details below. -
Priority of texts.
principales= hint, then footer names (leido.pie), then title lines. The footer comes before the title because custom cards often invent their title, while the footer carries the original English name. -
Local name index (
nombres::indice, no network after the first download): for the hint,exactothenal_principio; for the OCR lines,originales::buscarreturns real card names found anywhere, split intoseguros(exact hits) andaproximados(found by similarity). If nothing was found at all, the closest name to the hint becomes an approximate hit (one wrongly typed letter). -
Footer (
pie_leido): set code, collector number and language, using the set-code index (codigos_de_sets). If both set and number exist,card_lang(set, number, "en")fetches that exact printing as a candidate (del_pie). -
Tokens. If the reading says the card is a token (
Lectura.ficha),fichas::candidatasturns the type line ("Ficha de criatura - Guerrero") or a one-word title into an English token name from the table inscryfall/fichas.rs, and Scryfall is searched with!"Warrior" t:token include:extras. Exact-name results go first (so "Dinosaur // Treasure" does not precede "Treasure"). The first token found is remembered asprimera_ficha. -
Search.
seguros,aproximados, thenprincipalesare searched; at mostMAX_TEXTOS = 6distinct normalised texts, stopping atMAX_CANDIDATAS = 12cards. Each text is looked up as an exact English name (limit 4, withinclude:extras -layout:art_series -layout:front_card), and if that is empty, as a name fragment in any language (search_multilingual, limit 6, one result per card). -
No candidates at all. The text may be a name printed in another language for a card Scryfall has no printing in that language (old Spanish cards). The first two texts (hint, title) go through
fuentes::nombres_inglesesusing the footer's language (defaultes), and those English names are searched. -
Typed searches (
escrito): for each hint, every card with the text anywhere in its name (any language), up toMAX_ESCRITO = 700(four Scryfall pages), ordered byordenar_por_lo_escrito: equal name, then name starting with it, then a word starting with it, then the rest; whole-name matches beat single-face matches ("Treasure" before "Dinosaur // Treasure"). So typing "waste" lists Wastes, Wasteland, Lay Waste... -
Cockatrice tokens are added as candidates when their name equals something read (
seguros, hint, title), and, for typed searches, when the name merely contains the hint. -
Scoring (
puntuar): see below. Approximate hits do not score on their own; the proposal is measured against what was actually read, so a misread letter comes out as Probable and gets reviewed. -
Fallbacks. If confidence is
None, the exact printing from the footer (del_pie) becomes the proposal asProbable(a footer can be copied from a template, so neverExact); failing that, the first token found becomesProbable(several versions of every token exist). - The proposal is moved to the front of
candidates.lines= footer names followed by title lines (shown to the user as "Read on the card");pieis the parsed footer.
For each candidate it builds the names to compare: name and printed_name ("whole" names) and, for A // B cards, each face (not "whole"). Each is normalizard (lower case, accents and punctuation removed) and compared with every normalised text of 3+ characters using parecido: 1 - Damerau-Levenshtein / max(len), where swapping two adjacent letters counts as one error ("Menmarch" for "Memnarch", a typical mistake when writing a name by hand). The best similarity wins; on a tie a whole-name match beats a single-face match ("Lightning Bolt" beats "Emeritus of Conflict // Lightning Bolt").
| Best similarity | Confidence |
Meaning |
|---|---|---|
| >= 0.999 | Exact |
Normalised name equals a text. |
>= 0.85 (UMBRAL_PROBABLE) |
Probable |
One misread letter, accent, small difference. |
| lower | None |
Candidates exist but none is close; best = null. |
-
Engine. The pure-Rust
ocrscrate on thertenruntime: no system libraries. Two models,text-detection.rtenandtext-recognition.rten(about 12 MB together), are not bundled. They are downloaded on first use toapp_data_dir/modelos/(descargar_modelos, written withescribir_atomico) and stay there;modelos_listoschecks both exist and are non-empty. The app needs them for nothing else.ocr_ready(command) lets the UI warn about the first-time download ("about 12 MB, works offline afterwards"). - Gotcha: the models come from a third-party storage host with plain
reqwest, not throughfuentes::Externo, and without a hash check (External-Sources). -
One engine per process.
MOTOR: OnceLock<OcrEngine>; two threads racing initialise it twice and one result is dropped. -
Reading.
leer_carta(datos, img)reads the whole card at original resolution (not just a title strip: small footer text needs the pixels).lineas()runs detect words, find text lines, recognise text, and returnsLineaLeida { texto, y, alto }whereyis the line's vertical centre andaltoits glyph height, both as fractions of the card height (0 = top). -
Zones (
repartir). ConstantsFRANJA_TITULO = 0.16andFRANJA_PIE = 0.94:-
y < 0.16: title candidates (cleaned, kept ifparece_un_nombre: at least 3 letters, a vowel, not a short all-caps string, not a short mixed-case blob like "OOofC", which filters mana-symbol and set-symbol noise). -
y > 0.94, or the line looks like a footer (parece_pie: a collector number with 2+ digits followed by a single capital rarity letter, e.g.0057 S Menmarch): footer.nombre_del_piestrips the number and rarity, ignores lines with no lowercase letters (set code and language such asRTS ES) and copyright lines (wizards), and ignores a lone "Token". - everything else:
resto(rules text, type line...). - The type line (
linea_de_tipo) is the line between the two bands made only of type words (Spanish and English,PALABRAS_DE_TIPO), closest to mid-card (y = 0.55).fichais set when the type line, a footer-like line, or the start of a line says "Token"/"Ficha"; the word "ficha" in rules text ("non-token") is deliberately ignored.
-
-
Lectura.lineaskeeps every line unfiltered, top to bottom, fororiginales.
Authors put the English original name in many places: under the title, at the bottom left or right, after the artist (NEO • SP ➤ ARTIST | Mirror Box), in parentheses on the type line, in small caps, or split over two lines. So nothing is assumed: all pieces of all lines are tried against the local name index and each hit is scored by where and how it appears.
buscar(lineas, indice) -> Vec<Acierto { nombre, puntos, aproximado }>:
- Each line gets a zone:
Titulo,Pie,TipoorReglas(same thresholds as above). Adjacent lines are also merged into one candidate text when they are in the same non-rules zone, have glyph heights within 25 % and are close (gap up to 1.8 line heights): this catches names split over two lines. - Each text is cut at separators (
SEPARADORES:| • · — / ( ) [ ] « » : ; ★ © ➤ ...and triple spaces) because a name never spans them; chunks containing "wizards" (copyright) are dropped. - For every chunk, every window of 1..=6 consecutive words is checked with
Indice::exacto(case/accents/punctuation-insensitive, OCR-confusable letters folded). A single-word hit counts only if it is the whole chunk and not in the rules text (so "Fog" in a sentence is not the card name). - Points: multi-word hit
3 + 0.5 * words; single word1;+1if the window is the whole chunk; zone bonus+2title or footer,+1type line,-2rules text;+0.5if the line's glyphs are under 70 % of the title height (small print). Only hits with>= PUNTOS_SEGURO (3.0)are kept; per name the best score is kept. - If no multi-word name was found, a fuzzy pass (
Indice::parecido, similarity >= 0.85) tries up to 6 multi-word chunks from title/footer zones; these hits carry 3.0 points andaproximado = true. - Results are sorted by points, best first.
pie_leido(lineas, sets) -> Option<PieLeido { set, numero, idioma }> reads the footer: it scans lines with y > 0.85 or footer-looking text; the first token that is a collector number (up to 4 digits, optionally 250/302; leading zeros removed) becomes numero; the first all-uppercase 2-7 character token that is a known set code becomes set, and the following word is the language (idioma_del_pie: EN, ES or SP, FR, DE, IT, PT, JP or JA, KO or KR, RU, CS->zhs, CT->zht, PH). A glued form like NEOSP (the OCR swallowed the bullet) is split. Both set and number are required; one alone does not identify a printing, so the result is None.
| Command | Signature | Notes |
|---|---|---|
ocr_ready |
() -> bool |
Are the OCR models on disk? |
card_identify |
(blob, hint?, ocr) -> CardIdentify |
One card; escrito = true. Downloads models first if ocr and missing. |
identify_batch |
(items: [{blob, hint?}], ocr) -> job_id |
Starts a job. Events: identify://progress (IdentifyProgressEvent { jobId, done, total, blob, result? }, one per card) and identify://done (IdentifyDoneEvent { jobId, error? }). Cancelled with job_cancel. |
card_link |
(scryfall_id) -> CardMeta |
See above. |
card_link_externa |
(fuente, id) -> CardMeta |
For Cockatrice tokens. |
Batch behaviour (lote): models are downloaded once before the loop; for each item identificar(..., escrito = false) runs; a failure on one item emits a progress event with result = None and the job continues; a Network error aborts the whole job (no point continuing offline); the cancel flag is checked before each item. The command holds a state.trabajo() guard like other long jobs.
Opened from the card menu ("Link with a card..." / "Change the link...", src/features/preview/slot-menu.tsx). It shows the card image, a search box pre-filled with the card's name, and a grid of candidates (RejillaCartas). On open it runs a name-only identification when the name has 3+ characters. Buttons: "Search" (name-only card_identify, ocr = false) and "Detect with OCR" (ocr = true, shows a note about the 12 MB first-time download when ocr_ready is false). After OCR, the "Read on the card" list shows the lines; clicking one searches for it. The proposal (best, else the first candidate) is preselected; double-clicking a candidate links immediately. The footer button "Link with " calls vincular(card.id, chosen, idiomaPie); "Remove the link" appears when scryfallId is set and calls desvincular. It closes with an announcement "Linked with ".
Entry points: the decklist panel button "Link with a card" (whole deck, or the selection when several are selected), and "Link the selected..." in the multi-card menu (soloSeleccion). Phases:
-
Choose (
elegir). If a selection contains own images, a toggle picks "Whole deck (n)" or "Only the selected (n)". Scope: "Only those missing" (not yet linked) or "All again". Checkbox "Read the whole card with OCR (more reliable, about 2 s per card)"; without OCR only the file name is used. -
Read (
leyendo).identify_batchruns; each result arrives as a row. A progress bar with a remaining-time estimate. "Stop and review what was read" cancels the job but keeps the rows already read. -
Review (
revisar). Counters (Exact / Probable / No proposal). Each row has: a checkbox, the own image thumbnail, the text read, a selector of candidates ("Do not link" plusname · SET number · lang), a confidence badge, and the candidate's thumbnail; either thumbnail opens a large comparison (AmpliarCartas). Rows withexactorprobableconfidence and abeststart checked; the rest start unchecked and can be fixed by choosing another candidate (choosing one checks the row, "Do not link" unchecks it). A filter shows all rows or only unchecked ones. -
Apply (
aplicando). For each checked row it callsvincular(card.id, candidate, resultado.pie?.idioma)sequentially (each updates the deck store, so it lands in undo history like other edits; Inferred fromuseDeck.setCartas), with a "Stop" button. While reading or applying, the dialog is marked busy and closing it interrupts the work; with unsaved review results it asks for confirmation (cambiosSinGuardar).
The review step is the safety net: Rust only proposes; nothing is linked until the user confirms.
Screenshots (English UI):

| Situation | Behaviour |
|---|---|
| First OCR use offline |
descargar_modelos fails with Network; the single dialog shows the error toast, the batch ends with an error. |
| Engine/model load failure |
ErrorCode::Internal with ocr param. |
| Blob missing |
NotFound (blob). |
| Scryfall unreachable |
Network; in a batch it aborts the job, in the single dialog it shows an error toast. |
| Nothing read, nothing matches |
candidates empty or confidence = None; the UI shows "No results" / "No proposal"; the user can type a name. |
| Footer misread |
pie = None; no exact-printing candidate; the language of the image is not auto-set. |
-
src-tauri/src/commands/identify.rs:parecido, exact/probable/none scoring, double-faced matching, whole-card preference, typed-order (lo_escrito_ordena_por_parecido),normalizar; an#[ignore]d live test over a folder. -
src-tauri/src/ocr.rs: noise rejection, footer names, token detection only in type line or footer, type lines,limpiar; a live test (en_vivo,#[ignore]d, needs the models viaPB_OCR_DATOS) that reads a real image. -
src-tauri/src/originales.rs: real-case reading of a custom card, original name in each position (footer, under the title, type-line parentheses, small caps, split lines, one wrong letter), rules-text words not counted, footer set/number/language, a speed guard (many lines against 30 000 names in under 500 ms). -
src-tauri/src/scryfall/nombres.rs,fichas.rs: the index and token-name table. -
src/features/variants/__tests__/vincular.test.ts: linking all same-image copies, not taking the Scryfall image state, unlink, error leaves the deck unchanged,propiasPorImagen.
- Better recognition of a new card layout: add a case to
originales.rstests with the lines the OCR really produced, then adjust scoring; keep the rule "a single word counts only as a whole chunk outside the rules text". - A new source of candidates: add them in
identificar()beforepuntuar, asCardSummaryrows (setfuenteif not from Scryfall). - Changing thresholds:
UMBRAL_PROBABLE(identify),UMBRAL(name index fuzzy match),PUNTOS_SEGURO(originales); the bulk dialog pre-checks bothExactandProbable, so loosening them affects automatic linking.
- Scryfall-Integration - search, caches and the name catalogue.
- External-Sources - Cockatrice tokens and other-language name sources.
-
Deck-Model -
CardSource,CardMeta. - Statistics - why linked cards matter.
-
Add-Card-and-Variants - the sibling dialogs in
features/variants. - Settings-and-Shell, IPC-and-Error-Handling
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia