From ae7e3462e5067df4344cebf71158b17614be6b5d Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 31 Jul 2026 02:56:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(barcode):=20el=20ISBN=20es=20un=20PERFIL?= =?UTF-8?q?=20de=20entrada=20sobre=20EAN-13,=20no=20una=20simbolog=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un código de barras de ISBN ES un EAN-13: ISO 2108 mete los libros en el mismo espacio GTIN-13 que todo lo demás bajo los prefijos Bookland 978/979 — por eso el mismo lector lee una novela y una lata de garbanzos. Así que `isbn` NO entra como noveno valor de `symbology`: declararlo haría que `data-symbology` afirmara un estándar que no existe. Entra como perfil de entrada sobre `ean13`, que es la forma que adoptan las implementaciones serias. - **Separadores tolerados** en las simbologías NUMÉRICAS: un ISBN viaja con guiones y un GTIN con espacios. Code 128 y Code 39 quedan fuera a propósito — ahí un guión es dato codificable y quitarlo cambiaría la cadena en silencio. - **ISBN-10 → Bookland**: un valor de 10 caracteres en `ean13` se lee como ISBN-10, se le valida SU dígito de control (pesos 10…1, mod 11, con `X` valiendo 10) y se convierte con prefijo 978 y mod-10 recalculado. La validación previa es la parte que importa: sin ella, un ISBN-10 mal tecleado se convierte en un EAN-13 perfectamente válido que apunta a OTRO libro — el modo de fallo más caro del dominio. Falla con `invalid-check-digit`. - **La hyphenación NO es nuestra** y queda documentada como tal: las posiciones de los guiones no se calculan desde el número, dependen de las tablas de rangos de la International ISBN Agency, que son dato versionado con fecha de caducidad. Meterlas aquí pondría un reloj de mantenimiento dentro de una librería de cero dependencias. Si algún día se pinta la línea `ISBN 978-…` sobre las barras, el string con guiones lo trae el consumidor. Y un defecto que el ISBN destapó, anterior a él: **el nombre accesible mentía**. `aria-label` anunciaba el valor CRUDO mientras el símbolo codificaba otro — pasaba ya con cualquier EAN al que se le añade el dígito de control, y con el ITF-14, y con Code 39 al mayusculizar. Ahora anuncia lo que el símbolo lleva de verdad. - Vectores publicados en los tests: `0-306-40615-2` → `978-0-306-40615-7`, y los dos casos de control `X` (`0-8044-2957-X`, `0-9752298-0-X`). - `D-1.2` estaba ROTO en el commit anterior y no lo vi: prettier parte la unión `type Tab` a 102 caracteres y el audit exige la forma de UNA línea que documenta la guía de demos. Restaurada con `prettier-ignore`; el audit vuelve a PASS con 0 errores. - `F-1.4` se rompió al escribir la sección ISBN: mencionaba `## Gaps` en línea y el regex del audit engancha esa primera aparición. Reescrito. Co-Authored-By: Claude Opus 5 --- src/libs/barcode/barcode.test.ts | 52 ++++++++++++++++ src/libs/barcode/barcode.ts | 60 +++++++++++++++++++ src/uix/eidos/components/barcode/README.md | 49 ++++++++++++++- .../eidos/components/barcode/barcode.svelte | 7 ++- src/uix/eidos/components/barcode/types.ts | 8 ++- .../uix/components/barcode/+page.svelte | 54 +++++++++++++---- 6 files changed, 215 insertions(+), 15 deletions(-) diff --git a/src/libs/barcode/barcode.test.ts b/src/libs/barcode/barcode.test.ts index dd70a8a1c..4c0edc44e 100644 --- a/src/libs/barcode/barcode.test.ts +++ b/src/libs/barcode/barcode.test.ts @@ -469,6 +469,58 @@ describe('geometry', () => { expect(upce.text.map((g) => g.text).join('')).toBe('01234565'); }); + // ── ISBN: an input profile over EAN-13, never a symbology of its own ────── + // Vectors published in the ISO 2108 / ISBN literature: 0-306-40615-2 is the + // canonical worked example, and 0-8044-2957-X / 0-9752298-0-X are the + // published `X`-check-digit cases. + + it('ISBN-13 is an EAN-13 — Bookland 978 and 979 pass straight through', () => { + expect(encode('9780306406157', { symbology: 'ean13' }).value).toBe('9780306406157'); + expect(encode('9791234567896', { symbology: 'ean13' }).value).toBe('9791234567896'); + }); + + it('converts ISBN-10 to the Bookland EAN-13', () => { + const result = encode('0306406152', { symbology: 'ean13' }); + expect(result.value).toBe('9780306406157'); + expect(result.checkDigit).toBe('7'); + expect(result.size).toBe(95); + }); + + it('accepts the base-11 X check character', () => { + expect(encode('080442957X', { symbology: 'ean13' }).value).toBe('9780804429573'); + expect(encode('097522980X', { symbology: 'ean13' }).value).toBe('9780975229804'); + // Lowercase is the same character. + expect(encode('080442957x', { symbology: 'ean13' }).value).toBe('9780804429573'); + }); + + it('rejects an ISBN-10 whose own mod-11 check digit is wrong', () => { + // 0306406152 is valid; every other check character is not. + for (const wrong of ['0306406151', '0306406153', '030640615X']) { + try { + encode(wrong, { symbology: 'ean13' }); + throw new Error(`expected ${wrong} to be rejected`); + } catch (error) { + expect(error, wrong).toBeInstanceOf(BarcodeError); + expect((error as BarcodeError).reason, wrong).toBe('invalid-check-digit'); + } + } + }); + + it('tolerates the separators a numeric value travels with', () => { + // An ISBN is written hyphenated; a GTIN is written spaced. + expect(encode('978-0-306-40615-7', { symbology: 'ean13' }).value).toBe('9780306406157'); + expect(encode('0-306-40615-2', { symbology: 'ean13' }).value).toBe('9780306406157'); + expect(encode('978 84 339 2042 3', { symbology: 'ean13' }).value).toBe('9788433920423'); + expect(encode('0 36000 29145 2', { symbology: 'upca' }).value).toBe('036000291452'); + expect(encode('1 5400 1412 8876', { symbology: 'itf14' }).value).toHaveLength(14); + }); + + it('does NOT strip separators where they are data', () => { + // In Code 128 / Code 39 a hyphen and a space are encodable characters. + expect(encode('UIX-2026', { symbology: 'code128' }).value).toBe('UIX-2026'); + expect(encode('UIX 39', { symbology: 'code39' }).value).toBe('UIX 39'); + }); + it('Code 39 keeps the * delimiters out of the value (they are structure)', () => { const result = encode('ABC', { symbology: 'code39' }); expect(result.value).toBe('ABC'); diff --git a/src/libs/barcode/barcode.ts b/src/libs/barcode/barcode.ts index fb362d122..f44e4e9c7 100644 --- a/src/libs/barcode/barcode.ts +++ b/src/libs/barcode/barcode.ts @@ -109,6 +109,32 @@ const QUIET_ZONE: Record = { const DIGITS = /^[0-9]+$/; +/** + * The numeric symbologies, whose values are written with punctuation in the + * real world — an ISBN's hyphens, a GTIN's spaces. Code 128 and Code 39 are NOT + * here: a hyphen is encodable data there. + */ +const NUMERIC_SYMBOLOGIES = new Set(['ean13', 'ean8', 'upca', 'upce', 'itf', 'itf14']); + +/** Separators dropped from a numeric value before encoding. */ +const SEPARATORS = /[\s-]/g; + +/** 9 digits + a base-11 check character. */ +const ISBN10 = /^[0-9]{9}[0-9X]$/; + +/** + * ISBN-10's own check digit: the ten characters weighted 10…1 must sum to a + * multiple of 11, where the check character `X` carries the value 10 (ISO 2108 + * — the base-11 modulus is why the digit 10 needs a symbol at all). + */ +function isValidIsbn10(value: string): boolean { + let sum = 0; + for (let i = 0; i < 10; i++) { + sum += (value[i] === 'X' ? 10 : Number(value[i])) * (10 - i); + } + return sum % 11 === 0; +} + function fail(reason: BarcodeErrorReason, symbology: Symbology, message: string): never { throw new BarcodeError(reason, symbology, message); } @@ -634,12 +660,46 @@ function cells(text: string, start: number, cell: number): BarcodeTextGroup[] { * Encode `value` into a module lattice for the requested symbology. * Throws {@link BarcodeError} when the value cannot be encoded — callers are * expected to treat that as a state (a half-typed EAN), not as a crash. + * + * Two input conveniences, both scoped to the numeric symbologies: + * separators are tolerated (an ISBN travels hyphenated, a GTIN spaced), and a + * 10-character value in `ean13` is read as an ISBN-10 and converted. */ export function encode(value: string, options: EncodeOptions = {}): BarcodeResult { const symbology = options.symbology ?? 'code128'; if (!value) fail('empty', symbology, 'Nothing to encode.'); + // In Code 128 / Code 39 a hyphen or a space is DATA and must survive; in the + // numeric symbologies it is punctuation the value was written with. + let input = NUMERIC_SYMBOLOGIES.has(symbology) ? value.replace(SEPARATORS, '') : value; + if (!input) fail('empty', symbology, 'Nothing to encode once the separators are dropped.'); + + // ISBN-10 → Bookland EAN-13. An ISBN barcode IS an EAN-13 (ISO 2108), so + // this is an input profile, never a symbology of its own: the 10-digit form + // carries a base-11 check digit (possibly `X`) that we validate BEFORE + // converting — a mistyped ISBN-10 would otherwise become a perfectly valid + // EAN-13 pointing at a different book, the costliest failure in this domain. + if (symbology === 'ean13' && input.length === 10) { + // Only the check character can be a letter, so this is a no-op elsewhere. + input = input.toUpperCase(); + if (!ISBN10.test(input)) { + fail( + 'invalid-characters', + symbology, + 'A 10-character value is read as an ISBN-10: 9 digits plus a check digit (0-9 or X).' + ); + } + if (!isValidIsbn10(input)) { + fail('invalid-check-digit', symbology, 'The ISBN-10 check digit does not validate (mod 11).'); + } + input = `978${input.slice(0, 9)}`; + } + + // From here on the normalised form IS the value: everything below encodes it + // and reports it back through `result.value`. + value = input; + let bits = ''; let encoded = value; let checkDigit: string | undefined; diff --git a/src/uix/eidos/components/barcode/README.md b/src/uix/eidos/components/barcode/README.md index a1bad84f9..9ef22e719 100644 --- a/src/uix/eidos/components/barcode/README.md +++ b/src/uix/eidos/components/barcode/README.md @@ -115,6 +115,48 @@ scanner locks on; here they extend below the baseline for the same reason. | `itf` | ISO/IEC 16390 | digits, **even count** | mod-10 optional | interleaved pairs | | `itf14` | ISO/IEC 16390 | 13 digits | mod-10 **mandatory** | + bearer bars | +## ISBN — a profile, not a symbology + +**An ISBN barcode IS an EAN-13.** ISO 2108 assigns books the Bookland prefixes +`978` / `979` inside the same GTIN-13 space as everything else, which is why one +scanner reads a novel and a can of beans. So ISBN is not a ninth `symbology` +value: declaring one would make `data-symbology` claim a standard that does not +exist. It is an **input profile** over `ean13` — the shape every serious +implementation ships. + +```svelte + + + + + + +``` + +What the profile does: + +- **Tolerates separators** (`-`, whitespace) in every NUMERIC symbology — an + ISBN travels hyphenated, a GTIN travels spaced. Code 128 and Code 39 are + deliberately excluded: there a hyphen is encodable data, and stripping it + would silently encode a different string. +- **Reads a 10-character `ean13` value as an ISBN-10** and converts it: prefix + `978`, keep the 9 significant digits, recompute the EAN mod-10 check. +- **Validates the ISBN-10's own check digit first** (weights 10…1, mod 11, where + `X` = 10). This is the part that matters: without it a mistyped ISBN-10 becomes + a _perfectly valid_ EAN-13 pointing at a different book — the costliest failure + mode in this domain. A bad check digit fails with `invalid-check-digit`. + +What it deliberately does NOT do: + +- **Hyphenate.** The hyphen positions are not computable from the number: they + depend on the International ISBN Agency's prefix-range tables, which are + versioned data with an expiry date. Vendoring them would put a maintenance + clock inside a zero-dependency library. If the `ISBN 978-84-339-2042-3` line + above the bars is ever added, the hyphenated string comes from the consumer's + catalogue — where it is already stored that way. +- **Print the upper ISBN line** (the book-cover convention) or the **EAN-5 price + add-on** — both registered as deferred gaps below. + ## Scannability (the doctrine) The QR's scannability rules restated for a linear symbol — same principle, own @@ -266,7 +308,12 @@ label. disposition: **diferir** (v2). - **GS1-128 (FNC1 + Application Identifier parsing)** — shipped by bwip-js — disposition: **diferir** (v2); it is a Code 128 profile, not a new encoder. -- **EAN-2 / EAN-5 add-ons** — shipped by JsBarcode — disposition: **diferir** (v2). +- **EAN-2 / EAN-5 add-ons** — shipped by JsBarcode; the EAN-5 is the price + supplement printed beside a book's ISBN — disposition: **diferir** (v2). +- **The `ISBN 978-…` line above the bars** — the book-cover convention of the + industry guidelines. The band is easy; the hyphenation is not ours (see + `## ISBN`), so the consumer would pass the already-hyphenated string — + disposition: **diferir** (v2), pairs with the EAN-5 add-on. - **2D symbologies (DataMatrix, PDF417, Aztec)** — shipped by bwip-js — disposition: **diferir**; each is a whole different encoder (own Reed–Solomon, matrix layout), so they belong to their own component, not to diff --git a/src/uix/eidos/components/barcode/barcode.svelte b/src/uix/eidos/components/barcode/barcode.svelte index 9e298410d..5ba540726 100644 --- a/src/uix/eidos/components/barcode/barcode.svelte +++ b/src/uix/eidos/components/barcode/barcode.svelte @@ -11,6 +11,11 @@ * * The human-readable interpretation is a real SVG ``, so it survives * both the SVG and the PNG export. + * + * The accessible name is what the symbol ACTUALLY carries — the encoded + * value, not the raw prop: they differ whenever the encoder normalises + * (an ISBN-10 becomes Bookland 978, a check digit gets appended, separators + * are dropped). Announcing the input would describe a different code. */ import './barcode.css'; import { ActiveEidos } from '$uix/eidos'; @@ -252,7 +257,7 @@ data-symbology={symbology} data-invalid={invalid ? '' : undefined} role="img" - aria-label={decorative ? undefined : (alt ?? value)} + aria-label={decorative ? undefined : (alt ?? result?.value ?? value)} aria-hidden={decorative ? 'true' : undefined} viewBox="0 0 {fmt(boxWidth)} {fmt(boxHeight)}" shape-rendering="crispEdges" diff --git a/src/uix/eidos/components/barcode/types.ts b/src/uix/eidos/components/barcode/types.ts index ca2c34c15..448b10219 100644 --- a/src/uix/eidos/components/barcode/types.ts +++ b/src/uix/eidos/components/barcode/types.ts @@ -7,7 +7,13 @@ export type { Symbology, BarcodeErrorReason }; export type BarcodeDownloadFormat = 'png' | 'svg'; export type BarcodeProps = { - /** Data to encode. @default '' */ + /** + * Data to encode. In the numeric symbologies separators (`-`, spaces) are + * tolerated, and a 10-character `ean13` value is read as an ISBN-10 — + * validated against its own base-11 check digit, then converted to the + * Bookland EAN-13. + * @default '' + */ value?: string; /** Which standard to encode with. @default 'code128' */ symbology?: Symbology; diff --git a/web/routes/uix/components/barcode/+page.svelte b/web/routes/uix/components/barcode/+page.svelte index 9d22a55b2..41b51072c 100644 --- a/web/routes/uix/components/barcode/+page.svelte +++ b/web/routes/uix/components/barcode/+page.svelte @@ -18,16 +18,11 @@ const uix = getActiveUix(); - type Tab = - | 'live' - | 'system' - | 'motion' - | 'sema' - | 'services' - | 'api' - | 'morfo' - | 'recipe' - | 'a11y'; + // prettier-ignore — the canonical v2 union is ONE line (demo-authoring §3), + // and `component-audit` D-1.2 matches it as such; at 102 chars prettier + // would split it and the demo would stop matching the template. + // prettier-ignore + type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y'; let tab = $state('live'); /** One valid sample per symbology — each standard constrains its input. */ @@ -42,9 +37,19 @@ itf14: '1540014128876' }; + /** Extra one-click values that teach something the sample alone does not. */ + const EXTRAS: Partial> = { + ean13: [ + { label: 'ISBN-13 con guiones', value: '978-84-339-2042-3' }, + { label: 'ISBN-10 (→ 978)', value: '0306406152' }, + { label: 'ISBN-10 con X', value: '080442957X' } + ], + upca: [{ label: 'con espacios', value: '0 36000 29145 2' }] + }; + const RULES: Record = { code128: 'any ASCII 0–127 · A/B/C subsets chosen automatically', - ean13: '12 digits (13 with the check digit)', + ean13: '12 digits (13 with the check digit) · an ISBN is an EAN-13', ean8: '7 digits (8 with the check digit)', upca: '11 digits (12 with the check digit)', upce: '6 digits (7 with the number system, 8 with the check digit)', @@ -277,6 +282,24 @@ + {#if EXTRAS[symbology]} + + {/if}
@@ -648,7 +671,14 @@ provider (svg)role / aria-label"img" / alt ?? value"img" / alt ?? the encoded value — not the raw prop: they differ whenever + the encoder normalises (ISBN-10 → 978, appended check digit, dropped separators) + provider (svg)aria-hidden"true" when nothing is rendered (empty or unencodable) — and then it carries no + name: a hidden node that is also labelled is contradictory