The specification is maintained in English.
.tbook File Format Specification — version 2
Release status. Final, released 2026-09-29; version 2 is the current format. The author ratified every open choice of the draft on 2026-09-26 (Ratified decisions) and a second round of implementation questions on 2026-09-28 (Ratified decisions (2026-09-28)). The reference converter writes version 2 by default (
--format v1remains available); v2-capable readers open both versions.
Background. This document specifies the direction adopted by the 2026-09 format review (panel report §5.1: the
v2-jsoncontainer and link model, the paragraph text model and learner slots ofparagraph-multigranular, thesourceblock ofepub-overlay, and the seven preconditions of §4.3). Where the panel left a choice open the draft made one; each is recorded, with its outcome, under Ratified decisions. Numbers appear only where they justify a requirement, each with its report section.
1. Introduction
A .tbook file is a self-contained, offline-first container for a
language-learning ebook. It pairs the original text of a book with a
per-sentence translation into one or more target languages, plus
word-level alignment between source and translation.
The defining feature is unchanged from version 1: tap-to-translate. A reader displays the book in its source language; when the user taps a source word, the reader shows the sentence's translation with exactly the translated word(s) highlighted. Everything needed for this is in the file.
Version 2 changes how that data is laid out and what a consumer can trust:
| Version 1 | Version 2 | |
|---|---|---|
| Canonical text object | the sentence (src string) |
the paragraph (text string); sentences are code-point ranges into it (§4.5) |
| Languages | inline in every sentence (tr map) |
one overlay entry per (chapter, language); the chapter skeleton is language-free (§2.1) |
| Tappable words | producer-emitted char ranges (words) |
derived by a normative tokenizer tbook-w2 on both sides (§5); an explicit inventory only where the tokenizer does not fit |
| Alignment | chunk objects {t:[a,b], w:[…]} with char ranges |
one link per target token — int, int[], null (unaligned) or [] (inserted) — plus an exact escape list x for spans that are not whole tokens (§6.4, §6.5) |
| Quality | q coverage score (informative, blind to correctness) |
producer-written status s, verdict v, overlay-level gates[], and an alignment-pair digest (§6.7, §6.8, §9.2) |
| Identity | ordinal chapter ids ch1, ch2 |
content-derived chapter and paragraph ids, locators, source identity block (§3.5, §4.3, §3.6) |
| Offsets | "character offsets" (code points in practice) | code points, NFC, declared in the manifest (§7) |
| Container | ZIP of JSON | ZIP of JSON with mimetype sentinel, per-entry SHA-256 digests, shipped JSON Schema, requires[] capability list (§2, §3.2) |
| Learner payload | none above/below the word | optional units (source expressions), groups (target lexemes), parts (sub-token spans), and a 0-byte derived run/split labelling rule (§4.9, §6.10, §6.11, §8.3) |
1.1 Design model: source as pivot, paragraph as text, sentence as range
A .tbook is multi-language by design. The source language is the pivot;
every target ("gloss") language aligns word-by-word to the source, never to
another target. Adding a language costs one translation per sentence (N−1) and,
in version 2, is an append to the archive rather than a rewrite
(§2.4).
The paragraph is the canonical text object: a skeleton stores each
paragraph's text once and sentences are ranges into it, so segmentation is a
view — a boundary may fall mid-word and the paragraph still renders, because
the consumer displays text, not a join of sentences (Annex A.7).
Alignment is by meaning, from every target token to the source words it
renders, produced during translation; the consumer has no aligner.
1.2 Terminology
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY and OPTIONAL are to be interpreted as described in RFC 2119.
| Term | Meaning |
|---|---|
| Source | The original language of the book. One per file. |
| Target / gloss | A language the book is translated into. One or more per file. |
| Skeleton | The language-free chapter entry text/chN.json (§4). |
| Overlay | The per-(chapter, language) entry gloss/chN.<lang>.json (§6). |
| Block | A paragraph-like text object: a chapter paragraph, a table cell, or a footnote paragraph. Carries text and sents. |
| Sentence | A code-point range [a, b) into a block's text; the unit of translation. |
| Word | A source token produced by the tokenizer (or the block's words override) whose first code point lies in a sentence; identified by its 0-based index within that sentence. |
| Token | A target token of a translation t, produced by the same tokenizer (or the translation's words override); identified by its 0-based index. |
| Link | The a[j] entry of target token j: the source word index(es) it renders, null, or []. |
| Escape chunk | An entry of x: a code-point range of t that is not exactly one token, with its source word indices. |
| Unit | A producer-asserted multi-word expression on the source side, shared by all languages (§4.9). |
| Group | A producer-asserted set of target tokens forming one lexeme (§6.10). |
| Part | A producer-asserted sub-token span of a target token with its own link (§6.11). |
Status s |
A producer-written per-translation state (§6.7). |
Gate / verdict v |
A quality check that ran over an overlay, and its per-translation result (§6.8). |
| Locator | chapterId/paragraphId/sentenceIndex/wordIndex — a stable reference to a word (§3.5.2). |
| Producer | A tool that writes .tbook files. |
| Consumer | A tool that reads .tbook files. |
2. Container
A .tbook file MUST be a ZIP archive (PKZIP, APPNOTE 6.3.x). Rules beyond
plain ZIP:
- Every entry MUST use compression method
0(STORED) or8(DEFLATE). No other method, no encryption, no ZIP64 structures (a file needing ZIP64 is out of scope). - Entry names MUST be unique within the archive, use
/as separator, be case-sensitive, and be valid UTF-8 without a leading/or any..segment. A consumer MUST reject an archive whose central directory lists the same name twice. - Every JSON entry MUST be UTF-8 without a byte-order mark and MUST be Unicode NFC (§7).
- The first local file entry MUST be
mimetype(§2.2). manifest.jsonMUST be the last local file entry, immediately followed by the central directory (§2.4).- Entries MUST be addressable by name via the central directory; consumers MUST NOT depend on local-header order for anything except the two rules above.
2.1 Archive layout
book.tbook (ZIP)
├── mimetype required — first entry, STORED (§2.2)
├── schema/tbook-2.schema.json required — JSON Schema of every JSON entry (§3.9)
├── cover.jpg optional — cover image bytes
├── images/
│ └── img1.jpg optional — body images referenced by figures (§4.10)
├── text/
│ ├── ch1.json required (≥1) — chapter skeleton, language-free (§4)
│ ├── ch2.json
│ └── notes.json optional — footnote bodies, skeleton shape (§4.12)
├── gloss/
│ ├── ch1.ru.json one per (chapter, language) — overlay (§6)
│ ├── ch1.tr.json
│ ├── ch2.ru.json
│ └── notes.ru.json optional — footnote bodies, overlay shape (§6.12)
└── manifest.json required — LAST entry: metadata, spine, digests (§3)
Entry names other than mimetype and manifest.json are conventional: a
consumer MUST discover skeletons, overlays, notes and the schema only through
the manifest (spine[].text, spine[].gloss, notes, schema), never by
pattern. Producers MAY add entries; consumers MUST ignore entries the
manifest does not reference. The chN numbering is the reading order and
carries no identity — identity is the content-derived spine[].id
(§3.5.1).
2.2 The mimetype entry
The first local file entry MUST be named mimetype, MUST be STORED, and
MUST contain exactly the 25 ASCII bytes application/vnd.tbook+zip — no
trailing newline, no byte-order mark. Its local header MUST carry no extra
field, so that the string starts at byte offset 38 of the file (the same
sniffing rule as EPUB OCF).
A consumer MAY identify a .tbook by these 63 leading bytes before opening
the ZIP. A consumer MUST NOT rely on the file extension.
2.3 Compression
Producers SHOULD DEFLATE JSON entries and STORE already compressed media. Consumers MUST support both methods and MUST read entries by random access; whole-archive streaming need not work.
2.4 Appending a language
Adding a target language that the file does not yet have is an append,
defined as follows (a language already listed in languages.targets is not
appended; it is replaced per §2.5):
- Truncate the archive at the local-header offset of the existing
manifest.json(which, by §2, is the last local entry; this discards only the manifest and the central directory). - Append the new
gloss/<chN>.<lang>.jsonentries (andgloss/notes.<lang>.jsonif the book has footnotes). - Append a rewritten
manifest.jsonwith the language added tolanguages.targetsand to eachspine[].gloss, the new entries added todigests, and a newmeta.runs[]record (§3.10). - Write a new central directory and end-of-central-directory record.
The skeleton entries are untouched, which is possible only because every
skeleton field — including units — is derived from the source alone
(§4.9; report §4.3, M10: with language-dependent units 0 of 13
skeletons survived the append). A producer MUST NOT leave a superseded
manifest.json in the archive: after the append the name MUST occur exactly
once (§2).
Non-conformant input. If
manifest.json is not the last local entry of the input, or the central
directory lists it more than once, step 1 would discard or keep the wrong
bytes. A producer MUST then refuse to append, and its error MUST name
the entry that follows the manifest (for a duplicate, the other occurrence).
Rewriting the whole file into a conformant archive is a separate operation that
the user requests explicitly; a producer MUST NOT perform it silently as
part of an append.
The overlay entry names of an append, and the rules for meta, are those of
§2.5 and §3.10.
2.5 Replacing a language
A producer MAY replace a target language that is already present — for example to re-translate it with another model. Replacement is not an append (§2.4): the old overlays sit before the manifest, so the producer rewrites the archive. It MUST do so as follows:
- Every entry that is not a replaced overlay (step 2) —
mimetype, the schema, the cover, images, every skeleton,text/notes.json, every overlay of every other language, every overlay of the replaced language for a chapter the run does not cover (step 3), and every entry the manifest does not reference — MUST be written byte-identical to the input (same name, same compression method, same uncompressed and compressed bytes), in the input's local-entry order.mimetypestays the first entry and STORED (§2.2). - The overlay entries of the replaced language for the chapters the run
covers (
spine[].gloss[lang]) and its footnote overlay (notes.gloss[lang]) are removed and the new overlays written in their place; an overlay for a chapter that had none in that language is written after the last carried entry. - A replacement MAY be partial: only the chapters the run covers get new
overlays. A chapter the run does not cover MUST keep its existing
overlay for the replaced language, copied verbatim like any entry of step 1,
with its
spine[].glosskey and its digest unchanged. A chapter that had no overlay in that language and is not covered still has none, which is legal (§3.4, decision 10). manifest.jsonis rewritten as the last entry, exactly once: thedigestsof the replaced overlays are dropped and those of the new ones added, every other digest unchanged;languages.targetskeeps its order; each new overlay carries its ownalignDigest(§9.2).metafollows §3.10: the replaced language's earliermeta.runs[]records stay and a new record is added, whosetargetslists the replaced language.- A new central directory and end-of-central-directory record are written. The ZIP archive comment is not preserved.
Overlay entry names. An appended or
replacing overlay is named after its skeleton: text/X.json →
gloss/X.<lang>.json; the footnote overlay is gloss/notes.<lang>.json. The
names remain conventional for consumers (§2.1).
The non-conformant-input rule of §2.4 applies to a replacement too. A single run MAY replace some languages and add others; it is then a replacement (a rewrite) as a whole.
3. manifest.json
A single JSON object describing the book, its languages, its chapters, and the integrity of every other entry. The example is the one-chapter fixture book of Annex B:
{
"formatVersion": 2,
"requires": ["text/paragraph", "align/token", "tokenizer/tbook-w2"],
"title": "Canonical sentences",
"authors": ["Arthur Conan Doyle"],
"languages": { "source": "en", "targets": ["ru", "tr", "de"] },
"text": { "tokenizer": "tbook-w2", "offsets": "codepoint", "normalization": "NFC" },
"cover": null,
"notes": {},
"spine": [
{
"id": "cbedb517c",
"title": "Canonical sentences",
"level": 0,
"text": "text/ch1.json",
"gloss": { "ru": "gloss/ch1.ru.json", "tr": "gloss/ch1.tr.json", "de": "gloss/ch1.de.json" },
"paragraphs": 11,
"sentences": 15
}
],
"source": {
"kind": "epub",
"sha256": "8f4e3c1a9b2d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"identifier": "urn:uuid:5c9f1b6e-2d4a-4e8b-9c3f-7a1d2e3f4b5c",
"textSha256": "1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c",
"docs": [ { "href": "OEBPS/ch01.xhtml", "chars": 4180, "sha256": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678a1b2c3d4e5f60718293a4b5c" } ]
},
"schema": "schema/tbook-2.schema.json",
"digests": {
"schema/tbook-2.schema.json": "sha256:2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c",
"text/ch1.json": "sha256:6d345441cb4fa35e2e1c71ccf0b4a12874c2dfd3679cbcd52cfec965dc21e141",
"gloss/ch1.ru.json": "sha256:9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d"
},
"meta": {
"createdAt": "2026-09-14T10:00:00Z",
"runs": [ { "at": "2026-09-14T10:00:00Z", "producer": { "name": "tbook-converter", "version": "2.0.0" },
"targets": ["ru", "tr", "de"], "options": { "tokenizer": "tbook-w2", "lexcheck": true } } ]
}
}
(Hex values are layout placeholders except digests["text/ch1.json"], the real
digest of the Annex C skeleton; digests
is shown truncated.)
3.1 Fields
| Field | Type | Required | Description |
|---|---|---|---|
formatVersion |
integer | Yes | MUST be 2. Checked before any other field (§10.1). |
requires |
array of string | Yes | Feature ids a consumer MUST implement to open the file (§3.2). |
title |
string | Yes | Book title. MAY be empty. |
authors |
array of string | Yes | Authors in display order. MAY be empty. |
languages |
object | Yes | { "source": tag, "targets": [tag, …] } (§3.4). |
text |
object | Yes | Tokenizer, offset unit and normalization declaration (§3.3). |
cover |
string | null | No | Entry name of the cover image; absent or null = no cover. Format detected from bytes, never from the extension. |
notes |
object | Yes | Footnote entries: {} when the book has no footnotes, else { "text": entry, "gloss": { lang: entry } } (§3.7). Always an object, never a string or null (§10.4). |
spine |
array of ChapterRef |
Yes | Reading order; MUST contain ≥ 1 entry (§3.5). |
source |
object | No | Identity of the source document the book was converted from (§3.6). |
schema |
string | Yes | Entry name of the shipped JSON Schema (§3.9). |
digests |
object | Yes | entryName → "sha256:<64 lowercase hex>" for every entry except mimetype and manifest.json (§9.1). |
meta |
object | No | Provenance and service metadata; carries no format semantics (§3.10). |
Consumers MUST ignore unknown manifest fields.
3.2 formatVersion and requires
formatVersion is the on-disk format major version and is a constant 2 for
every file conforming to this document. A consumer that does not implement
version 2 MUST refuse the file (§10).
requires lists feature ids a consumer must implement to render the file
correctly. A consumer MUST refuse a file whose requires contains an id it
does not implement, and SHOULD show that id to the user. Registered ids:
| Feature id | Meaning | In version 2.0 files |
|---|---|---|
text/paragraph |
Paragraph text with sentence ranges (§4.5). | MUST be listed |
align/token |
Per-target-token links with x escapes and words overrides (§6.4–§6.6). |
MUST be listed |
tokenizer/tbook-w2 |
The tokenizer of §5. | MUST be listed |
Rules:
- A producer MUST list exactly the ids whose absence in a consumer would
produce a wrong rendering or wrong highlights. Purely additive slots that a
consumer can ignore without harm (
units,groups,parts,source,spans,notes,figure,table,s,v) MUST NOT be listed. - A future revision that introduces a mandatory feature MUST register a new id here; it MUST NOT change the meaning of an existing id.
- Ids are compared byte-for-byte; unregistered ids MUST use an
x-prefix or a reverse-DNS name.
3.3 text — tokenizer, offsets, normalization
{ "tokenizer": "tbook-w2", "offsets": "codepoint", "normalization": "NFC" }
| Field | Value | Meaning |
|---|---|---|
tokenizer |
"tbook-w2" |
The tokenizer applied to every block text and every translation t that carries no words override (§5). Under version 2 this is the only registered value; requires repeats it as tokenizer/tbook-w2. |
offsets |
"codepoint" |
The unit of every offset in the file — sents, words, spans, notes[].p, x, parts (§7). Constant. |
normalization |
"NFC" |
Every string in every JSON entry is NFC. Constant. |
These declare rules that are mandatory anyway, so that a validator and a future version can see what a file assumes.
3.4 languages
{ "source": "en", "targets": ["ru", "tr", "de"] }
Language codes are BCP 47 tags; bare lowercase ISO 639-1 is the common case.
Consumers MUST treat tags as opaque, case-sensitive keys matched exactly
against spine[].gloss, overlay lang and notes.gloss keys. targets is
the display order, SHOULD be non-empty, and MUST NOT contain source
or duplicates. A listed target SHOULD have an overlay in every spine
entry; where one is missing (a partial conversion) the consumer MUST render
the source text with no translation, and a validator SHOULD warn.
3.5 spine — chapter index
ChapterRef:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Content-derived chapter id (§3.5.1). Unique within the file. |
title |
string | Yes | Navigation title. Producer fallback: "Chapter N". |
level |
integer ≥ 0 | No | Nesting depth for a hierarchical table of contents. Default 0. |
text |
string | Yes | Entry name of the skeleton (§4). |
gloss |
object | Yes | lang → entry name of the overlays (§6). MAY be empty. |
paragraphs, sentences, words |
integer | No | Counts, so a consumer can weight reading progress by text rather than by bytes. Informative. |
The chapter title is also re-emitted by the reference producer as the
skeleton's first heading-role paragraph, so it is translated and tappable. A
consumer that renders ChapterRef.title above the text SHOULD skip its own
copy when the skeleton opens with a heading paragraph.
3.5.1 Chapter id
The chapter id is derived from content that re-conversions of the same book leave untouched. Measured on the two real re-conversion pairs in the reference corpus, a hash over all paragraph texts survived 0 of 58 chapters (one heading paragraph is inserted per chapter by the current producer), while the recipe below survived 12 of 12 and 45 of 46 (report §4.3, verification). The recipe is therefore normative so that two producers emit the same id for the same chapter:
normTitle= the chapter title, NFC-normalized, with every maximal run of UnicodeWhite_Spacecharacters replaced by one U+0020, leading and trailing whitespace removed, then full Unicode case folding applied (toCasefold).body₁ … bodyₙ= thetextof the first three paragraphs of the skeleton whose role isbody(fewer if the chapter has fewer), NFC, verbatim.bytes= UTF-8 ofnormTitle, then for eachbodyᵢ: U+000A followed by UTF-8 ofbodyᵢ. (No trailing newline.)id="c"+ the first 8 lowercase hex digits of SHA-256(bytes).- If the result equals an id already assigned to an earlier
spineentry, append-2for the second occurrence,-3for the third, and so on, in spine order.
The skeleton entry MUST repeat the id in its top-level id field, and every
overlay of the chapter MUST repeat it in chapter (§6.1);
a mismatch is a binding error and the consumer MUST reject the entry.
Example (Annex B): title Canonical sentences
→ normTitle = canonical sentences; body paragraphs S1, S2, S3 →
id = cbedb517c.
3.5.2 Locators
A locator names a word: chapterId/paragraphId/sentenceIndex/wordIndex,
e.g. cbedb517c/04537723/0/3 is wait in S1. Shorter locators name a
sentence (…/0), a paragraph, or a chapter. Consumers SHOULD store reading
positions and bookmarks as locators, never as array indices.
Precision. A consumer that anchors reading
positions by paragraph MAY store the paragraph-precision locator
chapterId/paragraphId/0/0; it is conformant and denotes the start of that
paragraph. A chapter-only locator chapterId denotes the position before the
first paragraph of the chapter — the cover or title position of a chapter
that opens with one.
Resolution rule (normative). To resolve a locator: (1) find the spine entry
with the chapter id; (2) within its skeleton find the paragraph id; (3) index
the sentence and word. If step (1) fails — the chapter id is unknown, as after a
re-conversion whose title or opening paragraphs changed — the consumer MUST
fall back to searching the paragraph id across all chapters and resolve to
the first match in spine order. Paragraph ids survive re-conversion far more
often than any chapter-level recipe (99.40 % / 99.96 % on the same two pairs,
report §4.3), which is why the fallback is mandatory and not advisory. A locator whose chapter resolves but whose paragraph does not SHOULD degrade to the first paragraph of that chapter — the chapter start, including its title (a leading heading paragraph, §3.5); one whose chapter is unknown too degrades to the book start. It MUST NOT resolve to a wrong word.
3.6 source — source identity
An optional block recording which source document — and which edition of it — the book was converted from. It is the only mechanism that can say whether a gloss belongs to the text a reader holds.
| Field | Type | Description |
|---|---|---|
kind |
string | "epub", "fb2", or another producer-defined kind. |
sha256 |
64 hex | SHA-256 of the source file bytes — exact-file identity. |
identifier |
string | The source's own identifier (dc:identifier for EPUB), if any. |
title, language |
string | The source's declared title and language, if any. |
textSha256 |
64 hex | Edition fingerprint that survives repackaging (recipe below). |
docs |
array | One record per source document in reading order: { "href", "chars", "sha256", "title"? } — sha256 over the document's text stream, chars its code-point length. |
textSha256 recipe. stream(k) = the text
nodes under document k's body concatenated in document order (descendants of
script, style, template excluded), line endings normalized to U+000A, no
other change. docs[k].sha256 = SHA-256(UTF-8(stream(k))). textSha256 =
SHA-256 of UTF-8 of NFC(stream(k)) with every White_Space code point removed,
for all k in order, joined by U+0000 — so the same text in an EPUB 2 and an
EPUB 3 wrapper has one textSha256 and two sha256s.
source is informative: a consumer MUST NOT require it, and MAY compare
it with a source file it holds to warn about an edition mismatch.
3.7 notes
notes is REQUIRED and is always a JSON object:
{}— the book has no footnotes.{ "text": "text/notes.json", "gloss": { "ru": "gloss/notes.ru.json" } }— footnote bodies in skeleton shape (§4.12) and one overlay per language (§6.12).glosskeys MUST be a subset oflanguages.targets.
The object form is mandatory even when empty so that every version-2 file
presents the same shape to a version-1 consumer, whose notes field is a
string (§10.4).
3.8 cover
Optional, as in version 1: when present and non-null the entry MUST exist and hold image bytes; the extension is nominal.
3.9 schema — the shipped JSON Schema
Every file MUST ship, byte-identical, the published JSON Schema (draft
2020-12, $id https://tbook.dev/schema/tbook-2.schema.json) at the entry
named by manifest.schema. Its $defs manifest, textChapter, textNotes,
glossChapter, glossNotes are normative for shape (types, required keys,
enumerations, patterns); cross-entry invariants — binding, counts, ranges,
digests — are normative in this prose (§11). Consumers need
not run a schema validator on open; the copy serves tooling and
self-description.
3.10 meta — provenance
meta is unchanged from version 1 §3.4 in shape and rules: an optional,
informative object with registered keys createdAt, updatedAt, runs[]
(RunRecord = at, producer{name, version, commit, url}, targets,
provider, models, prompts, options), open to namespaced producer keys.
Additional version-2 rules: meta MUST NOT carry format semantics —
tokenizer, offset unit, statuses, verdicts and ids live in their normative
fields, and a consumer MUST NOT derive rendering or tap behaviour from
meta. A run that appends a language (§2.4) or
replaces one (§2.5) MUST carry the existing
meta over and append its own record, whose targets lists only the added or
replaced language(s). It MAY set meta.updatedAt to the run's time and
MAY cap meta.runs to the most recent 20 records (the new one included);
everything else in meta — createdAt, the remaining records, unknown keys —
MUST be carried over unchanged.
runs[].options.tokenizer SHOULD record the aligner's tokenizer id, which
MUST equal text.tokenizer. No secrets, no machine identity, keep it small.
4. Skeleton — text/chN.json
The skeleton is the language-free chapter: paragraph text, sentence ranges, roles, emphasis, footnote markers, figures, tables and optional source-side units. It contains nothing that depends on the set of target languages.
{
"id": "cbedb517c",
"paragraphs": [
{ "id": "0f4552b1", "role": "heading", "text": "A SCANDAL IN BOHEMIA", "sents": [[0, 20]] },
{ "id": "04537723", "text": "“Then I can wait in the next room.”", "sents": [[0, 35]] },
{ "id": "781304bb",
"text": "“‘Oh, at his new offices. He did tell me the address. Yes, 17 King Edward Street, near St. Paul’s.’",
"sents": [[0, 23], [23, 51], [51, 88], [88, 96], [96, 99]] },
{ "id": "ff1de7b8",
"text": "“Still, if I had married Lord St. Simon, of course I’d have done my duty by him.",
"sents": [[0, 80]],
"spans": [{ "s": 25, "e": 39, "k": "i" }],
"units": [{ "s": 0, "w": [8, 9], "k": "idiom" }] }
]
}
4.1 Chapter object
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The chapter id; MUST equal the referencing spine[].id (§3.5.1). |
paragraphs |
array of Block |
Yes | Ordered paragraphs. MAY be empty. |
4.2 The Block object
A block is a paragraph, a table cell, or a footnote paragraph. All three share this shape.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes for paragraphs; No for cells | Content-derived paragraph id (§4.3). |
role |
string | No | body (default), subtitle, heading, sceneBreak, figure, table. Unknown → body (§4.4). |
text |
string | Yes | The whole block text, NFC, displayed verbatim. Empty for sceneBreak and table. |
sents |
array of [a, b] |
Yes | Sentence ranges into text (§4.5). MAY be empty. |
words |
array of array of [a, b] |
No | Per-sentence source word ranges, only when the block was not tokenized with text.tokenizer (§4.6). |
spans |
array of Span |
No | Inline emphasis (§4.7). |
notes |
array of NoteRef |
No | Footnote markers (§4.8). |
units |
array of Unit |
No | Source-side multi-word units (§4.9). |
figure |
object | No | { "image": entry, "alt"?: string }; only on a figure block (§4.10). |
table |
object | No | { "rows": [[Block, …], …] }; only on a table block (§4.11). |
header |
boolean | No | Table cells only: header cell. Default false. |
4.3 Paragraph id
id = the first 8 lowercase hex digits of SHA-256 over UTF-8 of the block's
NFC text, verbatim. If the result equals the id of an earlier paragraph of
the same chapter, append -2, -3, … in document order (66 paragraphs of the
reference sample repeat, mostly * * * and single-word replies). Ids of
different chapters MAY coincide; a locator always carries the chapter id.
Cells and footnote paragraphs MAY carry ids under the same recipe (scoped to the table, or to the note); overlays bind to them by position.
Examples: “Then I can wait in the next room.” → 04537723; the S9 paragraph
→ 781304bb.
4.4 role
Roles are presentational hints exactly as in version 1 §4.1: body,
subtitle, heading, sceneBreak, figure, table. A sceneBreak or
table block MUST have text "" and sents []. A consumer MAY
render any role as body text without losing tap-to-translate; it MUST
treat an unknown role as body.
4.5 text and sents
text is the paragraph as displayed. The producer has already normalized it
(whitespace collapsed, non-breaking spaces converted, trimmed, NFC). A
consumer MUST render text verbatim and MUST treat it as the
coordinate space of sents, words, spans and notes[].p
(§7).
sents[k] = [a, b] is the half-open code-point range of sentence k:
0 ≤ a < b ≤ length(text); ranges MUST be sorted and MUST NOT overlap; gaps between ranges are allowed (inter-sentence whitespace).- A boundary MAY fall anywhere, including inside a word; the consumer never joins sentences, so a mid-word cut only affects which sentence a word belongs to (next rule).
- Word inventory. The words of sentence
kare the tokens oftext(§5) whose first code point lies in[a, b), in text order; the index of a word in that list is its word index, the value every link refers to. A token that starts in one range and ends in the next belongs to the range containing its start. - Every token of
textMUST start inside some sentence range (a token in a gap would be untappable); a validator MUST report a violation. A consumer that meets one MUST attach it to the preceding sentence (to the first sentence when none precedes), so word indices stay stable.
In the S9 paragraph the boundary at 23 cuts offices (17–24); the token
belongs to sentence 0 as word 4, sentence 1 (23–51) has words He did tell me the address, and sentence 4 (96–99, s.’) has no words.
A consumer that interleaves a gloss after each sentence (bilingual mode) MUST NOT cut a word on the page: it splices after the last word of the sentence even when that word extends past sents[k][1].
4.6 words — source word inventory override
When present, words[k] is the explicit list of word ranges [a, b] (block
coordinates, code points) of sentence k, replacing the tokenizer for this
block. It exists for scripts the tokenizer cannot segment (Chinese, Japanese,
Thai — a producer with a segmenter emits it) and for producers whose word
inventory differs from tbook-w2.
wordsMUST have exactlylength(sents)elements; each range MUST lie inside its sentence, satisfya < b, be sorted and non-overlapping, and contain no U+0009 or U+000A.- A consumer MUST use
wordswhen present and MUST NOT re-tokenize the block; implementing this override is part ofalign/token.
4.7 spans — inline emphasis
{ "s": a, "e": b, "k": "i" | "b" }, block coordinates, 0 ≤ s < e ≤ length(text).
Semantics as version 1 §5.5: spans MAY overlap, a consumer unions styles,
ignores unknown k, and clamps out-of-range offsets. Independent of words and
alignment.
4.8 notes — footnote markers
{ "p": offset, "id": noteId, "label": string } — p is an insertion point
into text, 0 ≤ p ≤ length(text); id is a key of text/notes.json; the
marker text is not part of text. Semantics as version 1 §5.6. An id
absent from text/notes.json SHOULD be ignored.
4.9 units
A unit is a producer-asserted multi-word expression on the source side:
{ "s": 0, "w": [8, 9], "k": "idiom" }
| Field | Type | Required | Description |
|---|---|---|---|
s |
integer | Yes | Sentence index within the block. |
w |
array of integer | Yes | ≥ 2 word indices of that sentence, ascending, MAY be discontiguous. |
k |
string | No | Kind: idiom, phrasal, fixed, name, number, mwe. Unknown kinds are legal and render as mwe. |
Rules:
- A unit MUST be derivable from the source text alone (a tagging pass over the source, a lexicon, or an editor). It MUST NOT depend on any target language or on the set of targets present: the skeleton would otherwise be rewritten whenever a language is added, and the append of §2.4 would be impossible (report §4.3, M10).
- Units are an optional learner slot. A consumer ignoring them loses only the unit label and the union highlight of §8.4.
- Overlapping units are legal; on a tap, the smallest unit containing the word wins.
4.10 figure
On a figure-role block: { "image": "images/img1.jpg", "alt": "" }. The
block's own text/sents are the caption — translated and tappable like any
paragraph, or empty for a caption-less image. image MUST name an existing
entry; format is detected from bytes.
4.11 table
On a table-role block (whose text is ""): { "rows": [[cell, …], …] },
rows non-empty. A cell is a Block (with text, sents, optional
header, spans, notes, words, units). Rows MAY differ in cell
count; a consumer renders by row. A consumer ignoring tables loses their
content, as in version 1.
4.12 Footnote bodies — text/notes.json
{
"n1": { "label": "1", "kind": "note",
"paragraphs": [ { "id": "ea90ea77", "text": "The two attendants brought up the rear.", "sents": [[0, 39]] } ] }
}
A map noteId → Note; Note = { "label": string, "kind"?: "note" | "citation", "paragraphs": Block[] }. Unknown kind → note. Note ids are opaque and
unique within the file. The overlay side is §6.12.
5. Tokenizer tbook-w2
The tokenizer is normative for both sides: it produces the source words
of every block and the target tokens of every translation, unless a words
override is present (§4.6,
§6.6). It is what lets the file
drop every per-word character range: the consumer regenerates them.
5.1 Definition
A token is a maximal match of the following regular expression, applied left-to-right with no overlap, to the NFC text:
[\p{L}\p{N}][\p{L}\p{M}\p{N}]*(?:['’\-][\p{L}\p{N}][\p{L}\p{M}\p{N}]*|[.,:]\p{N}[\p{L}\p{M}\p{N}]*)*
pinned to the Unicode 15.1.0 character database for the General_Category
properties L, M, N. The three joiners are U+0027 APOSTROPHE, U+2019 RIGHT
SINGLE QUOTATION MARK and U+002D HYPHEN-MINUS; the three numeric separators are
U+002E, U+002C and U+003A. The expression contains no lookaround, no
backreference and no case-insensitive flag, so RE2 (Go), java.util.regex,
JavaScript /u, Rust regex and Python regex implement it identically.
In words: a token starts with a letter or a number and continues with letters,
marks and numbers; an apostrophe or hyphen joins two such runs (don’t,
Bow-Street-Zellen, этом-то, l'avons); ., , and : join two runs only
when the right-hand run starts with a digit (30,000, 3.14, 5:15).
Whitespace, punctuation and symbols — including £, §, % — are never part
of a token.
5.2 Properties
- Digits are tappable.
4and17in S4/S9 are words;£is not. - Superset of the version-1 word. Every version-1 word lies inside exactly
one
tbook-w2token (verified on 995,021 taps, Annex A.3). - Version pin.
tbook-w2is frozen. Any change to the expression or to the Unicode version is a new tokenizer id and a newrequiresentry; it MUST NOT be made under the nametbook-w2, because target token indices in every overlay depend on it. - Platform tables (informative). A consumer uses its platform's Unicode
tables, which may predate 15.1; only code points whose General_Category
changed since differ, and none occur in the reference corpus. A producer
MUST use 15.1.0 tables or later and SHOULD ship a
wordsoverride where a block's tokenization would differ under 15.1.0.
5.3 Examples
| Text | Tokens |
|---|---|
“That’s the worst of it, Mr. Holmes, I don’t know.” |
That’s the worst of it Mr Holmes I don’t know |
“‘Is £ 4 a week. |
Is 4 a week |
В этом-то всё и дело. |
В этом-то всё и дело |
„Er sieht nicht gerade wie eine Zierde für die Bow-Street-Zellen aus, was?“ |
Er sieht nicht gerade wie eine Zierde für die Bow-Street-Zellen aus was |
. Да, Кинг-Эдвард-стрит, 17, рядом с С |
Да Кинг-Эдвард-стрит 17 рядом с С |
5.4 Lazy tokenization (consumer requirement)
Tokenizing is cheap per string (microseconds) but materializing token arrays for every language of a chapter is not: doing so eagerly for the eight languages of the reference sample costs more heap than the whole version-1 chapter tree (14,486,956 B versus 12,784,448 B, report §4.3, M4). Therefore a consumer MUST tokenize per language on demand — the active language's translations when they are displayed, or on tap — and MUST NOT tokenize every overlay of a chapter on open.
6. Overlay — gloss/chN.<lang>.json
One overlay per (chapter, language). It binds to the skeleton by chapter id and paragraph ids, and carries per sentence the translation text and one link per target token.
{
"lang": "ru",
"chapter": "cbedb517c",
"gates": ["langcheck", "lexcheck"],
"alignDigest": "sha256:4bfa91a5b38848ba41ed5bf471da900413b0acdb6c68928dfaa74c0b6e832918",
"paragraphs": [
{ "id": "0f4552b1", "s": [ { "t": "СКАНДАЛ В БОГЕМИИ", "a": [1, [0, 2], 3] } ] },
{ "id": "04537723", "s": [ { "t": "«Тогда я могу подождать в соседней комнате.»", "a": [0, 1, 2, 3, 4, [5, 6], 7] } ] },
{ "id": "781304bb", "s": [
{ "t": "«О, в его новом офисе", "a": [0, 1, 2, 3, 4] },
{ "t": ". Он действительно сказал мне адрес", "a": [0, 1, [1, 2], 3, [4, 5]] },
{ "t": ". Да, Кинг-Эдвард-стрит, 17, рядом с С", "a": [0, [2, 3, 4], 1, 5, 5, 6] },
{ "t": ". Павлом»", "a": [0] },
{ "t": ".»" }
] },
{ "id": "ff1de7b8", "s": [
{ "t": "«Всё же, если бы я вышла замуж за лорда Сент-Саймона, я, конечно, исполнила бы свой долг перед ним.",
"a": [0, 0, 1, 3, 2, 4, 4, 4, 5, [6, 7], 10, [8, 9], [10, 12], [10, 11], 13, 14, 15, 16] }
] }
]
}
(The alignDigest shown is the real value for a one-paragraph overlay holding
only the S1 sentence, Annex C; a
four-paragraph overlay has a different digest.)
6.1 Overlay object and binding
| Field | Type | Required | Description |
|---|---|---|---|
lang |
string | Yes | The overlay's language tag; MUST equal the spine[].gloss key that names this entry. |
chapter |
string | Yes | MUST equal the skeleton's id. |
gates |
array of string | Yes | Quality gates that ran over every translation of this overlay (§6.8). MAY be empty. |
alignDigest |
string | Yes | "sha256:<64 hex>" over the alignment pairs (§9.2). |
paragraphs |
array of GlossParagraph |
Yes | Parallel to the skeleton's paragraphs. |
GlossParagraph is { "id": paragraphId, "s": [Translation | null, …] } for a
text block, or { "id": paragraphId, "rows": [[GlossCell, …], …] } for a
table block, where GlossCell = { "s": [Translation | null, …] }.
Binding rules — a consumer MUST reject the overlay (treat the chapter as having no translation in this language, and report the error) if any fails:
chapter≠ skeletonid.length(paragraphs)≠ skeletonlength(paragraphs).- For any
p:paragraphs[p].id≠ skeletonparagraphs[p].id. - For any text block:
length(s)≠length(sents); for any table: row count, or any row's cell count, or any cell'slength(s)differs. lang≠ thespine[].glosskey that names this entry.- Shape mismatch: a text block whose gloss paragraph carries
rows, or atableblock whose gloss paragraph carriess.
A null entry means "not translated". A rejected overlay MUST NOT be
rendered partially; permutations that pass all four rules are caught by
digests and alignDigest (§9).
6.2 The Translation object
| Field | Type | Required | Description |
|---|---|---|---|
t |
string | No | Translation text, NFC, displayed verbatim; the coordinate space of x, words, parts. Absent = no translation text (then only s/v may be present). |
a |
array of Link |
No | One link per target token, in token order (§6.4). Absent = no links known. |
x |
array | No | Escape chunks (§6.5). |
words |
array of [a, b] |
No | Target token inventory override (§6.6). |
s |
string | No | Status; absent = ok (§6.7). |
v |
object | No | Verdict of a gate (§6.8). |
lang |
string | No | Actual language of t when it differs from the overlay's (§6.9). |
groups |
array | No | Producer-asserted target lexemes (§6.10). |
parts |
array | No | Producer-asserted sub-token spans (§6.11). |
a, x, words, groups, parts MUST NOT appear without t.
6.3 Absent translations
A null entry in s[], a Translation without t, or an empty t is "no
translation available": the consumer renders the source without a gloss and
shows the status, if any (§6.7). A producer SHOULD write
null for a sentence it never attempted and a Translation with s for one
it attempted and did not ship.
6.4 a — links
a[j] is the link of target token j (token order, §5
or words):
| Value | Meaning | Highlighted by a tap on… |
|---|---|---|
int i |
token j renders source word i |
word i |
int[] |
token j renders several source words (compound, contraction, fused idiom) |
any of them |
null |
unaligned: the producer does not know what j renders |
nothing |
[] |
inserted: j has no source counterpart (article, particle, auxiliary) |
nothing |
Invariants:
length(a) ≤ number of tokens of t; missing trailing entries arenull. A consumer MUST ignore entries beyond the token count; a validator MUST report them.- Every index MUST satisfy
0 ≤ i < wordCount(sentence); anint[]MUST be ascending with no duplicates and MUST have ≥ 1 element when it is not the inserted marker[]. - A source word MAY appear in the links of several tokens (one-to-many).
nulland[]are different statements. A producer MUST write[]only when it knows the token was inserted; a migration or an aligner that cannot tell MUST writenull.
Examples (S1, source words 0=Then 1=I 2=can 3=wait 4=in 5=the 6=next 7=room):
{ "t": "“O zaman bitişik odada bekleyebilirim.”", "a": [0, 0, [5, 6], [4, 7], [1, 2, 3]] }
Tapping wait (3) highlights «bekleyebilirim»; so does tapping I or can —
a fan-in the parts slot can refine (§6.11). In de,
"a": [0, 2, 1, [4, 5], [6, 7], 3]: «im» renders in the, «Nebenzimmer»
next room. S5 (de): "a": [0, 2, 1, null, null, 3, 4, 5, 6, [7, 8, 9], 2, [10, 11]]
— gerade and wie are unaligned, not inserted; look (2) is rendered by
tokens 1 and 10, «sieht … aus».
6.5 x — escape chunks
An escape chunk records an alignment whose target span is not exactly one
token: a part of a token, or a run across tokens. Each entry is
[a, b, i₁, i₂, …] — a half-open code-point range of t followed by zero or
more source word indices:
{ "t": "«Всё же, если бы я вышла замуж за лорда Сент-Саймона, я, конечно, исполнила бы свой долг перед ним.",
"a": [0, 0, 1, 3, 2, 4, 4, 4, 5, null, 10, [8, 9], [10, 12], [10, 11], 13, 14, 15, 16],
"x": [[40, 44, 6], [45, 52, 7]] }
Had the producer aligned the halves of «Сент-Саймона» separately (Сент ←
St, Саймона ← Simon), token 9 would be null in a and the two sub-token
spans would live in x. (The reference file links the whole token, [6, 7].)
Rules:
0 ≤ a < b ≤ length(t); the range MUST contain at least one letter or digit; indices follow the rules ofa.- An escape chunk MUST NOT overlap a token whose
aentry is a link (intorint[]); it MAY overlapnulland[]tokens. It MUST NOT coincide with a whole token (that is a link, not an escape). xis the exception path (0.02 %–0.56 % of corpus chunks, Annex A.3). A producer SHOULD preferwords(§6.6) for a whole translation with a different token inventory, andparts(§6.11) to refine an already linked token.
6.6 words — target token inventory override
When present, words = [[a, b], …] is the explicit token list of t
(code-point ranges, sorted, non-overlapping, a < b, no U+0009/U+000A) and a
indexes it instead of the tokenizer output. It is REQUIRED for a target the
tokenizer cannot segment: under tbook-w2 a Chinese translation is one token
per punctuation-delimited run, and 「所以基本上你是在给你老爸施压」 would receive a
single link listing every source word (report §4.3, verification); with words
a segmenter's tokens carry ordinary per-token links. A consumer MUST
implement this override (it is part of align/token) and MUST NOT
re-tokenize a translation that carries words.
6.7 s — status
s is a producer-written state of one translation. Absent = ok.
| Value | Meaning | t |
|---|---|---|
raw |
Translated; the alignment pass did not run or failed after retries; shipped without links. | present |
unaligned |
No links, cause not recorded. The only value a migration tool may write (Annex A.4). | present |
skipped |
Deliberately not translated or aligned by policy (e.g. bibliographic citations). | optional |
rejected |
A gate judged the translation wrong and it was not repaired. | optional |
unverified |
A gate flagged it; regeneration was exhausted; shipped as is. | present |
wrongLang |
The text is not in the overlay's language (a language check). lang SHOULD name the detected language. |
present |
free |
A free rendering: sentence-level meaning is right, per-word taps are unreliable. | present |
Rules:
- Only the producer writes
s. A consumer MUST NOT derive a status (e.g. by a script test) and drive its UI from it: the panel's majority-script check mis-flagged 51 of 1,361 cells, all Roman-numeral headings such as «I.», «II.» (report §4.3). A migration tool writesunalignedonly. - A consumer MUST surface a non-ok status — a badge, a dimmed highlight,
or a hidden gloss — so a learner never takes «СКАНДАЛ В БОГЕМИИ» under the
trkey for Turkish (S8; 9.43 % of the shipped sample's sentences carried such a cell, report §2.2 C19). ForwrongLangandrejectedit MAY hidet. Unknown values MUST be surfaced likeunverified. A consumer MUST NOT hidetfor a status it does not know. - An ok
ssays nothing about correctness; that is whatvandgatessay.
6.8 v and gates — verdicts
gates (overlay level) lists the quality gates that ran over every
translation of the overlay. v (per translation) is the result of a gate that
did not pass the cell:
{ "t": "Двое сопровождающих замыкали шествие.", "a": [1, [0, 2], 2],
"s": "free", "v": { "ok": false, "by": "judge", "why": "free rendering; brought/rear have no counterpart" } }
| Field | Type | Required | Description |
|---|---|---|---|
ok |
boolean | Yes | false = flagged; true = explicitly re-verified after a repair. |
by |
string | Yes | The gate id; MUST be listed in gates. |
why |
string | No | Short human-readable reason. |
Registered gate ids: langcheck (language identification), lexcheck
(dictionary support of alignment pairs), judge (LLM semantic verification).
Others MUST be namespaced (x-…).
Together the two fields make absence meaningful: no v under
gates: ["judge"] means judged and passed; no v under gates: [] means never
checked. A consumer SHOULD show which gates ran and MUST surface
v.ok == false like a non-ok status.
6.9 lang
The actual language of t when a language check found it differs from the
overlay's lang; informative, always accompanied by s: "wrongLang".
6.10 groups
A group is a producer-asserted set of target tokens that form one lexeme, with the source words they render together:
{ "t": "„Er sieht nicht gerade wie eine Zierde für die Bow-Street-Zellen aus, was?“",
"a": [0, 2, 1, null, null, 3, 4, 5, 6, [7, 8, 9], 2, [10, 11]],
"groups": [["lexeme", [1, 10], [2]]] }
Each entry is [kind, targetTokens[], sourceWords[]]; kinds: lexeme
(a discontinuous lexeme such as the separable verb sieht … aus), phrase
(a contiguous multi-token rendering), name. A group MUST be consistent
with a (every listed token's link contains at least one of the listed
words). A group takes precedence over the derived labelling of
§8.3 for the words it lists. When several groups list the tapped word, the first in list order wins. A
consumer ignoring groups loses only the label.
6.11 parts
A part refines an already linked token into sub-token spans with their own links — the slot for morpheme-level alignment of agglutinative targets and for elisions:
{ "t": "“O zaman bitişik odada bekleyebilirim.”", "a": [0, 0, [5, 6], [4, 7], [1, 2, 3]],
"parts": [[4, 0, 5, 3], [4, 5, 10, 2], [4, 10, 12, []], [4, 12, 14, 1]] }
Each entry is [token, a, b, link]: a, b are code-point offsets relative
to the token's start, 0 ≤ a < b ≤ tokenLength (the tokenizer token, or the words override range when one is present); link is an int, an
ascending int[], or [] for an inserted morpheme. Here bekle ← wait,
yebil ← can, ir (aorist) inserted, im ← I.
Rules: parts of one token MUST NOT overlap; every non-empty part link
MUST be a subset of the token's a link; a token with parts MUST have
an int or int[] link in a. Tap rule: smallest containing wins — if a
part of token k links to the tapped word, the consumer highlights the part
span(s), otherwise the whole token (§8.2). A consumer
ignoring parts highlights whole tokens, exactly as without them.
parts and x are disjoint by rule: x carries spans of tokens that have
no link; parts refines tokens that have one.
6.12 Footnote overlays — gloss/notes.<lang>.json
{ "lang": "ru", "gates": [], "alignDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"notes": { "n1": [ { "id": "ea90ea77", "s": [ { "t": "Двое сопровождающих замыкали шествие.", "a": [1, [0, 2], 2] } ] } ] } }
Same rules as a chapter overlay, binding per note id: every key of notes
MUST exist in text/notes.json; each note's paragraph list follows
§6.1 rules 2 and 4 against the note's
paragraphs. Note paragraphs bind by position (§4.3,
decision 6): rule 3 does not apply, and a consumer MUST NOT reject a
notes overlay because a GlossParagraph.id differs from the id of the note
paragraph at the same index (a validator MAY warn). The lang
check of rule 5 (decision 17) does apply: lang MUST equal the
notes.gloss key that names the entry, and a consumer MUST reject the
overlay (notes shown without translation in that language) if it does not. Citations MAY be left untranslated (null or
{ "s": "skipped" }). For the digest, notes are processed in ascending
code-point order of their ids (§9.2).
7. Offsets and normalization
This section is normative. Every offset in a version-2 file — sents,
words (both sides), spans[].{s,e}, notes[].p, x[][0..1], parts[][1..2]
— is a Unicode code-point offset (half-open [a, b) for ranges; an
insertion point for p) into the relevant NFC string. Every string in every
JSON entry MUST be NFC; a producer MUST normalize before computing any
offset or digest.
Consumers whose strings are UTF-16 (Kotlin/JVM, JavaScript) MUST convert code-point offsets to UTF-16 indices before slicing — once per block or translation, whenever the string contains a code point above U+FFFF. The pass is a no-op on the reference corpus (no astral characters) but it MUST exist: version 1's "clamp and hope" is what this section replaces. Ranges the consumer derives with its own tokenizer are already in native units; only offsets read from the file need conversion.
A consumer MUST still clamp every offset it reads into the valid range of
its string and skip degenerate ranges (b ≤ a) rather than fail: alignment is
model-generated and a file may carry rare imperfections that pass validation.
8. Tap-to-translate algorithm
Given a tap at code point p of a rendered block, gloss language L:
8.1 Resolve
- Find sentence
kwithsents[k][0] ≤ p < sents[k][1]; none → nothing. - Build the word inventory of
k(§4.5 orwords); find wordiwhose range containsp; none → nothing. tr = overlay(L).paragraphs[para].s[k](or the cell'ss[k]).null, not, or emptyt→ show "no translation" and stop. A non-oksorv.ok == falseMUST be surfaced (§6.7); the consumer MAY stop here forwrongLang/rejected.
8.2 Word rung
toks = tokens(tr.t)(ortr.words), computed lazily and cached.- Highlight set
H= every tokenjwitha[j] == iori ∈ a[j]; for each suchjthat haspartslinkingi, replace the whole token by those part spans (smallest containing wins); plust[a:b)of everyxchunk whose indices containi.
8.3 Derived labelling: runs and splits
This rule costs 0 bytes and applies to every file. A quarter of all taps in the reference sample land on a source word rendered by several target tokens (26.3 % contiguous, a further 4.8 % discontiguous; report §0 and §5.1), and a learner cannot tell a multi-word rendering from a smear unless the reader says which it is.
- If
Hcontains ≥ 2 whole tokens: if agroupsentry listsi, label the highlight with that group's kind (lexeme: one word rendered bysieht … aus). Otherwise derive: partition the highlighted token indices into maximal blocks of consecutive indices. One block → a run ("one rendering of word": S2just→ «этом-то всё»). Two or more blocks → a split ("split rendering": S5look→ «sieht … aus»). A consumer MAY show the label; it MUST NOT merge the blocks' text. Only tokens highlighted whole count toward this decision: part spans andxspans do not, so a highlight with fewer than two whole tokens carries no label.
8.4 Unit mode
- If
ibelongs to aunitsentry of sentencek(smallest containing unit), a consumer MAY offer a unit view: the union of step 5 over all members, labelled with the unit's kind and source text (S6of/course→ es «por supuesto», fr «bien sûr», ru «конечно»). The word view of step 5 MUST remain reachable.
8.5 Reference (TypeScript)
type Link = number | number[] | null;
interface Translation { t?: string; a?: Link[]; x?: number[][]; words?: [number, number][];
parts?: [number, number, number, Link][]; }
/** Code-point ranges of tr.t to highlight for source word i (§8.2). */
function highlightRanges(tr: Translation, toks: [number, number][], i: number): [number, number][] {
const out: [number, number][] = [];
const hits = (l: Link) => l !== null && (l === i || (Array.isArray(l) && l.includes(i)));
(tr.a ?? []).forEach((link, j) => {
if (j >= toks.length || !hits(link)) return;
const [ta, tb] = toks[j];
const parts = (tr.parts ?? []).filter(p => p[0] === j && hits(p[3]));
if (parts.length) parts.forEach(p => out.push([ta + p[1], ta + p[2]]));
else out.push([ta, tb]);
});
for (const ch of tr.x ?? []) if (ch.slice(2).includes(i)) out.push([ch[0], ch[1]]);
return out.map(([a, b]) => [clamp(a, 0, n(tr)), clamp(b, 0, n(tr))] as [number, number])
.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
}
/** §8.3: "run" for one block of consecutive token indices, "split" for several. */
function label(tokenIdx: number[]): "run" | "split" | null {
if (tokenIdx.length < 2) return null;
const s = [...tokenIdx].sort((a, b) => a - b);
return s.every((v, k) => k === 0 || v === s[k - 1] + 1) ? "run" : "split";
}
toks comes from the consumer's tokenizer (or tr.words), converted to native
units where needed (§7); n(tr) is tr.t's
length in those units.
8.6 Language switch
Switching gloss language loads another overlay; the skeleton and its tokenization MUST be reused, not re-read, and the previously active overlay SHOULD stay parsed (bounded cache) so switching back is not a second decode.
9. Integrity
9.1 digests — entry integrity
manifest.digests maps every entry except mimetype and manifest.json to
"sha256:" + lowercase hex SHA-256 of its uncompressed bytes. A producer
MUST write it complete and recompute it on every write, append
(§2.4) or replacement (§2.5).
A validator MUST verify all.
Referenced entries. The entries a file
references are: the chapter skeletons (spine[].text), the overlays (every
value of spine[].gloss), the footnote entries (notes.text and every value of
notes.gloss), cover when non-null, schema, and every key of digests. An
image named only inside chapter text (figure.image, §4.10) is
verified when it is displayed, not at import.
At import. A consumer SHOULD verify the digest of every referenced entry when it imports a file, and MUST then refuse the whole file on any mismatch or on a missing referenced entry.
After open. A consumer that verifies lazily — when an entry is first read after the file was opened — MUST refuse only the damaged entry, report the error, and keep the rest of the book usable:
| Damaged entry | Consequence |
|---|---|
| chapter skeleton | the chapter is not shown (an error is shown in its place) |
| overlay | that language is not shown for that chapter, as for a binding failure (§6.1) |
footnote bodies (notes.text) |
footnotes are unavailable |
footnote overlay (a notes.gloss value) |
footnotes stay readable; that language's footnote translations are not shown |
| image or cover | the image is not shown |
Entry without a digest. A referenced entry that
has no key in digests makes the file non-conformant
(§11.1); a validator MUST report it. A
consumer reads such an entry unverified and SHOULD log a warning; it
MUST NOT refuse the file or the entry for this reason alone.
9.2 alignDigest — the alignment-pair digest
A permuted alignment — every link shuffled within its sentence — is
structurally valid: every index is in range, coverage is unchanged, and the
version-1 validator accepted such a file byte-for-byte identically (report §2.2,
C10). alignDigest is the structural defence: a digest over the texts of
the aligned pairs, which any party can recompute from the file, and which the
producer computes independently from its aligner's output — so an index-level
drift between the aligner's pairs and the serialized links is caught at write
time, and a later permutation is caught by any validator.
Recipe. canonical is the UTF-8 string built by visiting, in order:
paragraphs in entry order (for a table, rows then cells; for a notes overlay,
note ids in ascending code-point order, then paragraphs); each sentence with a
t; and for each:
- for each token
jin order with a non-nulllink: if the link is[], append U+0009,token(j), U+000A; else for each source indexiin ascending order appendword(i), U+0009,token(j), U+000A; - then for each
xchunk in list order: for each indexiascending (or once with an empty word if there are none) appendword(i), U+0009,t[a:b), U+000A.
word(i) and token(j) are the NFC texts of the source word and target token
(tokenizer or override). parts and groups are excluded. alignDigest =
"sha256:" + hex SHA-256(canonical). A sentence with no pairs contributes
nothing; an overlay with no pairs digests the empty string.
For a one-paragraph overlay holding only S1 ru the canonical string is
Then\tТогда\nI\tя\ncan\tмогу\nwait\tподождать\nin\tв\nthe\tсоседней\nnext\tсоседней\nroom\tкомнате\n
and alignDigest is
sha256:4bfa91a5b38848ba41ed5bf471da900413b0acdb6c68928dfaa74c0b6e832918.
Swapping the links of «Тогда» and «комнате» yields
sha256:f7552db092fe0c64ff64ab2d69079e20a36e4fb3f9efd8c51dd6ab99e5e24d7b.
A producer MUST compute alignDigest from its aligner's pair list and
verify it against the serialized overlay before writing; a validator MUST
recompute it from the file. A consumer MAY verify it, on import or when it
loads the overlay.
A consumer that verifies alignDigest and finds
a mismatch MUST reject the overlay exactly as for a binding failure
(§6.1, decision 17): the chapter (or, for a
notes overlay, the footnotes) has no translation in that language, the error is
reported, and the overlay is never rendered partially. An overlay without
alignDigest is accepted by a consumer — it is read unverified — although the
field is REQUIRED and a validator reports its absence.
9.3 What each check defends
| Attack or accident | Caught by |
|---|---|
| Entry bytes corrupted or replaced | digests |
| Two overlays' contents swapped, or an overlay renamed to another chapter | digests; chapter ≠ skeleton id; paragraph id mismatch (§6.1) |
| Overlay paragraphs shifted by one | paragraph id / count mismatch |
| Links permuted or drifted inside a sentence | alignDigest |
| Gloss built from another edition of the book | source.textSha256 / docs[].sha256 (§3.6) |
None of these is a cryptographic signature; a producer that wants provenance MAY sign the manifest by an external mechanism.
10. Versioning and compatibility
10.1 formatVersion
manifest.formatVersion is the on-disk major version. A consumer MUST read
it before decoding any other field (decode the manifest as a generic JSON
object first): 1 → the version-1 code path; 2 → this document; anything
else, or a missing field → refuse, stating the version. A missing
formatVersion MUST NOT default to any version.
10.2 Additive change under version 2
Within version 2, changes MUST be additive and backward-compatible:
- New OPTIONAL fields MAY be added anywhere; consumers MUST ignore unknown fields and unreferenced entries.
- A new field that a consumer must understand to render correctly MUST
come with a new
requiresid (§3.2). - Meanings of existing fields, the tokenizer
tbook-w2, the id recipes and the digest recipes MUST NOT change; a change to any of them is version 3. - The spec MUST be updated before the reference producer emits a change.
10.3 Version-2 consumers opening a version-1 file
A version-2 consumer SHOULD keep a version-1 code path selected by
formatVersion, or convert on import with the migration of
Annex A.
10.4 Version-1 consumers opening a version-2 file
Shipped version-1 consumers do not check formatVersion (report §2.2, C20),
so the version-2 manifest is shaped to fail in them at manifest parse, before
any text is rendered: notes is an object where version 1 has a string
(§3.7); author, sourceLang, targetLangs, chapters are
absent. Making notes an object unconditionally is what makes the failure
consistent — with a nullable notes, a footnote-free book opened as an empty
library entry while a footnoted one was refused (report §4.3, verification). A
version-1 consumer tolerant of missing fields sees zero chapters and renders
nothing. In no case does it render wrong text or wrong highlights.
11. Conformance
11.1 A conformant file MUST
- Be a ZIP per §2: STORED/DEFLATE only, unique names,
mimetypefirst and STORED with the exact 25 bytes,manifest.jsonlast. - Carry a
manifest.jsonwithformatVersion2,requires⊇ the three ids of §3.2, all REQUIRED fields of §3.1,notesan object, anddigestscomplete and correct. - Ship the published JSON Schema at
manifest.schema, and have every JSON entry validate against its definition. - Have every skeleton repeat its
spine[].id; paragraph ids follow §4.3;sentssorted, non-overlapping, in range; every token starting inside a sentence;sceneBreak/tableblocks empty;figure.imageexisting;wordsoverrides well-formed. - Have every overlay satisfy the binding rules of
§6.1, every link and escape in range
(§6.4, §6.5),
xdisjoint from linked tokens,partsconsistent witha,v.by∈gates, and a correctalignDigest. - Have every string NFC and every offset a code point (§7).
- Carry only source-derived
units, and no format semantics inmeta.
11.2 A conformant producer MUST
- Tokenize source and target with
tbook-w2(Unicode ≥ 15.1.0) or emit awordsoverride; write one link per target token; write[]only for a known insertion. - Write
sonly from pipeline state; never derivewrongLangby script heuristics without a real language check. - Compute
alignDigestfrom the aligner's pairs and verify it against the serialized overlay; recomputedigestson every write or append. - Append a language per §2.4 without touching
skeleton entries, and replace one per §2.5
keeping every other entry byte-identical; refuse either on an input whose
manifest.jsonis not last or not unique, naming the entry that follows it.
11.3 A conformant consumer MUST
- Check
formatVersionfirst, thenrequires; refuse unknown ids by name. - Discover entries only through the manifest; ignore unknown fields/entries.
- Reject an overlay that fails binding; never render it partially.
- Convert code-point offsets to native units where needed; clamp; skip degenerate ranges.
- Apply
wordsoverrides on both sides; tokenize lazily per language; reuse the skeleton across a language switch. - Surface non-ok
sandv.ok == false; never derive statuses itself. - Resolve locators, wherever it stores or accepts them, with the paragraph-id fallback.
- Implement the word rung including
xandparts; never merge the text of a split's blocks (showing the run/split label is OPTIONAL, §8.3). - When it verifies integrity: refuse the whole file on a digest mismatch found
at import, refuse only the damaged entry on one found after open
(§9.1), and reject an overlay whose
alignDigestmismatches as for a binding failure (§9.2).
11.4 Validation
The reference producer validates on assembly. An independent validator checks
§11.1 in full — schema, cross-entry invariants,
digests, alignDigest — and runs the tests of
Annex B. Structural validity still does not
prove alignment correctness; only a semantic gate does, and the file now
records whether one ran (gates).
12. Reference implementations
The reference converter writes version 2 by default since 2026-09-26. Mapping: producer and validator in tbook_converter (internal/tbook,
internal/segment for tbook-w2, internal/align for links, x and the
digest; cmd/tbook-migrate, cmd/tbook-validate); consumers in
tbook_desktop (src-tauri/src/{models,tbook}.rs,
src/lib/{render,align,tokenize}.ts) and TReader (data/model, data/book,
ui/reader). A per-section file mapping, as in version 1 §11, is not
yet provided.
Annex A — Migration from version 1 (normative for migration tools)
A version-1 file converts to version 2 losslessly except for A.6. On the reference sample (8 languages, 836,600 taps) and a single-language book (158,421 taps), 99.9876 % and 100 % of taps highlight exactly what version 1 did; the whole residue is the 104 taps (13 fragments × 8 languages) on the mid-word cuts that A.7 repairs (report §4.3, M8).
A.1 Mapping
| Version 1 | Version 2 |
|---|---|
manifest.chapters[] {id, title, file} |
spine[] {id (§3.5.1), title, text, gloss{…}}; chN ordinals dropped |
author / sourceLang / targetLangs |
authors: [author] / languages.source / languages.targets |
notes: "notes.json" / null |
notes: {text, gloss{…}} / {} |
cover, meta |
unchanged; meta.runs[] gains a migration record with options.tokenizer |
chapters/chN.json paragraphs[p] (sentence list) |
text/chN.json paragraphs[p] {id, text, sents} (A.2) |
paragraphStyles[p] |
paragraphs[p].role (omitted when body) |
figures[] {para, image, alt} / tables[] {para, rows} |
paragraphs[para].figure / .table (cells become blocks) |
Sentence.src |
a range of the paragraph text |
Sentence.words |
dropped — derived by tbook-w2 (A.3); emitted as words only if a v1 word is not contained in one v2 token (0 cases in the corpus) |
Sentence.spans, Sentence.notes |
block spans, notes, offsets re-based by the sentence start |
tr[L].text |
gloss/chN.L.json …s[k].t; "" → null |
tr[L].align[] {t, w} |
a links + x escapes (A.3) |
tr[L].q |
dropped — coverage is count(a[j] ≠ null) / tokens(t), derivable |
notes.json |
text/notes.json + gloss/notes.L.json |
A.2 Paragraph text and sentence ranges
text = the v1 sentences joined with one U+0020, except no space when the
previous sentence ends in a letter or digit and the next starts with a lowercase
letter, or the previous ends in a letter + U+2019 and the next begins with a
lone s — the signature of the v1 segmenter's mid-word cuts (13 in the sample,
17 in an es-source book, 0 false joins in five books). sents[k] = each
sentence's range after the join; id per §4.3.
A.3 Links
For each v1 chunk {t:[a,b], w} of translation text:
- Map every source index in
wto the v2 word that contains the v1 word's range (sentence-relative); a v1 word whose token now starts in another sentence (the mid-word cuts) has no v2 counterpart and is dropped. - If
[a, b)equals exactly onetbook-w2tokenjoftext: seta[j]to the mapped indices (intif one, ascendingint[]if several); ifa[j]is already set, take the union. If the mapped set is empty (orphan), leavenull. - Otherwise, if
[a, b)contains a letter or digit: append[a, b, …indices]tox. Fragments with no letter or digit are dropped (they highlighted nothing in v1). - Digit pass-through: a still-
nulltoken that is a digit-only string identical to a source word of the sentence is linked to that word (S44→ «4»; 530 links in the sample), unless anxchunk overlaps the token — linking it would break §6.5 (a footnote marker in «проектирования».10», a numeric id inside an untranslated URL).
Every v1 word lies in exactly one tbook-w2 token (0 failures in 995,021
taps), so step 1 never fails. Step 3 fires for 0.02 % (English-source books) to
0.56 % (an older es-source book) of chunks — elisions split by the v1 aligner
(l + avons), digit-adjacent fragments (1888'di → di), em-dash joins
(report §4.3, verification).
A.4 Statuses
A migration tool MUST write s: "unaligned" for a translation that has
text, at least one token, and no links after A.3, and MUST NOT
write any other status: raw, wrongLang, skipped and rejected are
pipeline facts a v1 file does not record (991 sample cells and 1,435 cells of a
de→ru book conflate four causes; report §4.3, verification). gates = [];
no v is written. [] links are never written by migration.
A.5 Ids and digests
Ids per §3.5.1 and §4.3; digests and
alignDigest from the emitted bytes. Version-1 saved positions are index-based;
a consumer SHOULD map (chapterIndex, paragraphIndex) to a locator once, on
first open after migration.
A.6 What is not carried
q(derivable).- The 13 sample fragments' orphaned chunk references: a v1 chunk whose only
source word was a mid-word fragment (
s,t) becomesnull; a v1 chunk spanning the fragment and a real word keeps the real word (S9: «Он» ←He).
A.7 Worked example (S9)
Version 1 stored sample ch3 p103 as five sentences — “‘Oh, at his new office | s. He did tell me the addres | s. Yes, 17 King Edward Street, near S | t. Paul’ | s.’ — rendered by consumers as «office s. He did tell me the
addres s.». Version 2 stores the paragraph of §4
(781304bb) with sents [[0,23],[23,51],[51,88],[88,96],[96,99]] and the
ru overlay of §6. Taps: offices → «офисе»;
He → «Он» (v1: ← {s, He}); address → «адрес»; 17 → «17»; St → «С»;
Paul’s → «Павлом»; sentence 4 has no words.
A.8 Version 2 → version 1 (informative)
A down-converter is possible for prose: src = text[a:b) per sentence,
words from the tokenizer, {t, w} chunks from a (token ranges) and x.
It drops [] semantics, s, v, gates, units, groups, parts, ids,
source and alignDigest, and must re-synthesize notes as a string.
Annex B — Conformance tests
B.1 The fixture book
A one-chapter version-2 book, title Canonical sentences, chapter id
cbedb517c, eleven paragraphs in this order, one sentence each unless noted:
| # | Paragraph id | Text (source) | Fixture |
|---|---|---|---|
| 0 | 04537723 |
“Then I can wait in the next room.” |
S1 |
| 1 | 2b606e75 |
That is just my point. |
S2 |
| 2 | 1bfb4bea |
“That’s the worst of it, Mr. Holmes, I don’t know.” |
S3 |
| 3 | 8d89345a |
“‘Is £ 4 a week. |
S4 |
| 4 | 0e2c5231 |
“He doesn’t look a credit to the Bow Street cells, does he?” |
S5 |
| 5 | ff1de7b8 |
“Still, if I had married Lord St. Simon, of course I’d have done my duty by him. |
S6 |
| 6 | ea90ea77 |
The two attendants brought up the rear. |
S7 |
| 7 | 0f4552b1 |
A SCANDAL IN BOHEMIA (role heading) |
S8 |
| 8 | 781304bb |
the S9 paragraph, 5 sentences (§4) | S9 |
| 9 | 0982e735 |
“If your Majesty would condescend to state your case,” … |
S10a |
| 10 | b51a0a09 |
With hardly a word spoken, … threw across his case of cigars, … |
S10b |
Overlays ru, tr, de carry the translations of the panel fixture
(canonical-sentences.json) migrated per Annex A;
the a arrays quoted below are the expected migration output.
B.2 Expected taps (word rung, §8.2)
| Tap | Expected highlight | Label (§8.3) |
|---|---|---|
S1 wait |
ru «подождать»; tr «bekleyebilirim»; de «warten» | — |
S1 I, can (tr) |
«bekleyebilirim» (fan-in; with parts of §6.11: «im», «yebil») |
— |
S2 just (ru, a: [1,[0,1,2],2,1,[3,4]]) |
«этом-то», «всё» | run |
S2 is (ru) |
«В», «этом-то», «и» | split |
S3 of (tr, a: [1,[2,3],3,4,5,6,[7,8,9]]) |
«kötüsü», «de» | run |
S3 of (es, a: [0,0,[1,3],2,5,6,8,9,[7,9]]) |
«lo» | — |
S3 don’t (tr) |
«bilmiyorum» | — |
S4 4 (tr, a: [[0,2,3],1,2]; de, a: [0,0,1,2,2,3]) |
«4» | — |
S4 4 (ru, a: [0,[0,2],2,3]) |
nothing (numeral parked on Is by the v1 aligner; the file carries it faithfully) |
— |
S4 Is (ru) |
«Четыре», «фунта» | run |
S5 look (de) |
«sieht», «aus» | split; lexeme when groups present |
S5 look (ru, a: [1,2,null,0,4,9,5,[6,7,8],[10,11]]) |
«похоже» | — |
S6 of / course (ru) |
«конечно» | — (unit mode: same) |
S7 brought, rear (ru, a: [1,[0,2],2]) |
nothing; s: "free" badge when present |
— |
S7 attendants (ru) |
«сопровождающих», «замыкали» | run |
S8 any word (s: "wrongLang", lang: "ru" on the tr/de cells) |
consumer MUST surface the status; MAY hide «СКАНДАЛ В БОГЕМИИ» | — |
S9 offices / He / address / 17 / St / Paul’s (ru) |
«офисе» / «Он» / «адрес» / «17» / «С» / «Павлом» | — |
S10a case (ru) |
«дело» | — |
S10b case (word 19, ru) |
«футляр» | — |
S10b case (word 26, "spirit case") |
nothing — the reference alignment links «шкафчик» to word 24 (a), a drift the format carries and a judge gate would flag |
— |
B.3 Permuted-alignment test
Take the ru overlay; within one sentence, permute the links of a (e.g. S1:
[7,1,2,3,4,[5,6],0]). The file MUST still pass every shape and range
check of §11.1 items 1–5 except the last, and
the validator MUST fail it on alignDigest
(§9.2). Re-computing alignDigest
for the permuted file makes it pass — the test is a defence against drift and
corruption, not against a hostile producer.
B.4 Tamper tests
From a valid fixture file, each of the following MUST be refused by a
validator and by a consumer that verifies digests:
- Replace: overwrite
gloss/ch1.ru.jsonwith thetroverlay's bytes →digestsmismatch; without digests,lang≠spinekey. - Swap: exchange two skeletons or two overlays of a two-chapter book →
digestsmismatch;chapter≠ skeleton id. - Shift: delete an overlay's first
GlossParagraphand append a copy of the last → counts still equal, ids mismatch at index 0 (§6.1 rule 3) → MUST reject even without digests. - Duplicate name: a second
manifest.json→ refused (§2).
B.5 Mis-binding test
Point spine[0].gloss.ru at an overlay whose chapter is another chapter's
id, or whose paragraphs[3].id differs → the consumer MUST show the chapter
with no ru translation and report the error; it MUST NOT gloss
positionally.
B.6 Tokenizer vectors
“‘Is £ 4 a week. → Is|4|a|week; Bow-Street-Zellen, 30,000, 5:15,
l'avons, don’t, этом-то → one token each; £, —, … → none;
St. Paul’s → St|Paul’s.
B.7 Version-1 consumer test
A version-1 consumer opening the fixture MUST fail at manifest parse or list a zero-chapter book; it MUST NOT display any paragraph.
Annex C — Complete minimal example
A one-paragraph book (manifest as in §3 with a single
target ru; notes: {}, cover: null).
text/ch1.json — 117 bytes; digests["text/ch1.json"] =
sha256:6d345441cb4fa35e2e1c71ccf0b4a12874c2dfd3679cbcd52cfec965dc21e141:
{"id":"cbedb517c","paragraphs":[{"id":"04537723","text":"“Then I can wait in the next room.”","sents":[[0,35]]}]}
gloss/ch1.ru.json:
{"lang":"ru","chapter":"cbedb517c","gates":[],
"alignDigest":"sha256:4bfa91a5b38848ba41ed5bf471da900413b0acdb6c68928dfaa74c0b6e832918",
"paragraphs":[{"id":"04537723","s":[{"t":"«Тогда я могу подождать в соседней комнате.»","a":[0,1,2,3,4,[5,6],7]}]}]}
Tapping wait (3) highlights «подождать»; the (5) or next (6) highlights
«соседней» (link [5,6]); room (7) highlights «комнате». Nothing is inserted
or unaligned; gates is empty, so the absence of v means "unchecked", not
"passed". (The chapter id reuses the fixture value for illustration; a real
one-paragraph chapter with this title hashes differently, since the recipe
takes up to three body paragraphs.)
Ratified decisions (2026-09-26)
Choices the draft made that the panel did not settle, as ratified by the author on 2026-09-26. All stand as drafted except item 16.
- Manifest key shapes — ratified:
authors(array) andlanguages{source, targets}replace v1'sauthor/sourceLang/targetLangs(§3.1, §3.4). requiresscope — ratified:units,groups,partsare not gated byrequires;wordsoverrides are part ofalign/token(§3.2).xversuspartsboundary — ratified:xonly for spans of unlinked tokens,partsonly refines linked tokens (§6.5, §6.11).- Overlay binding by per-paragraph
id— ratified, rather than a single skeleton digest (§6.1). - Chapter-id byte recipe — ratified: full case folding,
White_Spacecollapse, U+000A separator,-nsuffixes in spine order (§3.5.1). - Paragraph-id repeat suffix — ratified: scoped to the chapter; cells and note paragraphs bound positionally (§4.3).
textSha256— ratified: whitespace removal + NFC only, no quote/dash fold (§3.6).- Status vocabulary — ratified:
unalignedadded,untranslatedremoved,toptional (§6.7). vis one object per translation — ratified; per-link verdicts are not representable (§6.8).- A missing overlay for a listed target is legal — ratified: partial conversion, validator warning (§3.4).
alignDigestlives in the overlay — ratified:[]links count as empty-source pairs;parts/groupsexcluded (§9.2).- Shipped schema is REQUIRED — ratified; Unicode 15.1.0 pinned in the tokenizer id (§3.9, §5).
- Mimetype — ratified:
application/vnd.tbook+zipwith the offset-38 rule (§2.2). - No ZIP64, no encryption — ratified as MUST NOTs (§2).
- Migration writes
unalignedonly for a translation with ≥ 1 token — ratified (Annex A.4). - Derived labelling — changed: showing the run/split label is MAY for consumers, not SHOULD; merging a split's blocks stays forbidden (§8.3).
langmismatch and shape mismatch are binding errors — ratified (§6.1 rules 5–6).- A token starting in a gap joins the preceding sentence in a consumer; a validator reports it — ratified (§4.5).
- Bilingual splicing never cuts a word — ratified (§4.5).
- Run/split labelling counts whole tokens only; first matching group wins — ratified (§8.3, §6.10).
- Over-long
ais ignored by consumers, reported by validators; an unknown status never hidest— ratified (§6.4, §6.7). - Locator degradation to the chapter's first paragraph, then the book start — ratified (§3.5.2).
Ratified decisions (2026-09-28)
Second ratification round, on questions raised while implementing the readers and the append path. Numbered G1–G10 as raised.
- G1 — Digest mismatch after open: a consumer that verifies lazily refuses only the damaged entry (chapter not shown; overlay → that language not shown for the chapter; footnote bodies → notes unavailable; footnote overlay → that language's footnote translations not shown; image/cover not shown); at import a consumer SHOULD verify every referenced entry and refuse the whole file on any mismatch (§9.1).
- G2 — Entry without a digest: the consumer reads it unverified and SHOULD log a warning; validators report it (§9.1).
- G3 —
alignDigestmismatch: a consumer that verifies MUST reject the overlay as for a binding failure (decision 17); an overlay withoutalignDigestis accepted (§9.2). - G4 — Referenced entries are chapter texts, overlays, footnote entries, cover, schema and every key of
digests; images named only inside chapter text are verified when displayed (§9.1). - G5 — Locators:
chapterId/paragraphId/0/0is conformant for readers that anchor by paragraph; a chapter-only locator denotes the position before the first paragraph; "first paragraph of the chapter" in the degradation rule means the chapter start including its title (§3.5.2). - G6 — Notes binding is by position (decision 6): §6.1 rule 3 does not apply to note paragraphs (§6.12).
- G7 — The
langbinding check (decision 17) applies to footnote overlays too (§6.12). - G8 — Append and
meta: an append MAY updatemeta.updatedAtand MAY capmeta.runsto the most recent 20 records; everything else inmetais carried over unchanged (§3.10, §2.4). - G9 — Replacing a language — alternative chosen: a producer MAY replace a target already present by rewriting the archive; every skeleton and every other language's overlay stays byte-identical, digests and
alignDigestof the replaced language are updated, its earliermeta.runsstay and a new run is added; partial replacement is legal (decision 10); the ZIP comment is not preserved; overlay names derive from the skeleton name (§2.5, overlay entry names). Refinement (2026-09-28, author's decision): in a partial replacement a chapter the run does not cover keeps its existing overlay for the language, byte-identical with its digest unchanged; only covered chapters get new overlays (§2.5 step 3). - G10 — Non-conformant input: when
manifest.jsonis not last or is listed twice, a producer MUST refuse to append and name the entry that follows the manifest; rewriting the whole file is a separate explicit operation (§2.4).
License
This specification is licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0). You are free to share and adapt it for any purpose, including commercially, provided you give appropriate attribution.