feat(barcode): el ISBN es un PERFIL de entrada sobre EAN-13, no una simbología

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 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent d2e841c65d
commit ae7e3462e5

@ -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');

@ -109,6 +109,32 @@ const QUIET_ZONE: Record<Symbology, { start: number; end: number }> = {
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<Symbology>(['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;

@ -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
<Barcode symbology="ean13" value="978-84-339-2042-3" />
<!-- ISBN-13, hyphenated -->
<Barcode symbology="ean13" value="0306406152" />
<!-- ISBN-10 → 9780306406157 -->
<Barcode symbology="ean13" value="080442957X" />
<!-- base-11 X check character -->
```
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

@ -11,6 +11,11 @@
*
* The human-readable interpretation is a real SVG `<text>`, 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"

@ -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;

@ -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<Tab>('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<Record<Symbology, { label: string; value: string }[]>> = {
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<Symbology, string> = {
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 @@
</span>
<input type="text" bind:value />
</label>
{#if EXTRAS[symbology]}
<label data-uix-control style="flex-basis: 100%;">
<span data-uix-control-label>
try <span data-uix-control-hint
>separators are tolerated; ISBN-10 converts to Bookland 978</span
>
</span>
<span data-uix-chips role="radiogroup">
{#each EXTRAS[symbology] ?? [] as extra}
<button
data-uix-chip
data-active={value === extra.value}
onclick={() => (value = extra.value)}>{extra.label}</button
>
{/each}
</span>
</label>
{/if}
</div>
<div data-uix-subsection-head>
@ -648,7 +671,14 @@
<tbody>
<tr
><td class="name">provider (svg)</td><td>role / aria-label</td><td class="type"
>"img" / alt ?? value</td
>"img" / alt ?? the <strong>encoded</strong> value — not the raw prop: they differ whenever
the encoder normalises (ISBN-10 → 978, appended check digit, dropped separators)</td
></tr
>
<tr
><td class="name">provider (svg)</td><td>aria-hidden</td><td class="type"
>"true" when nothing is rendered (empty or unencodable) — and then it carries no
name: a hidden node that is also labelled is contradictory</td
></tr
>
<tr

Loading…
Cancel
Save

Powered by TurnKey Linux.