Files
craft-libs/crates/rtf/README.md
T
Dylan M. TaylorandSimon Koch-Sultan 9ab7768771 Add craft-rtf: RTF tokenizer, code pages, and a reader and writer for a small model (#890)
* Add craft-rtf: RTF tokenizer, text decoding, and a reader and writer for a small model

The tokenizing rules, code pages, multibyte decoding, dates and escaping come
from WordCraft's RTF reader and writer. The model (paragraphs with outline
level and indent, character formatting, tables) covers what DesignCraft's text
import, DeckCraft's outline import and export and PdfCraft's export use.

An unpaired UTF-16 surrogate in \u reads as U+FFFD and a high surrogate pairs
only with the very next \u, as storytold/wordcraft#6105 describes for Word.

* craft-rtf: a symbol font's bytes are its own codes

A font with \fcharset2 (Symbol, Wingdings) has no letters: a byte from 0x80
reads as U+F000 + the byte instead of a letter of the document's code page.
The symbol page applies to body text only, a symbol default font's page does
not apply to text in a font chosen with \f, \plain makes the default font's
page current again, and a code page an entry names with \cpg is kept.

Ported from storytold/wordcraft#6014.

* craft-rtf: alignment, indents, spacing, superscript and subscript; NUL is not text

The model and both directions gain what DesignCraft's RTF export writes:
\ql \qc \qr \qj, \ri, \fi, \sb, \sa, \super, \sub, \nosupersub.
A \u0 or \'00 no longer puts a NUL into the text.

---------

Co-authored-by: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com>
2026-11-11 17:26:57 -05:00

4.6 KiB

craft-rtf

Rich Text Format, read and written. It replaces the RTF code each Craft app carried: the tokenizer, code pages, \u handling, dates and escaping come from WordCraft's reader and writer; the small model covers what DesignCraft's text import, DeckCraft's outline import and export and PdfCraft's export read and write.

use craft_rtf::{Block, CharFmt, Document, Para, Run, read, write};

let bold = CharFmt { bold: true, ..CharFmt::default() };
let doc = Document {
    blocks: vec![Block::Para(Para {
        runs: vec![Run { text: "Plan {Q3} ".into(), fmt: bold }, Run { text: "café 😀".into(), fmt: CharFmt::default() }],
        outline_level: Some(0),
        ..Para::default()
    })],
    pictures_left_out: 0,
};
let rtf = write(&doc, "Liberation Sans");
assert!(rtf.contains(r"\outlinelevel0 {\b Plan \{Q3\} }caf\u233?"));
assert_eq!(read(rtf.as_bytes()).unwrap(), doc);

Layers

Layer What it is For
Tokens The tokens of a file: {, }, control words with their parameter, \*, \'hh, control symbols, text runs, \bin data. Any reader.
TextDecoder, codepage 8-bit text in a code page, \uN and its fallback characters. Any reader.
esc, esc_font, date_words, iso_to_dttm Escaping and dates. Any writer.
read, write, Document A file to and from a small model. An app that needs text with basic formatting.

A reader that keeps more than the model holds (styles, lists, fields, notes, tracked changes, pictures) uses the first two layers and its own interpretation of the control words.

Text

  • Code pages. 8-bit text is in the current font's code page (\fcharset, \cpg), else the default font's (\deff), else the document's (\ansicpg, \mac, \pc, \pca), else Windows-1252. Single-byte: Windows 874 and 1250 to 1258, DOS 437, 737, 850, 852, 857, 866, Mac OS Roman. Multibyte: 932 (Shift-JIS), 936 (GBK), 949 (UHC), 950 (Big5), streamed through one decoder so a lead byte and its trail byte decode together whatever falls between them. Raw bytes above 0x7F are text in the code page, as \'hh is. They are not read as UTF-8.
  • \uN. One UTF-16 code unit, taken modulo 65,536. A high surrogate pairs only with a low surrogate in the very next \u; a surrogate that does not pair is U+FFFD, as in Word. The \ucN characters after a \u are its fallback and are skipped: a text byte, a \'hh, a control word and a control symbol each count as one, a following \u does not, and the skipping ends with the group.
  • Malformed input. \' without two hex digits is dropped with the hex digits it has; a } or \ after it keeps its meaning. A control word is at most 32 letters and a parameter at most 10 digits. A parameter outside i32 is clamped.

The model

Document { blocks, pictures_left_out }, Block::{Para, Table}, Para { runs, outline_level, left_indent, right_indent, first_line_indent, space_before, space_after, align }, Run { text, fmt }, CharFmt { bold, italic, underline, strike, vert_align, half_points, color, font }, Table { rows: Vec<Vec<Cell>> }, Cell { right, paras }.

In Run::text, a tab is \t, and LINE_BREAK (\n), PAGE_BREAK (U+000C) and COLUMN_BREAK (U+000E) are \line, \page and \column. Lengths are in twips and sizes in half-points, as the file has them.

read leaves out headers, footers, footnotes, comments, field instructions (a field's result is kept), list labels (\listtext, \pntext), the stylesheet, pictures and objects (counted), and every destination marked \*. Paragraph properties end with their group, as character properties do. A trailing \par does not leave an empty last paragraph; empty paragraphs elsewhere are kept. \sect ends a paragraph.

Limits

Limit Value Past it
MAX_GROUPS 512 nested groups deeper groups are read in the state of the group around them
MAX_FONTS 4096 font table entries later fonts are not kept
MAX_COLORS 4096 colour table entries later colours are not kept
MAX_FONT_NAME 256 bytes the font is not kept

The reader does not recurse, and what it returns is never larger than a small multiple of the file: each character of text costs the file at least one byte.

Not here yet

WordCraft's full reader and writer (styles, lists, fields, bookmarks, pictures, notes, comments, tracked changes, sections, headers and footers) are written against WordCraft's own document model and stay there until that model has a neutral form. They can already sit on Tokens, TextDecoder, esc and the date functions.