rtf-codec
v4.4.5
Published
Hand-written Rich Text Format (RTF 1.9.1) reader and writer against the shared document-schema.js content pivot.
Maintainers
Readme
rtf-codec
A hand-written, dependency-minimal Rich Text Format codec: reads RTF into the shared document-schema.js content pivot, and writes deterministic, 7-bit-ASCII RTF back out. Built against Microsoft's own RTF Specification, version 1.9.1 and Zod 4, with no third-party RTF library.
Status: under active development. The read and write paths described below are implemented and tested, including against a real-producer corpus: pnpm test:corpus runs the gitignored test/corpus/ suite against LibreOffice-produced RTF (flat-ODT sources spanning runs, colour, headings, lists, tables, and alignment, converted through Writer's own RTF filter by scripts/generate-corpus.mjs) -- the corpus validated the reader cleanly across all eight fixtures with no defects found. Scope states exactly what is handled and what is not; nothing in this README describes work that is planned rather than done.
Every construct that remains unhandled is either a gap in document-schema.js rather than in this codec (character scaling, kerning, and run background colour have no field to land in), or something RTF itself does not specify at all beyond its own form-field vocabulary (a docx-style rich-text SDT has no RTF spelling of any kind) — see Deliberately not handled, which says which of the two each row is.
RTF is the cleanest structural fit of any format this family did not already handle. It is a wordprocessing format through and through — paragraphs, runs, character properties, paragraph properties, tables, lists and pictures all have direct ContentDocument equivalents — and it can express more of the wordprocessing variant than markdown can, carrying colour, font family, font size, alignment, vertical position and text direction natively. No document-schema.js model change was needed for it.
What it is not is another XML format. RTF is tokenised plain text with a brace-nested group and destination model, so none of the XML plumbing ooxml.js and odf.js share applies here: this package carries its own byte lexer, its own destination state machine, its own \uN/\ucN Unicode handling with code-page fallback, and its own parsers for the five header mini-formats. The closest relative in this workspace is markdown-codec, which is likewise a hand-written scanner and parser for a non-XML text format rather than a wrapper around a document library.
graph TD
archive("archive-codec")
schema("document-schema.js")
rtfcodec("rtf-codec")
archive --> rtfcodec
schema --> rtfcodec
click archive "https://github.com/ExaDev/documents.js/tree/main/packages/archive-codec" "archive-codec"
click schema "https://github.com/ExaDev/documents.js/tree/main/packages/document-schema.js" "document-schema.js"
click rtfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/rtf-codec" "rtf-codec"
style rtfcodec fill:#f9a825,stroke:#333,stroke-width:3pxrtf-codec depends on document-schema.js for the content pivot and archive-codec for the [MS-CFB] container an embedded object's \objdata carries — see Embedded objects and Dependency choices. It is reachable from documents.js's conversion engine, and so from document-cli, document-mcp, and the web UI, as an ordinary source and target format.
Getting started
pnpm add rtf-codecimport { readRtf, writeRtf, readRtfContent, writeRtfContent } from "rtf-codec";
// The tree-form pair, over document-schema.js's DocumentTree -- what to reach for by default.
const { documentPackage, diagnostics } = readRtf(await file.bytes());
const bytes = writeRtf(documentPackage);
// The flat pair, over its ContentDocument -- the shape the reader itself builds.
const { document } = readRtfContent(await file.bytes());
const flatBytes = writeRtfContent(document);Every entry point takes bytes, not a string. RTF is defined over bytes: \'hh names a raw byte decoded through whichever code page the document declared, and \binN is followed by literally N arbitrary bytes. A caller who has already decoded a .rtf file as UTF-8 has destroyed exactly the information the code-page layer needs. For the one string form that genuinely still holds bytes — a file read with a latin-1/binary reader — rtfBytesFromLatin1 converts it exactly, and throws above U+00FF rather than truncating.
Both encodings are also available as z.codec() pairs, matching the convention markdown-codec and pdf-codec already follow:
import { rtfCodec, rtfContentCodec, RtfBytesSchema } from "rtf-codec";
const documentPackage = rtfCodec.parse(bytes); // bytes -> DocumentTree
const roundTripped = rtfCodec.encode(documentPackage); // DocumentTree -> bytesRtfBytesSchema is a real magic-byte check — the <File> production requires an RTF document to begin {\rtf, so a caller handing the codec a docx or a PDF is refused at the schema boundary rather than deep inside the tokenizer.
The specification
Everything here is implemented against Microsoft's own Rich Text Format (RTF) Specification, Version 1.9.1 (March 2008, 278 pages) — the final revision, covering Word 2007. Each source module cites the section it implements by name.
- Primary source:
[MSFT-RTF].pdf, hosted in Microsoft's own Office protocol documentation archive. - Microsoft's original download page: https://www.microsoft.com/en-us/download/details.aspx?id=10725 (Wayback snapshot).
- The version history and the note that 1.9.1 is the final revision: Rich Text Format on Wikipedia (Wayback snapshot).
- Format-preservation context: Library of Congress, Sustainability of Digital Formats — RTF (Wayback snapshot).
The code-page tables in src/codepage.ts were generated, not transcribed: each is bytes([b]).decode(codec) over 0x80..0xFF from Python's own codec library, verified byte-for-byte against it, because a hand-typed 128-entry table is exactly where one transposed character hides until a real document decodes wrong.
The five East Asian double-byte code-page tables in src/codepage-dbcs.ts (932 Shift-JIS, 936 GBK/GB2312, 949 UHC/Hangul, 950 Big5, 1361 Johab) were generated the same way, at larger scale: scripts/generate-dbcs-tables.py decodes every lead-byte/trail-byte pair through Python's own cp932/cp936/cp949/cp950/cp1361 codecs (https://docs.python.org/3/library/codecs.html#standard-encodings) — the exact Windows code pages RTF's own \ansicpgN/\cpgN name by that number, not a nearby web-oriented substitute. That script's own header comment has the full citation, including which pages were cross-checked against a second, independent decoder (Node's ICU-backed TextDecoder, for the four of the five pages the WHATWG Encoding Standard also defines) and where the two genuinely diverge.
Architecture
Five stages, each its own module, each testable on its own:
| Stage | Module | What it does |
| ------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Lex | src/tokenize.ts | Bytes to a flat token stream: control words (32-letter name cap, 10-digit signed parameter, one-space delimiter), control symbols (no delimiter at all), the \'hh hex byte as its own token kind, \binN's raw byte run, and CR/LF handling. |
| Group | src/group.ts | Brace matching and destination identification — the two structural facts every stage above the lexer needs. |
| Header | src/header.ts | The five header mini-formats: \fonttbl, \colortbl, \stylesheet, \listtable and \listoverridetable, plus \info and the document properties, in one pass ahead of the body. |
| Read | src/read.ts | The destination/group state machine that turns the token stream into a ContentDocument. |
| Write | src/write.ts | The inverse: mints the header tables from what the document actually uses, then emits a body that references them by index. |
Supporting modules: src/codepage.ts (byte-to-character tables and the \ansicpgN/\fcharsetN/\cpgN precedence, plus the lead-byte state machine the five DBCS pages in src/codepage-dbcs.ts need), src/base64.ts (hex and base64 conversion for picture and object payloads), src/units.ts (twips, half-points, pixels), src/list-id.ts (the opaque numId grammar), src/constructs.ts (the fidelity-construct descriptor shapes and the DTTM bit field), src/cell-format.ts (the <celldef> border, shading, and merge production), src/embedded-object.ts (the \object/\objdata payload -- JSON in, real [MS-CFB] compound file out, via archive-codec; see Embedded objects), src/diagnostics.ts (the three-tier diagnostic policy).
The reader is the specification's own model, literally
"Conventions of an RTF Reader" states the model this reader implements exactly: an opening brace stores the current state on a stack, a closing brace retrieves it, a backslash collects a control word or symbol and dispatches on it, and anything else is text written "to the current destination using the current formatting properties". Four kinds of state ride that stack, as the spec enumerates them — destination, character properties, paragraph properties, table properties — plus the \ucN skip count, which the spec separately requires be stacked.
The destination is not merely a label: it decides what happens to text. Body text becomes runs; a \pict destination's text is hex picture payload; a \fldinst destination's text is a field instruction to be parsed rather than shown; a \listtext destination's text is the flat rendering of a list number that "should be ignored by any reader that understands Word 97 through Word 2007 numbering"; an unrecognised {\* destination's text is discarded whole. That mapping is what lets the reader be a single pass with no lookahead beyond a group's own head.
Tables are paragraph properties, not a group
"There is no RTF table group; instead, tables are specified as paragraph properties." A row is a run of \intbl paragraphs terminated by \cell marks and closed by \row, with the row's own \trowd ... \cellxN definition sitting before it, after it, or — for Word 2002 onward — both. The table builder is therefore driven by the \cell/\row marks in the text stream rather than by nesting, and a table closes when a non-table paragraph arrives.
Unicode
\uN carries the character and is followed by an ANSI approximation a Unicode-aware reader must skip: "the reader should ignore the next N' characters, where N' corresponds to the last \ucN' value encountered", where "any RTF control word or symbol is considered a single character" and a brace ends the skippable run early. All three of those rules are implemented, including partial consumption of a text run — which is why the main loop carries a byte offset alongside its token index. {\upr {ansi} {\*\ud unicode}} pairs take the \ud half and discard the ANSI one.
On the way out, every non-ASCII character leaves as \uN with a one-character ? fallback under a single \uc1. The writer deliberately does not hunt for a code page that could carry a character as a \'hh byte: the output is then pure 7-bit ASCII whatever the input contained, which is what makes it safe to transmit and trivially diffable, and costs a conforming reader nothing.
Scope
Read: RTF → ContentDocument
| Construct | Handled |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Groups, destinations, {\* ignorable destinations | Yes — per the spec's own reader conventions |
| Control words, control symbols, \'hh, \binN | Yes |
| \uN / \ucN with ANSI fallback skipping, \upr/\ud | Yes |
| Code pages | \ansi/\mac/\pc/\pca, \ansicpgN, per-font \cpgN/\fcharsetN; the Windows, OEM and Macintosh single-byte pages, the five East Asian DBCS pages (932 Shift-JIS, 936 GBK/GB2312, 949 UHC/Hangul, 950 Big5, 1361 Johab), plus UTF-8 |
| \fonttbl | Face name, family keyword, per-font code page |
| \colortbl | RGB, including a theme colour's own literal RGB; index 0 is the auto colour |
| \stylesheet | Paragraph style names and heading levels (\outlinelevelN or a built-in heading N name) |
| \listtable / \listoverridetable | \lsN → \listidN → the level's \levelnfcN and \levelstartatN, with each \lfolevel's own start-at or whole-level override applied |
| \*\revtbl | The revision authors \revauthN and its siblings index into |
| Sections | \sect, \sectd, the \pgwsxnN/\marg*sxnN geometry family, and the \sbk* break vocabulary |
| Paragraphs | \par, \pard, alignment, indents, spacing, \slN/\slmultN, \pagebb |
| Runs | \b, \i, \ul (every variant), \strike, \super/\sub and \upN/\dnN onto ContentRun.verticalAlign (\nosupersub as the off-spelling), \fN, \fsN, \cfN, \v (dropped as hidden) |
| Text direction | The four scopes RTF states it at: \rtlch/\ltrch onto ContentRun.direction, \rtlpar/\ltrpar onto ContentParagraph.direction, \rtlrow/\ltrrow onto ContentTableRow.direction, and \rtldoc/\ltrdoc onto LayoutMetadata.direction. \rtlsect/\ltrsect stay unmapped: they state a section's own column-snaking direction, a page-layout fact ContentSection carries no field for, not the text-direction scope the four fields name |
| Tables | \trowd, \cellxN, \trleftN, \cell, \row, multi-paragraph cells |
| Table cells | \clbrdrt/l/b/r with the whole <brdr> production, \clvertalc/\clvertalb (and \clvertalt, whose stated default collapses into the absence ContentTableCell.verticalAlign already means) as the cell's vertical alignment, \clcbpatN/\clcfpatN/\clshdngN shading (a real two-colour pattern fill, not just a flat colour -- see below), and both merge families (\clvmgf/\clvmrg, \clmgf/\clmrg) |
| Bookmarks | \*\bkmkstart/\*\bkmkend as anchor constructs, with \bkmkcolfN/\bkmkcollN quarantined as residue |
| Revision marks | The whole <chrev> production as provenance constructs: \revised, \deleted, \mvf/\mvt, \crauthN, with authors and \revdttmN dates |
| Form fields | A \field whose \*\fldinst names FORMTEXT/FORMCHECKBOX/FORMDROPDOWN, plus whatever \*\formfield data it carries (\*\ffname as its tag, \*\ffhelptext as its alias when \ffownhelp says it is author-set rather than auto-generated, \ffprot as its lock, a checkbox's own \ffres/\ffdefres as its checked state, a dropdown's own \ffres/\ffdefres as its selected entry alongside its \*\ffl option list), as a contentControl construct (plainText/checkbox/dropDown). A plainText field's own \*\ffdeftext group is recognised but its content is skipped whole rather than captured, since value names the control's CURRENT value, which for a text field is the wrapped-run text already carried in the extent's own children, not \ffdeftext's default/reset text (see Write below for the one direction \ffdeftext does feed value); the same applies to the four other \*\formfield destination strings RTF's own Form Fields table names alongside it (\*\ffformat, \*\ffstattext, \*\ffentrymcr, \*\ffexitmcr), each skipped whole for the identical reason -- no ContentControlDescriptor field exists to carry any of them |
| Lists | \lsN, \ilvlN, with the marker type carried through the numId grammar |
| Pictures | \pngblip and \jpegblip, hex or \binN payload, \picwgoalN/\pichgoalN or \picwN/\pichN, \picscalexN/\picscaleyN |
| Hyperlinks | The HYPERLINK field production, including its \l anchor switch |
| Special characters | \tab, \line, \emdash, \endash, \bullet, the quotation marks, \~, \-, \_, \\, \{, \}, and the zero-width and directional marks |
| Page breaks | \page |
| \info | Title, author, subject, keywords |
| Embedded objects | \object\objemb -> ContentEmbeddedObjectBlock when \objdata is this package's own payload (see Embedded objects); a real, foreign OLE object degrades with a diagnostic and its own \result fallback paragraphs, when present |
Write: ContentDocument → RTF
Everything in the read table above has a write path, with the header tables minted from what the document actually uses: a font table entry per distinct family, a colour table per distinct colour (runs' and cells' alike), a heading N style per distinct heading level, a \listtable/\listoverridetable pair per distinct list, and a \*\revtbl per distinct revision author. Output is deterministic (the same document produces byte-identical bytes) and pure 7-bit ASCII. An embeddedObject block writes unconditionally too now (see Embedded objects), and that holds inside a table cell as well as outside one: writeCellBlocks writes a cell's own content as a run of \intbl <pict>/<obj>/paragraph groups, plus any constructStart/constructEnd bracket a bookmark spans across them with (see Deliberately not handled), so only a table or pageBreak block placed directly in a cell's own content is degraded with a diagnostic rather than embedded.
Two places where the two models genuinely differ in shape, rather than merely in spelling:
- Page geometry is stated twice. The document-level
\paperwNfamily is written once in the header from the first section's own geometry, and the section-level\pgwsxnNfamily per section — so a reader that understands neither multiple sections nor the section family still lays the document out on the right paper. - A horizontally merged cell is one cell here and several there.
ContentTableCellstates acolSpanon one cell, while RTF states the same merge as several cells, the first carrying\clmgfand each continuation\clmrg. The writer expands one into the other, and the reader collapses it back. A vertical merge is the opposite: RTF and the content model both keep a cell in each covered row, so\clvmrgreads as a cell with no blocks — the conventionooxml.jsalready follows forw:vMerge.
A contentControl construct mints a real \*\formfield only for the three controlTypes RTF's own vocabulary actually spells (plainText/checkbox/dropDown); any other controlType (richText, comboBox, date, and the rest) degrades through the same construct-gap diagnostic every other unrepresentable construct uses. Two contentControl extents that cross within the same paragraph -- neither nests inside nor around the other, so one starts before the other ends but also ends after it does -- have no valid \*\formfield brace sequence at all, since RTF's own destination is a bracket, not a range; the later-opening extent is dropped with its own diagnostic rather than emit output where each extent's closing braces close the other's groups instead of its own. The fields inside a minted \*\formfield are emitted in a fixed order this writer itself chooses -- RTF 1.9.1's own "Form Fields" section gives a real Formal Syntax production for <formfield>, and further productions for <formparams>/<formstrings> that mandate a fixed order for their own members (via the spec's plain-juxtaposition operator, not its & "any order" operator) -- this writer's own order is a genuine subsequence of that spec-mandated order, not a free house convention; see write.ts's own top-of-file comment on formFieldPayload for the full citation and the exact productions. This writer's own convention puts every numeric flag/index control word (\fftype, \ffownhelp, \ffprot, \ffhaslistbox, \ffdefres/\ffres) before every destination string (\*\ffname, \*\ffdeftext, \*\ffhelptext, then the \*\ffl entries), and \*\ffname itself before \*\ffhelptext within that second group. A dropDown always mints the explicit \ffhaslistbox1, never a bare \ffhaslistbox, whether or not it carries any options: [MS-DOC] 2.9.79 FFDataBits.fHasListBox must be set for a list-type field regardless of how many entries the list holds, and RTF 1.9.1's own Form Fields table states \ffhaslistboxN as a genuine N-parameterised control word ("1 if this field has list box attached to it, 0 otherwise"), so a conformant reader applying RTF's own general Value-word default would read a bare occurrence as 0/false, the opposite of what a dropdown actually has -- this codec's own reader never has to apply that default here at all, since it has no \ffhaslistbox case anywhere and simply does not consult the control word on the way in. \ffres/\ffdefres are minted alongside it only when the field's own recorded selection genuinely names one of options, and omitted together in both remaining cases — no selection was ever recorded, or the recorded value names none of options — rather than guess an index that would silently point at the wrong entry. The unmatched-value case is reported through the same diagnostic sink every other unrepresentable construct in this writer uses, since it is real, signalable data loss rather than an absence. The never-selected case is different: a real producer (PHPRtfLite, per this package's own read.test.ts fixtures) spells "no current selection" as \ffres25 (FFDataBits' own undefined-selection sentinel) plus a genuine \ffdefres0, not by omitting both — but this writer cannot emit that exact form without reintroducing the ambiguity an earlier round of it removed, because this codec's own reader deliberately falls a sentinel \ffres25 through to \ffdefres (to recover a real PHPRtfLite checkbox's meaningful reset default rather than reading it as unchecked), and that same fallback would read a written \ffdefres0 back as "option 0 is selected" rather than "nothing is selected". Omitting both fields instead sidesteps that: this reader tolerates the omission cleanly and decodes it as an unset value with no ambiguity, at the cost of not matching the form a real producer would actually write for the identical case. [MS-DOC] 2.9.78 FFData.wDef "MUST exist if and only if" the field is a checkbox or dropdown is a real MS-DOC production rule that this omission does not satisfy: a producer omitting wDef is spec-noncompliant but demonstrably tolerated in practice, since this reader is built to survive real-world RTF, not just conformant RTF. Options beyond [MS-DOC] 2.9.78 FFData.hsttbDropList's own 25-entry limit are truncated, also with a diagnostic — not an arbitrary cutoff, since FFDataBits' own iRes field reserves index 25 as its "undefined selection" sentinel, so a 26th real entry would collide with it. When the recorded value genuinely matched one of the truncated-away entries, the resulting unmatched-value diagnostic names truncation as the reason rather than reusing the generic "does not match any of the field's own options" wording — the value did match something, until the cap removed it, and the two causes are distinguished so the message states the one that actually happened. A plainText control's own value is minted as {\*\ffdeftext ...} (FFData.xstzTextDef, "MUST exist if and only if" the field is a text field), distinct from the field's DISPLAYED text carried by its wrapped runs — a real, reachable case: documents.js's own PDF AcroForm-to-contentControl reconstruction hands a text field exactly this {controlType:'plainText', value, ...} shape for a real /V string. value names the control's CURRENT scalar value and \ffdeftext names its DEFAULT/reset text — a genuinely different fact, not merely a different spelling of the same one — so this mis-slot is reported through the diagnostic sink like every other cross-field case this writer names, even though (unlike those) the string itself is not dropped: it lands in the RTF byte stream, just under a field that reads back as something else (see the round-trip note at the end of this paragraph). \ffprot1 ([MS-DOC] 2.9.79 FFDataBits.fProt), always written with its explicit parameter rather than bare, is minted whenever a control's lock is content or both, since both lock the control's own value; lock: container protects only the control's own removal, a fact RTF's form-field vocabulary has no bit for at all, so a container lock is dropped in full (nothing is written for it) and a both lock's own removal half is dropped alongside the \ffprot1 its content half still writes — each reported through the diagnostic sink with a message naming which of the two actually happened, rather than one message describing both. \ffownhelp1{\*\ffhelptext ...} carries a control's alias, RTF's own closest analogue to docx w:alias/PDF AcroForm's /TU alternate description; the read side honours an explicit \ffownhelp0 too, since FFDataBits.fOwnHelp being 0 means xstzHelpText "contains an empty or auto-generated string" rather than an author-set label, and promoting that text to alias regardless would misrepresent it -- but a bare, unparameterised \ffownhelp reads as true rather than following that same 0-default, since LibreOffice's own RTF exporter emits exactly that bare form whenever the control model exposes a HelpText property at all, alongside genuine author-set help text, and the literal spec default would otherwise silently discard it (see read.ts's own comment on applyFormFieldControlWord's "ffownhelp" case for the full citation). A checkbox's own value -- distinct from checked -- has no RTF spelling at all: a real, reachable case (pdf-codec's own AcroForm reading spreads a checkbox widget's /V export-value name, e.g. 'Yes', onto value alongside the boolean checked derived from that same /V) is reported through the diagnostic sink rather than silently dropped, since unlike a dropDown's value it can never match anything RTF's \ffres/\ffdefres can name. value/checked/options recorded on a controlType that has no concept of them at all -- a plainText field's checked or options, a checkbox's options, a dropDown's checked -- are each reported through the identical sink rather than the writer silently reading past a field it has no branch for. The plainText value->\ffdeftext minting described above is write-only: this codec's own reader does not restore \ffdeftext back onto value on the way in, since value names the control's CURRENT value and \ffdeftext is the field's DEFAULT/reset text -- for a text field the current value is whatever the wrapped runs actually carry, so a document built from a value-carrying plainText descriptor does not read back with that value on a round trip, a mismatch reported through the diagnostic sink at write time (see above) rather than left for a caller to discover only by round-tripping the document themselves.
Deliberately not handled
Each of these is reported through a diagnostic rather than dropped silently — see Diagnostics.
| Construct | Why |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Headers, footers, footnotes, endnotes, annotations | ContentDocument's flat form has no page-furniture or note position for them. A footnote's real home is document-schema.js's tree-only definitions table, which a codec producing the flat form cannot reach. |
| Content controls beyond RTF's own form-field vocabulary (richText, comboBox, date, picture, repeatingSection, button, index, group) | RTF 1.9.1 specifies nothing for these. It predates OOXML's w:sdt: its "Custom XML Tags" (\xmlopen/\xmlclose) are a bare namespace/name tag with no type, lock, alias or value, and \*\datastore is an opaque blob whose "format ... is unknown to RTF" by the spec's own words. \*\formfield (see the Scope table above) is the one real analogue RTF has, and covers plainText/checkbox/dropDown only. |
| Code page 42 (SYMBOL_CHARSET) | Not an encoding: its bytes are glyph indices into whichever symbol font the run names, so there is no correct Unicode for them without that font's own cmap. |
| Metafile and bitmap pictures (\wmetafileN, \emfblip, \dibitmapN, \wbitmapN, \macpict) | ContentImageBlock carries PNG and JPEG only. |
| A picture with no stated size | ContentImageBlock requires a positive width and height, and deriving them from the payload would need an image decoder this package deliberately does not carry. |
| Nested tables (\nestcell/\nestrow) | Read as ordinary cell content; the inner table's own structure is not reconstructed. |
| Drawing objects (\do, \shp) | A schema gap, not a container one. These are Word's own native in-document vector-drawing layer, not an OLE embed -- there is no raw drawing-shape ContentBlock for a wordprocessing section's block flow to land in (only ContentEmbeddedObjectBlock, which names a whole embedded document, not a shape), so a \do/\shp construct is dropped regardless of the container work Embedded objects below did for \object. |
| A real, foreign \object's OLE data | This package's own \objdata payload round-trips fully (see Embedded objects); a real Word-authored OLESaveToStream structure (an actual embedded .xls/.doc/OLE-control payload) has no decoder here and degrades with a diagnostic, recovering \result's own fallback paragraphs when present instead of the real object. |
| Superscript/subscript (\super, \sub, \upN, \dnN), character scaling, kerning, background colour | Character scaling (\charscalexN), kerning (\kerningN), background colour (\cbN/\highlightN) A schema gap, not an RTF one. ContentRun carries no field for any of the three. (The vertical-position family that shared this row — \super/\sub/\upN/\dnN — now reads and writes through ContentRun.verticalAlign.)
