feat(rtf): read an rtf as a text document - #733
Merged
Merged
Conversation
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
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.
🤖 Generated with Claude Code
Stage 1 of
src/odr/internal/rtf/PLAN.md. An rtf opens and renders instead of reachingopen_strategy's fallthrough and throwingUnknownFileType.What it decodes
Paragraph structure and text:
\par,\line,\tab,\page,\sect, the literal-character control words, the escapes\\\{\}\~\_\-,\'hhin the run's encoding, and\uN— including surrogate pairs. The encoding resolves from\ansi/\mac/\pc/\pca/\ansicpgNthroughinternal/encoding.The table row moves from "recognised by magic, no decoder" to
DocumentType::textwith{.open, .translate_html, .color_scheme}. No binding work: theFileTypeordinal 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.
\cellrenders as a tab and\rowends a paragraph, so table text stays readable until stage 4.Shape
Following
internal/pdf's parser shape rather thanoldms, as the plan argued: rtf is a text format, so none of the CFB/FIB/piece-table machinery applies, butObjectParser's stream handling does —\binNneeds a raw n-byte read mid-stream and\'hhneeds the same hex helpers. The registry is copied fromoldms/textminus its style index.Leniency is the spec here
The root
AGENTS.mdsays 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\binNrunning past EOF and excessive nesting all throw.Two traps worth calling out, both covered by tests:
\binNis 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.\nonshppictneeds 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
.rtfanywhere undertest/data, and an rtf fragment reads fine in a raw string. They cover the delimiter rules token by token,\binpayloads containing braces, the encoding chain, surrogate pairs,\ucNskipping, and the group leniency.textutilrtf (umlauts,\'80euro, a\uc0surrogate-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/dataholds no rtf, soHtmlOutputTestsgains no case.