Skip to content

feat(rtf): read an rtf as a text document - #733

Merged
andiwand merged 4 commits into
mainfrom
feat/rtf-text
Aug 24, 2026
Merged

feat(rtf): read an rtf as a text document#733
andiwand merged 4 commits into
mainfrom
feat/rtf-text

Conversation

@andiwand

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Stage 1 of src/odr/internal/rtf/PLAN.md. An rtf opens and renders instead of reaching open_strategy's fallthrough and throwing UnknownFileType.

What it decodes

Paragraph structure and text: \par, \line, \tab, \page, \sect, the literal-character control words, the escapes \\ \{ \} \~ \_ \-, \'hh in the run's encoding, and \uN — including surrogate pairs. The encoding resolves from \ansi / \mac / \pc / \pca / \ansicpgN through internal/encoding.

The table row moves from "recognised by magic, no decoder" to DocumentType::text with {.open, .translate_html, .color_scheme}. No binding work: the FileType ordinal was already spent, and everything else arrives through the abstract document model.

Character and paragraph formatting, page layout, tables and pictures are stages 2–5 and are not read yet. \cell renders as a tab and \row ends a paragraph, so table text stays readable until stage 4.

Shape

bytes ─▶ Tokenizer ─▶ TreeBuilder ─▶ ElementRegistry ─▶ Document ─▶ RtfFile

Following internal/pdf's parser shape rather than oldms, as the plan argued: rtf is a text format, so none of the CFB/FIB/piece-table machinery applies, but ObjectParser's stream handling does — \binN needs a raw n-byte read mid-stream and \'hh needs the same hex helpers. The registry is copied from oldms/text minus its style index.

Leniency is the spec here

The root AGENTS.md says throw where the spec dictates what to expect. Here it dictates the opposite: an unknown control word "should be ignored", and {\* exists precisely so a newer writer's output stays readable. So unknown control words and unimplemented {\* destinations are ignored and an unmatched } is dropped — while a group left open at EOF, an invalid hex digit after \', a \binN running past EOF and excessive nesting all throw.

Two traps worth calling out, both covered by tests:

  • \binN is read by the tokenizer, not the parser. Its payload is raw bytes that may contain braces, so a brace-counting scan would desync the group nesting — including while skipping an ignorable destination, which is why that skip still tokenizes.
  • \nonshppict needs an explicit entry in the discard table. It is the unmarked twin of {\*\shppict}, so the \* rule does not hide it, and every image would otherwise appear twice.

Verification

  • 33 new tests, all inline string literals — there is no .rtf anywhere under test/data, and an rtf fragment reads fine in a raw string. They cover the delimiter rules token by token, \bin payloads containing braces, the encoding chain, surrogate pairs, \ucN skipping, and the group leniency.
  • Full suite green: 1018 passed, 0 failed, the same 8 pre-existing skips.
  • clang-tidy: zero findings in the new sources.
  • Real files: a macOS textutil rtf (umlauts, \'80 euro, a \uc0 surrogate-pair emoji, curly quotes) and four LibreOffice conversions including a 2 MB one with images and a table document — correct text throughout, no duplicated images, no leaked font or style-table content.

No reference-output regen is owed: test/data holds no rtf, so HtmlOutputTests gains no case.

andiwand and others added 4 commits August 23, 2026 18:17
Stage 1 of `internal/rtf/PLAN.md`: an rtf opens and renders instead of
reaching `open_strategy`'s fallthrough and throwing `UnknownFileType`.

A pull-based `Tokenizer` over the rtf byte grammar, a `State` group stack,
and a parser building `root -> paragraph -> (text | line break)` so the
generic html renderer and every binding get it for free. Text, its encoding
(`\ansi`, `\mac`, `\ansicpgN`, `\'hh`, `\uN` with signed folding and
surrogate pairs, `\ucN` skipping), paragraphs, line breaks and tabs.

Leniency is the spec here: unknown control words and unimplemented `{\*`
destinations are ignored, an unmatched `}` is dropped. A group left open at
EOF, a bad hex digit, a `\binN` past EOF and excessive nesting throw.

Character and paragraph formatting, page layout, tables and pictures are
stages 2-5 and are not read yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N47M1zVGirwkpbtXXctCAb
…into feat/rtf-text

# Conflicts:
#	CHANGELOG.md
#	src/odr/internal/open_strategy.cpp
#	test/CMakeLists.txt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfnhZeFgZh84WMmeKK5cp2
Bound the `\binN` read by the bytes the stream delivers instead of the
length the file claims, so a 22-byte rtf can no longer demand two
gigabytes. Stop `\page`, `\sect` and the end of the file fabricating an
empty paragraph, and drop `\u0` rather than writing a NUL into the text.

Move the parser into `odr::internal::rtf` the way `iwork` and `odf` do,
the hex helpers off the tokenizer's header, and bring README, the public
`FileType` enum, `AGENTS.md` and `PLAN.md` in line with rtf being decoded
rather than merely detected. Ten new tests: the three `open_strategy`
branches and the file type table row, the per-byte degradation of an
undecodable encoding, the parameter digit cap and the int32 clamp,
`\binN` as one `\ucN` character, and a document with no body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfnhZeFgZh84WMmeKK5cp2
@andiwand
andiwand merged commit 4ab3eec into main Aug 24, 2026
23 of 27 checks passed
@andiwand
andiwand deleted the feat/rtf-text branch August 24, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant