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