feat(rtf-codec): hand-written RTF reader and writer against the content pivot - #843
Merged
Conversation
This was referenced Sep 3, 2026
Mearman
force-pushed
the
feat/rtf-codec
branch
from
September 3, 2026 04:54
318fa82 to
28e5ae1
Compare
Mearman
marked this pull request as ready for review
September 3, 2026 04:59
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Lexes RTF 1.9.1's own productions from raw bytes: control words with the 32-letter name cap, the 10-digit signed parameter, and the one-space delimiter rule; control symbols, which take no delimiter at all; the \'hh hex byte escape as its own token kind so its two digits never reach the text stream; a backslash before CR/LF as \par; bare CR/LF ignored; and \binN's raw byte run consumed by the lexer, since braces and backslashes inside it would otherwise open phantom groups. Entry points take bytes rather than a string because RTF is defined over bytes -- \'hh names a byte decoded through the document's own codepage -- so a string already decoded as UTF-8 has lost what the codepage layer needs. rtfBytesFromLatin1 accepts the one string form that still holds bytes and throws above U+00FF rather than truncating.
…pivot
readRtf/readRtfContent turn RTF bytes into a wordprocessing ContentDocument,
and writeRtf/writeRtfContent turn one back into deterministic 7-bit-ASCII
RTF, with a z.codec() pair per encoding and a {\rtf magic-byte schema at the
bytes boundary.
The reader is the specification's own model literally: a brace pushes and
pops group state, a destination decides what happens to text (runs, picture
payload, a field instruction, a list number to ignore, or a discarded
ignorable group), and \uN skips the number of ANSI fallback characters the
innermost \ucN states, ending at a brace and consuming a text run byte by
byte. Header tables are read in their own pass ahead of the body, so a
producer that emits one late still reads.
Handled: font/colour/style/list header tables, paragraph and character
formatting, tables via \cell and \row marks, lists with the marker type
carried through an opaque numId grammar, PNG and JPEG pictures, HYPERLINK
fields, and the special-character vocabulary. Code page decoding covers
\ansi/\mac/\pc/\pca, \ansicpgN, and a font's own \cpgN/\fcharsetN across 24
single-byte pages plus UTF-8; the tables were generated from Python's own
codec library and verified against it rather than transcribed.
Every construct the ContentDocument mapping cannot carry -- notes, page
furniture, metafile pictures, nested tables, embedded objects -- reports a
diagnostic rather than disappearing, since a format whose readers are
required to ignore what they do not recognise makes a silent drop
indistinguishable from never having seen the construct.
A footnote, header, footer, annotation, embedded object or drawing object is read past deliberately -- no ContentDocument position carries any of them -- but the reader said nothing when it happened, which in a format whose readers are required to ignore whatever they do not recognise leaves a caller unable to tell a construct that was dropped from one that was never there. Each now emits rtf/content-destination-skipped, distinct from the rtf/unknown-destination-skipped an unrecognised ignorable group already produced, and a coverage suite proves every code in the table is reachable from a real input and fails if a code has no fixture. Three codes that no site emitted are gone rather than left as vocabulary nothing produces.
RtfDiagnostic.offset was documented as a byte offset into the input, but the only site that set it passed a token index: the tokenizer emits text in runs and carries no per-token input position, so a byte offset there would have to be invented. The field is tokenIndex now, and its comment says why that is the honest unit. The README also stated a count of supported single-byte code pages, which rots the moment a page is added; it names the families instead.
Reporting every skipped destination made a real Word document emit a warning
per numbered paragraph: {\*\pn} legacy numbering is superseded by the
\lsN/\ilvlN the reader already takes, {\nonshppict} is by definition the
picture copy Word itself refuses to read beside the one that was taken, and
the font-table subgroups were consumed by the header parser before the body
ever saw them. Annotation, object and shape sub-parts are the same: their
parent construct already reports once for the whole thing.
Those stay silent now; a footnote, header, footer, annotation, object or
shape still reports, because each is content with nowhere in ContentDocument
to go. A drop worth surfacing is only visible if the ones that are not do
not drown it.
The README already said registering this codec in documents.js was a separate change; naming the issue that holds the survey of what it touches turns that from a description into something a reader can follow.
A long paragraph and a picture payload both reach the reader as one uninterrupted byte run -- bare CR/LF does not break one, since the tokenizer skips those bytes and keeps accumulating -- and both were being appended with push(...bytes) and String.fromCharCode(...bytes). A function call's argument count is bounded, so both throw RangeError past roughly a hundred thousand bytes, which no small fixture reaches and every real document does. appendBytes and asciiStringFromBytes replace them, the latter chunked, and a 300k-byte text run and picture payload pin both. Verified the old form actually throws at that size rather than assuming it.
Mearman
force-pushed
the
feat/rtf-codec
branch
from
September 3, 2026 05:24
28e5ae1 to
daab37f
Compare
6 tasks
Mearman
added a commit
that referenced
this pull request
Sep 3, 2026
…tion engine rtf-codec (added in #843) read and wrote against ContentDocument but was unreachable from convertDocument, document-cli, document-mcp, or the web UI: rtf was not a DocumentFormat member. Adds rtf as the thirteenth DocumentFormat member (DocumentFormatSchema), a CONTENT_READERS.rtf entry over readRtfContent, a DOCUMENT_FORMAT_CODECS.rtf read/write pair, and FORMAT_CAPABILITIES.rtf (variant wordprocessing, hasLayoutPath false -- rtf-codec has no layout engine of its own, so rtf reaches PDF through a same-variant bridge to docx/odt/markdown plus that format's own toPdf/fromPdf edge, exactly like xlsx/csv's own ods-composed routing). The composition engine's FORMAT_NODES.rtf entry decodes/encodes through a lossless byte<->latin1-string round trip (rtfBytesFromLatin1 and a local inverse) rather than a genuine text conversion: RTF is byte-oriented, not UTF-8 text (a \binN run can carry arbitrary raw picture bytes), so unlike markdown/csv/svg there is no well-formed-UTF-8 decode its own bytes always survive. This keeps rtf structurally identical to every other TextFormatNode member at the type level, avoiding a union-of-calls type error that a genuinely separate bytes-native FormatNode variant would introduce into executeBridge's and executeToPdf's generic dispatch over the whole ContentFormat space. RtfBytesSchema is re-exported directly from rtf-codec at the package barrel (src/index.ts) rather than reimplemented in src/model/bytes.ts -- that module is not a barrel, and rtf-codec already exports the exact '{\rtf' magic-byte check. rtf joins REBUILD_FORMATS in src/metadata/write.ts: its \info group round-trips title/author/subject/keywords like every other content format's own metadata container. Two conversions -- rtf<->csv and rtf<->xlsx -- are genuinely unroutable: reaching either needs four hops (bridge to docx/odt/markdown, toPdf, fromPdf to ods, bridge to csv/xlsx), one past the pathfinder's own 3-hop cap, since rtf (unlike markdown) has no toPdf/fromPdf edge of its own. resolveCompositionPlan correctly reports these as unsupported. Fixes #847
Mearman
added a commit
that referenced
this pull request
Sep 3, 2026
…tion engine rtf-codec (added in #843) read and wrote against ContentDocument but was unreachable from convertDocument, document-cli, document-mcp, or the web UI: rtf was not a DocumentFormat member. Adds rtf as the thirteenth DocumentFormat member (DocumentFormatSchema), a CONTENT_READERS.rtf entry over readRtfContent, a DOCUMENT_FORMAT_CODECS.rtf read/write pair, and FORMAT_CAPABILITIES.rtf (variant wordprocessing, hasLayoutPath false -- rtf-codec has no layout engine of its own, so rtf reaches PDF through a same-variant bridge to docx/odt/markdown plus that format's own toPdf/fromPdf edge, exactly like xlsx/csv's own ods-composed routing). The composition engine's FORMAT_NODES.rtf entry decodes/encodes through a lossless byte<->latin1-string round trip (rtfBytesFromLatin1 and a local inverse) rather than a genuine text conversion: RTF is byte-oriented, not UTF-8 text (a \binN run can carry arbitrary raw picture bytes), so unlike markdown/csv/svg there is no well-formed-UTF-8 decode its own bytes always survive. This keeps rtf structurally identical to every other TextFormatNode member at the type level, avoiding a union-of-calls type error that a genuinely separate bytes-native FormatNode variant would introduce into executeBridge's and executeToPdf's generic dispatch over the whole ContentFormat space. RtfBytesSchema is re-exported directly from rtf-codec at the package barrel (src/index.ts) rather than reimplemented in src/model/bytes.ts -- that module is not a barrel, and rtf-codec already exports the exact '{\rtf' magic-byte check. rtf joins REBUILD_FORMATS in src/metadata/write.ts: its \info group round-trips title/author/subject/keywords like every other content format's own metadata container. Two conversions -- rtf<->csv and rtf<->xlsx -- are genuinely unroutable: reaching either needs four hops (bridge to docx/odt/markdown, toPdf, fromPdf to ods, bridge to csv/xlsx), one past the pathfinder's own 3-hop cap, since rtf (unlike markdown) has no toPdf/fromPdf edge of its own. resolveCompositionPlan correctly reports these as unsupported. Fixes #847
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
packages/rtf-codec, a hand-written RTF reader and writer against the shareddocument-schema.jspivot. Works towards #83; it does not close it — see "What is not here" below, and the scope comment on the issue itself.RTF is the odd one out structurally: it is tokenised plain text with a brace-nested group and destination model, not XML, so nothing in
ooxml.js/odf.jswas reusable. The package carries its own byte lexer, its own destination state machine, its own\uN/\ucNhandling with code-page fallback, and its own parsers for the five header mini-formats. Everything is implemented against Microsoft's own RTF Specification 1.9.1 (March 2008), and each module cites the section it implements by name; the primary source is the copy in Microsoft's Office protocol documentation archive, https://officeprotocoldoc.z19.web.core.windows.net/files/Archive_References/%5BMSFT-RTF%5D.pdf.Status: under active development, using the same wording
odf.js's README already uses for a package whose scope is real but bounded. The read and write paths below are implemented and tested; the package has not yet been run against a real-world corpus.What reads
Groups, destinations and
{\*ignorable destinations per the spec's own reader conventions. Control words (32-letter name cap, 10-digit signed parameter, one-space delimiter), control symbols,\'hh,\binN.\uN/\ucNwith the ANSI fallback skip — including "any control word or symbol counts as one character" and "a brace ends the skippable run early" — and{\upr}/{\*\ud}pairs. Code pages:\ansi/\mac/\pc/\pca,\ansicpgN, and a font's own\cpgN/\fcharsetN, across the Windows, OEM and Macintosh single-byte pages plus UTF-8.All five header tables:
\fonttbl(face name, family, per-font code page),\colortbl(RGB, auto colour at index 0),\stylesheet(paragraph style names and heading levels),\listtable/\listoverridetable(resolving\lsN→\listidN→ the level's\levelnfcN/\levelstartatN), plus\infoand the document page geometry.Body: paragraphs (
\par,\pard, alignment, indents, spacing,\slN/\slmultN,\pagebb), runs (\b,\i, every\ulvariant,\strike,\fN,\fsN,\cfN,\v), tables via the\trowd/\cellxN/\cell/\rowmarks with multi-paragraph cells, lists via\lsN/\ilvlN, PNG and JPEG pictures with goal-size and scaling,HYPERLINKfields including the\lanchor switch,\page, and the special-character vocabulary.What writes
Everything above has a write path. Header tables are minted from what the document actually uses — a font entry per distinct family, a colour per distinct colour, a
heading Nstyle per heading level, a\listtable/\listoverridetablepair per list — and the body then references them by index. Output is deterministic (byte-identical for one input) and pure 7-bit ASCII: every non-ASCII character leaves as\uNwith a one-character?fallback under a single\uc1, which is what the spec itself recommends and what makes the output diffable.Both encodings are exposed the way this family already does it:
readRtf/writeRtfoverDocumentTree,readRtfContent/writeRtfContentoverContentDocument, plusrtfCodec/rtfContentCodecz.codec()pairs and a{\rtfmagic-byteRtfBytesSchema.Diagnostics
Every construct the reader decides not to place reports a named code rather than disappearing — that matters more in RTF than in the XML formats, because the spec requires a reader to ignore what it does not recognise, so a silent drop is indistinguishable from never having seen the construct.
src/diagnostics-coverage.test.tsproduces each code from a real input and fails if any code in the table has no reachable emit site.The counterpart is that a skip which loses nothing stays silent: legacy
{\*\pn}numbering superseded by\lsN, the{\nonshppict}copy Word itself refuses to read, and the font-table subgroups the header parser already consumed would otherwise emit a warning per paragraph in a real document and bury the drops that matter.What is not here
ContentDocumenthas no position for them, and a footnote's real home is the tree-onlydefinitionstable a codec producing the flat form cannot reach.constructStart/constructEnd, run-level extents) — neither read nor written.document-schema.js'sSourceFormatSchemais a closed enum with nortfmember, so this codec has no legal residue value to emit. Adding one is an additive change to that package.\lfoleveloverride-level semantics — an override is followed to its\listidNand the list's own levels are used, so a per-level start-at or format override is ignored.rtfis not yet aDocumentFormatmember, so it is not reachable fromconvertDocument, the CLI, the MCP server, or the web UI. That change touches five tables indocuments.jsand three exhaustive conversion-matrix tests, and is deliberately separate — exactly asepub-codec's own engine wiring was (Wire epub into the conversion engine, CLI, MCP server, and web UI #802).Verification
CI is green on this PR: Commitlint, Lint, Typecheck, Test, Test (workerd), Smoke test, Knip and Audit all pass — https://github.com/ExaDev/documents.js/actions/runs/33716756164. Locally the same tasks pass, with 146 unit tests, the workerd suite, and the smoke suite against the built ESM and CJS artifacts.
(The
pull_requesttrigger did not fire on the first few pushes to this branch, which is why earlier revisions of this description pointed atworkflow_dispatchruns instead; it fired normally once the branch was rebased onto currentmain, and the run above is the real PR check.)The package is Worker-isomorphic, with
isomorphic: truein its lint config; the workers suite deliberately exercises the two places a Node-only shortcut would have been tempting —src/base64.ts's hand-written encoders (instead ofBuffer.from(...).toString("base64")) andsrc/codepage.ts's own tables (instead of the Node-onlyiconv-lite, which is banned by name alongside every third-party RTF library).The code-page tables were generated from Python's own codec library and verified byte-for-byte against it rather than transcribed by hand.
Checklist against CONTRIBUTING's "Adding a package to the workspace"
packages/rtf-codec/with the sibling scripts convention — done."document-schema.js": "^5.4.0"), notworkspace:*— done.pnpm-workspace.yaml'sminimumReleaseAgeExclude— done.eslint.config.tscallingpackageLintConfigwithisomorphic: trueplus atest:workerssuite — done._typecheck:attwin its scripts and itstypecheckrun list — done.rtf-codecpointing at this repository'sci.yml— not done, and needs a human: it is an npmjs.com setting, and per CONTRIBUTING it must be configured before this package's first release attempt, since a misconfigured package early in the topological order blocks every other package's release too.