rtf-codec 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/dist/base64.cjs +66 -0
  4. package/dist/base64.d.cts +7 -0
  5. package/dist/base64.d.ts +7 -0
  6. package/dist/base64.js +62 -0
  7. package/dist/bytes.cjs +25 -0
  8. package/dist/bytes.d.cts +6 -0
  9. package/dist/bytes.d.ts +6 -0
  10. package/dist/bytes.js +22 -0
  11. package/dist/codec.cjs +29 -0
  12. package/dist/codec.d.cts +2308 -0
  13. package/dist/codec.d.ts +2308 -0
  14. package/dist/codec.js +26 -0
  15. package/dist/codepage.cjs +98 -0
  16. package/dist/codepage.d.cts +10 -0
  17. package/dist/codepage.d.ts +10 -0
  18. package/dist/codepage.js +92 -0
  19. package/dist/diagnostics-DdDFJLNO.d.cts +51 -0
  20. package/dist/diagnostics-DdDFJLNO.d.ts +51 -0
  21. package/dist/diagnostics.cjs +75 -0
  22. package/dist/diagnostics.d.cts +2 -0
  23. package/dist/diagnostics.d.ts +2 -0
  24. package/dist/diagnostics.js +67 -0
  25. package/dist/group.cjs +40 -0
  26. package/dist/group.d.cts +11 -0
  27. package/dist/group.d.ts +11 -0
  28. package/dist/group.js +38 -0
  29. package/dist/header.cjs +378 -0
  30. package/dist/header.d.cts +43 -0
  31. package/dist/header.d.ts +43 -0
  32. package/dist/header.js +376 -0
  33. package/dist/index.cjs +28 -0
  34. package/dist/index.d.cts +8 -0
  35. package/dist/index.d.ts +8 -0
  36. package/dist/index.js +8 -0
  37. package/dist/list-id.cjs +30 -0
  38. package/dist/list-id.d.cts +16 -0
  39. package/dist/list-id.d.ts +16 -0
  40. package/dist/list-id.js +27 -0
  41. package/dist/options.cjs +7 -0
  42. package/dist/options.d.cts +18 -0
  43. package/dist/options.d.ts +18 -0
  44. package/dist/options.js +5 -0
  45. package/dist/read.cjs +854 -0
  46. package/dist/read.d.cts +16 -0
  47. package/dist/read.d.ts +16 -0
  48. package/dist/read.js +852 -0
  49. package/dist/tokenize.cjs +155 -0
  50. package/dist/tokenize.d.cts +25 -0
  51. package/dist/tokenize.d.ts +25 -0
  52. package/dist/tokenize.js +154 -0
  53. package/dist/units.cjs +43 -0
  54. package/dist/units.d.cts +17 -0
  55. package/dist/units.d.ts +17 -0
  56. package/dist/units.js +29 -0
  57. package/dist/write.cjs +364 -0
  58. package/dist/write.d.cts +7 -0
  59. package/dist/write.d.ts +7 -0
  60. package/dist/write.js +362 -0
  61. package/package.json +95 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Mearman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # rtf-codec
2
+
3
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/rtf-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/rtf-codec) [![npm version](https://img.shields.io/npm/v/rtf-codec)](https://www.npmjs.com/package/rtf-codec) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)
4
+
5
+ > A hand-written, dependency-minimal Rich Text Format codec: reads RTF into the shared [document-schema.js](../document-schema.js/README.md) content pivot, and writes deterministic, 7-bit-ASCII RTF back out. Built against Microsoft's own [RTF Specification, version 1.9.1](#the-specification) and [Zod 4](https://zod.dev), with no third-party RTF library.
6
+
7
+ **Status: under active development.** The read and write paths described below are implemented and tested, but this package is new and has not yet been exercised against a real-world corpus. [Scope](#scope) states exactly what is handled and what is not; nothing in this README describes work that is planned rather than done.
8
+
9
+ 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 and alignment natively. No `document-schema.js` model change was needed for it.
10
+
11
+ 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.
12
+
13
+ ```mermaid
14
+ graph TD
15
+ schema("document-schema.js")
16
+ rtfcodec("rtf-codec")
17
+
18
+ schema --> rtfcodec
19
+
20
+ click schema "https://github.com/ExaDev/documents.js/tree/main/packages/document-schema.js" "document-schema.js"
21
+ click rtfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/rtf-codec" "rtf-codec"
22
+
23
+ style rtfcodec fill:#f9a825,stroke:#333,stroke-width:3px
24
+ ```
25
+
26
+ `rtf-codec` depends on nothing else in this family beyond `document-schema.js` — see [Dependency choices](#dependency-choices). Wiring it into `documents.js`'s conversion engine, `document-cli`, `document-mcp`, or the web UI is explicitly out of scope here, exactly as it was for [epub-codec](../epub-codec/README.md); see [Not yet wired into the conversion engine](#not-yet-wired-into-the-conversion-engine).
27
+
28
+ ## Getting started
29
+
30
+ ```sh
31
+ pnpm add rtf-codec
32
+ ```
33
+
34
+ ```ts
35
+ import { readRtf, writeRtf, readRtfContent, writeRtfContent } from "rtf-codec";
36
+
37
+ // The tree-form pair, over document-schema.js's DocumentTree -- what to reach for by default.
38
+ const { documentPackage, diagnostics } = readRtf(await file.bytes());
39
+ const bytes = writeRtf(documentPackage);
40
+
41
+ // The flat pair, over its ContentDocument -- the shape the reader itself builds.
42
+ const { document } = readRtfContent(await file.bytes());
43
+ const flatBytes = writeRtfContent(document);
44
+ ```
45
+
46
+ 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.
47
+
48
+ Both encodings are also available as [`z.codec()`](https://zod.dev) pairs, matching the convention `markdown-codec` and `pdf-codec` already follow:
49
+
50
+ ```ts
51
+ import { rtfCodec, rtfContentCodec, RtfBytesSchema } from "rtf-codec";
52
+
53
+ const documentPackage = rtfCodec.parse(bytes); // bytes -> DocumentTree
54
+ const roundTripped = rtfCodec.encode(documentPackage); // DocumentTree -> bytes
55
+ ```
56
+
57
+ `RtfBytesSchema` 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.
58
+
59
+ ## The specification
60
+
61
+ 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.
62
+
63
+ - Primary source: [`[MSFT-RTF].pdf`](https://officeprotocoldoc.z19.web.core.windows.net/files/Archive_References/%5BMSFT-RTF%5D.pdf), hosted in Microsoft's own Office protocol documentation archive.
64
+ - Microsoft's original download page: <https://www.microsoft.com/en-us/download/details.aspx?id=10725> ([Wayback snapshot](https://web.archive.org/web/2024/https://www.microsoft.com/en-us/download/details.aspx?id=10725)).
65
+ - The version history and the note that 1.9.1 is the final revision: [Rich Text Format on Wikipedia](https://en.wikipedia.org/wiki/Rich_Text_Format) ([Wayback snapshot](https://web.archive.org/web/2025/https://en.wikipedia.org/wiki/Rich_Text_Format)).
66
+ - Format-preservation context: [Library of Congress, Sustainability of Digital Formats — RTF](https://www.loc.gov/preservation/digital/formats/fdd/fdd000473.shtml) ([Wayback snapshot](https://web.archive.org/web/2024/https://www.loc.gov/preservation/digital/formats/fdd/fdd000473.shtml)).
67
+
68
+ 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.
69
+
70
+ ## Architecture
71
+
72
+ Five stages, each its own module, each testable on its own:
73
+
74
+ | Stage | Module | What it does |
75
+ | ------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | 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. |
77
+ | Group | `src/group.ts` | Brace matching and destination identification — the two structural facts every stage above the lexer needs. |
78
+ | 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. |
79
+ | Read | `src/read.ts` | The destination/group state machine that turns the token stream into a `ContentDocument`. |
80
+ | 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. |
81
+
82
+ Supporting modules: `src/codepage.ts` (byte-to-character tables and the `\ansicpgN`/`\fcharsetN`/`\cpgN` precedence), `src/base64.ts` (hex and base64 conversion for picture payloads), `src/units.ts` (twips, half-points, pixels), `src/list-id.ts` (the opaque `numId` grammar), `src/diagnostics.ts` (the three-tier diagnostic policy).
83
+
84
+ ### The reader is the specification's own model, literally
85
+
86
+ "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.
87
+
88
+ 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.
89
+
90
+ ### Tables are paragraph properties, not a group
91
+
92
+ "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.
93
+
94
+ ### Unicode
95
+
96
+ `\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.
97
+
98
+ 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.
99
+
100
+ ## Scope
101
+
102
+ ### Read: RTF → `ContentDocument`
103
+
104
+ | Construct | Handled |
105
+ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
106
+ | Groups, destinations, `{\*` ignorable destinations | Yes — per the spec's own reader conventions |
107
+ | Control words, control symbols, `\'hh`, `\binN` | Yes |
108
+ | `\uN` / `\ucN` with ANSI fallback skipping, `\upr`/`\ud` | Yes |
109
+ | Code pages | `\ansi`/`\mac`/`\pc`/`\pca`, `\ansicpgN`, per-font `\cpgN`/`\fcharsetN`; the Windows, OEM and Macintosh single-byte pages, plus UTF-8 |
110
+ | `\fonttbl` | Face name, family keyword, per-font code page |
111
+ | `\colortbl` | RGB, including a theme colour's own literal RGB; index 0 is the auto colour |
112
+ | `\stylesheet` | Paragraph style names and heading levels (`\outlinelevelN` or a built-in `heading N` name) |
113
+ | `\listtable` / `\listoverridetable` | `\lsN` → `\listidN` → the level's `\levelnfcN` and `\levelstartatN` |
114
+ | Paragraphs | `\par`, `\pard`, alignment, indents, spacing, `\slN`/`\slmultN`, `\pagebb` |
115
+ | Runs | `\b`, `\i`, `\ul` (every variant), `\strike`, `\fN`, `\fsN`, `\cfN`, `\v` (dropped as hidden) |
116
+ | Tables | `\trowd`, `\cellxN`, `\trleftN`, `\cell`, `\row`, multi-paragraph cells |
117
+ | Lists | `\lsN`, `\ilvlN`, with the marker type carried through the `numId` grammar |
118
+ | Pictures | `\pngblip` and `\jpegblip`, hex or `\binN` payload, `\picwgoalN`/`\pichgoalN` or `\picwN`/`\pichN`, `\picscalexN`/`\picscaleyN` |
119
+ | Hyperlinks | The `HYPERLINK` field production, including its `\l` anchor switch |
120
+ | Special characters | `\tab`, `\line`, `\emdash`, `\endash`, `\bullet`, the quotation marks, `\~`, `\-`, `\_`, `\\`, `\{`, `\}`, and the zero-width and directional marks |
121
+ | Page breaks | `\page` |
122
+ | `\info` | Title, author, subject, keywords |
123
+
124
+ ### Write: `ContentDocument` → RTF
125
+
126
+ 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, a `heading N` style per distinct heading level, and a `\listtable`/`\listoverridetable` pair per distinct list. Output is deterministic (the same document produces byte-identical bytes) and pure 7-bit ASCII.
127
+
128
+ ### Deliberately not handled
129
+
130
+ Each of these is reported through a diagnostic rather than dropped silently — see [Diagnostics](#diagnostics).
131
+
132
+ | Construct | Why |
133
+ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134
+ | 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. |
135
+ | Fidelity constructs (`constructStart`/`constructEnd`, run-level extents) | Neither read nor written. Bookmarks, tracked changes and content controls all have RTF spellings; mapping them onto the harmonised construct vocabulary is its own piece of work. |
136
+ | The residue channel (`source: { format, xml }`) | `document-schema.js`'s `SourceFormatSchema` is a closed enum with no `rtf` member, so this codec has no legal residue value to emit. Adding one is an additive change to that package, tracked separately. |
137
+ | East Asian DBCS code pages (932, 936, 949, 950, 1361) | Each needs a ~20k-entry table and its own lead-byte state machine. A document declaring one decodes through cp1252 and says so. |
138
+ | 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. |
139
+ | Metafile and bitmap pictures (`\wmetafileN`, `\emfblip`, `\dibitmapN`, `\wbitmapN`, `\macpict`) | `ContentImageBlock` carries PNG and JPEG only. |
140
+ | 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. |
141
+ | Nested tables (`\nestcell`/`\nestrow`) | Read as ordinary cell content; the inner table's own structure is not reconstructed. |
142
+ | Embedded objects (`\object`), drawing objects (`\do`, `\shp`) | Dropped. Writing one would need the OLE container this package does not build. |
143
+ | Multiple sections | `\sect` ends the paragraph but does not start a new `ContentSection`; a document reads as one section with the document-level page geometry. |
144
+ | Superscript/subscript (`\super`, `\sub`), character scaling, kerning, background colour | No `ContentRun` field expresses them. |
145
+ | Table cell borders, shading, vertical merge | No RTF is emitted for `ContentTableCell.borders`/`background`, and `\clvmgf`/`\clvmrg` are not read into `rowSpan`. |
146
+ | Right-to-left text (`\rtlch`, `\rtlpar`) | No `ContentDocument` field carries direction. |
147
+
148
+ ## Diagnostics
149
+
150
+ The same three-tier policy `markdown-codec` and `pdf-codec` use: **throw** for input that cannot be processed at all, **recover with a diagnostic** for input that is malformed in a way the spec's own robustness advice says to survive, and **degrade with a diagnostic** for a construct read correctly at the token level whose meaning the `ContentDocument` mapping does not carry.
151
+
152
+ That third tier does more work here than in the XML formats. The spec _requires_ an unknown control word to be ignored and an unknown `{\*` destination to be skipped whole, so "I did not understand this" is the format's normal operating mode rather than an error condition — but a reader that silently drops a construct a caller cared about is indistinguishable from one that never saw it. Every drop this package makes deliberately therefore names itself through a code in `RtfDiagnosticCodes`.
153
+
154
+ ```ts
155
+ import { readRtf, RtfDiagnosticCodes } from "rtf-codec";
156
+
157
+ const { documentPackage, diagnostics } = readRtf(bytes);
158
+ const droppedPictures = diagnostics.filter(
159
+ (diagnostic) =>
160
+ diagnostic.code === RtfDiagnosticCodes.UNSUPPORTED_PICTURE_FORMAT,
161
+ );
162
+ ```
163
+
164
+ The throw tier is `RtfNotAnRtfDocumentError` (no `{\rtf` header), `RtfInputTooLargeError` and `RtfNestingLimitExceededError` (the two resource guards, both configurable through `ReadRtfOptions`), and `RtfUnsupportedDocumentKindError` on the write side, since RTF is a wordprocessing format and a presentation, spreadsheet, drawing or formula document has no RTF spelling.
165
+
166
+ ## Dependency choices
167
+
168
+ `document-schema.js` and `zod`, and nothing else. Every third-party RTF library is banned **by name** in this package's own `eslint.config.ts` — `rtf-parser`, `rtf.js`, `rtf-stream-parser`, `node-rtf`, `jsrtf`, `@shelf/rtf-to-html` — the same bet `markdown-codec` makes against micromark/remark/marked and `pdf-codec` makes against pdf-lib/pdfjs-dist. Depending on one would defeat the reason this package exists.
169
+
170
+ `iconv-lite` is banned for a second reason on top of that: it is Node-only (it is built on `Buffer`), so depending on it would break this package's Worker isomorphism. The code-page tables in `src/codepage.ts` exist instead.
171
+
172
+ It deliberately does **not** depend on `byte-codec`, even though that package has the base64 and byte-writing primitives `src/base64.ts` reimplements. RTF's picture payload is hex-encoded ASCII inside a text format, not a binary container, so what is actually needed here is about sixty lines of hex and base64 conversion — considerably less than the coupling a dependency on a sibling's release cadence would cost. `epub-codec` made the same call for the same reason.
173
+
174
+ ## Worker isomorphism
175
+
176
+ Like every foundation and format-codec package in this family, `rtf-codec` is Worker-isomorphic: its published `src/` imports no `node:*` module and uses no `Buffer`, so one artifact behaves identically in a Node host, a browser, and a Cloudflare Worker. The ban is enforced by `isomorphic: true` in this package's `eslint.config.ts`, and `pnpm test:workers` proves it at runtime by running the public surface inside workerd.
177
+
178
+ Two places would have been tempting to write with a Node-only shortcut, and the workers suite exercises both: `src/base64.ts`'s hand-written encoders (`Buffer.from(bytes).toString("base64")` is the one-liner they exist instead of) and `src/codepage.ts`'s own tables.
179
+
180
+ ## Not yet wired into the conversion engine
181
+
182
+ This package is a standalone codec. It is not yet registered in `documents.js`'s `DOCUMENT_FORMAT_CODECS` registry or its format pathfinder, so `rtf` is not yet a source or target for `convertDocument`, the CLI, the MCP server, or the web UI. That wiring touches the format enum, the capability table, the pathfinder's own node table, and several exhaustive conversion-matrix tests, and is deliberately a separate change — exactly as `epub-codec`'s own engine wiring was. Tracked in [ExaDev/documents.js#847](https://github.com/ExaDev/documents.js/issues/847).
183
+
184
+ ## Build, test, and lint
185
+
186
+ ```sh
187
+ pnpm install
188
+ pnpm build # tsdown -> ESM + CJS + .d.ts in dist/
189
+ pnpm typecheck # tsc for the web program and the node program, plus attw --pack
190
+ pnpm lint # eslint . --fix --cache --max-warnings 0
191
+ pnpm test # vitest run --project unit
192
+ pnpm test:workers # the same code inside workerd, the real Cloudflare Workers runtime
193
+ pnpm test:smoke # rebuilds dist/ and exercises the built ESM and CJS artifacts
194
+ ```
195
+
196
+ To run a single test file: `pnpm vitest run src/read.test.ts`.
197
+
198
+ ## Release and publishing
199
+
200
+ Release, CI, and commit-message conventions are workspace-wide, not package-local — see the [monorepo root README](../../README.md#releases) for the mechanism.
201
+
202
+ ## Contributing
203
+
204
+ Conventional Commits, enforced workspace-wide by commitlint through a root `commit-msg` hook. Work inside `packages/rtf-codec/`; see [CONTRIBUTING.md](../../CONTRIBUTING.md) for the shared git hooks and history conventions.
205
+
206
+ ## License
207
+
208
+ MIT
@@ -0,0 +1,66 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/base64.ts
3
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4
+ const HEX_DIGITS = "0123456789abcdef";
5
+ function bytesToBase64(input) {
6
+ let out = "";
7
+ for (let index = 0; index < input.length; index += 3) {
8
+ const first = input[index] ?? 0;
9
+ const second = input[index + 1];
10
+ const third = input[index + 2];
11
+ out += BASE64_ALPHABET.charAt(first >> 2);
12
+ out += BASE64_ALPHABET.charAt((first & 3) << 4 | (second ?? 0) >> 4);
13
+ out += second === void 0 ? "=" : BASE64_ALPHABET.charAt((second & 15) << 2 | (third ?? 0) >> 6);
14
+ out += third === void 0 ? "=" : BASE64_ALPHABET.charAt(third & 63);
15
+ }
16
+ return out;
17
+ }
18
+ function base64Value(character) {
19
+ const index = BASE64_ALPHABET.indexOf(character);
20
+ return index === -1 ? void 0 : index;
21
+ }
22
+ function base64ToBytes(input) {
23
+ const out = [];
24
+ let accumulator = 0;
25
+ let bits = 0;
26
+ for (const character of input) {
27
+ if (character === "=" || /\s/.test(character)) continue;
28
+ const value = base64Value(character);
29
+ if (value === void 0) return;
30
+ accumulator = accumulator << 6 | value;
31
+ bits += 6;
32
+ if (bits >= 8) {
33
+ bits -= 8;
34
+ out.push(accumulator >> bits & 255);
35
+ }
36
+ }
37
+ return Uint8Array.from(out);
38
+ }
39
+ function bytesToHex(input) {
40
+ let out = "";
41
+ for (const byte of input) {
42
+ out += HEX_DIGITS.charAt(byte >> 4);
43
+ out += HEX_DIGITS.charAt(byte & 15);
44
+ }
45
+ return out;
46
+ }
47
+ function hexToBytes(input) {
48
+ const out = [];
49
+ let high;
50
+ for (const character of input) {
51
+ const value = HEX_DIGITS.indexOf(character.toLowerCase());
52
+ if (value === -1) continue;
53
+ if (high === void 0) {
54
+ high = value;
55
+ continue;
56
+ }
57
+ out.push(high * 16 + value);
58
+ high = void 0;
59
+ }
60
+ return Uint8Array.from(out);
61
+ }
62
+ //#endregion
63
+ exports.base64ToBytes = base64ToBytes;
64
+ exports.bytesToBase64 = bytesToBase64;
65
+ exports.bytesToHex = bytesToHex;
66
+ exports.hexToBytes = hexToBytes;
@@ -0,0 +1,7 @@
1
+ //#region src/base64.d.ts
2
+ declare function bytesToBase64(input: Uint8Array): string;
3
+ declare function base64ToBytes(input: string): Uint8Array | undefined;
4
+ declare function bytesToHex(input: Uint8Array): string;
5
+ declare function hexToBytes(input: string): Uint8Array;
6
+ //#endregion
7
+ export { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes };
@@ -0,0 +1,7 @@
1
+ //#region src/base64.d.ts
2
+ declare function bytesToBase64(input: Uint8Array): string;
3
+ declare function base64ToBytes(input: string): Uint8Array | undefined;
4
+ declare function bytesToHex(input: Uint8Array): string;
5
+ declare function hexToBytes(input: string): Uint8Array;
6
+ //#endregion
7
+ export { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes };
package/dist/base64.js ADDED
@@ -0,0 +1,62 @@
1
+ //#region src/base64.ts
2
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ const HEX_DIGITS = "0123456789abcdef";
4
+ function bytesToBase64(input) {
5
+ let out = "";
6
+ for (let index = 0; index < input.length; index += 3) {
7
+ const first = input[index] ?? 0;
8
+ const second = input[index + 1];
9
+ const third = input[index + 2];
10
+ out += BASE64_ALPHABET.charAt(first >> 2);
11
+ out += BASE64_ALPHABET.charAt((first & 3) << 4 | (second ?? 0) >> 4);
12
+ out += second === void 0 ? "=" : BASE64_ALPHABET.charAt((second & 15) << 2 | (third ?? 0) >> 6);
13
+ out += third === void 0 ? "=" : BASE64_ALPHABET.charAt(third & 63);
14
+ }
15
+ return out;
16
+ }
17
+ function base64Value(character) {
18
+ const index = BASE64_ALPHABET.indexOf(character);
19
+ return index === -1 ? void 0 : index;
20
+ }
21
+ function base64ToBytes(input) {
22
+ const out = [];
23
+ let accumulator = 0;
24
+ let bits = 0;
25
+ for (const character of input) {
26
+ if (character === "=" || /\s/.test(character)) continue;
27
+ const value = base64Value(character);
28
+ if (value === void 0) return;
29
+ accumulator = accumulator << 6 | value;
30
+ bits += 6;
31
+ if (bits >= 8) {
32
+ bits -= 8;
33
+ out.push(accumulator >> bits & 255);
34
+ }
35
+ }
36
+ return Uint8Array.from(out);
37
+ }
38
+ function bytesToHex(input) {
39
+ let out = "";
40
+ for (const byte of input) {
41
+ out += HEX_DIGITS.charAt(byte >> 4);
42
+ out += HEX_DIGITS.charAt(byte & 15);
43
+ }
44
+ return out;
45
+ }
46
+ function hexToBytes(input) {
47
+ const out = [];
48
+ let high;
49
+ for (const character of input) {
50
+ const value = HEX_DIGITS.indexOf(character.toLowerCase());
51
+ if (value === -1) continue;
52
+ if (high === void 0) {
53
+ high = value;
54
+ continue;
55
+ }
56
+ out.push(high * 16 + value);
57
+ high = void 0;
58
+ }
59
+ return Uint8Array.from(out);
60
+ }
61
+ //#endregion
62
+ export { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes };
package/dist/bytes.cjs ADDED
@@ -0,0 +1,25 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_diagnostics = require("./diagnostics.cjs");
3
+ //#region src/bytes.ts
4
+ const ASCII_CHUNK_SIZE = 8192;
5
+ function asciiStringFromBytes(input) {
6
+ let out = "";
7
+ for (let start = 0; start < input.length; start += ASCII_CHUNK_SIZE) out += String.fromCharCode(...input.subarray(start, start + ASCII_CHUNK_SIZE));
8
+ return out;
9
+ }
10
+ function appendBytes(target, input) {
11
+ for (const byte of input) target.push(byte);
12
+ }
13
+ function rtfBytesFromLatin1(source) {
14
+ const out = new Uint8Array(source.length);
15
+ for (let index = 0; index < source.length; index += 1) {
16
+ const code = source.charCodeAt(index);
17
+ if (code > 255) throw new require_diagnostics.RtfParseError("rtf/not-byte-preserving-string", `character at index ${String(index)} is U+${code.toString(16).toUpperCase().padStart(4, "0")}, above U+00FF: this string was decoded through a multi-byte encoding and no longer holds the file's bytes. Read the .rtf file as bytes and pass the Uint8Array directly.`);
18
+ out[index] = code;
19
+ }
20
+ return out;
21
+ }
22
+ //#endregion
23
+ exports.appendBytes = appendBytes;
24
+ exports.asciiStringFromBytes = asciiStringFromBytes;
25
+ exports.rtfBytesFromLatin1 = rtfBytesFromLatin1;
@@ -0,0 +1,6 @@
1
+ //#region src/bytes.d.ts
2
+ declare function asciiStringFromBytes(input: Uint8Array): string;
3
+ declare function appendBytes(target: number[], input: Uint8Array): void;
4
+ declare function rtfBytesFromLatin1(source: string): Uint8Array;
5
+ //#endregion
6
+ export { appendBytes, asciiStringFromBytes, rtfBytesFromLatin1 };
@@ -0,0 +1,6 @@
1
+ //#region src/bytes.d.ts
2
+ declare function asciiStringFromBytes(input: Uint8Array): string;
3
+ declare function appendBytes(target: number[], input: Uint8Array): void;
4
+ declare function rtfBytesFromLatin1(source: string): Uint8Array;
5
+ //#endregion
6
+ export { appendBytes, asciiStringFromBytes, rtfBytesFromLatin1 };
package/dist/bytes.js ADDED
@@ -0,0 +1,22 @@
1
+ import { RtfParseError } from "./diagnostics.js";
2
+ //#region src/bytes.ts
3
+ const ASCII_CHUNK_SIZE = 8192;
4
+ function asciiStringFromBytes(input) {
5
+ let out = "";
6
+ for (let start = 0; start < input.length; start += ASCII_CHUNK_SIZE) out += String.fromCharCode(...input.subarray(start, start + ASCII_CHUNK_SIZE));
7
+ return out;
8
+ }
9
+ function appendBytes(target, input) {
10
+ for (const byte of input) target.push(byte);
11
+ }
12
+ function rtfBytesFromLatin1(source) {
13
+ const out = new Uint8Array(source.length);
14
+ for (let index = 0; index < source.length; index += 1) {
15
+ const code = source.charCodeAt(index);
16
+ if (code > 255) throw new RtfParseError("rtf/not-byte-preserving-string", `character at index ${String(index)} is U+${code.toString(16).toUpperCase().padStart(4, "0")}, above U+00FF: this string was decoded through a multi-byte encoding and no longer holds the file's bytes. Read the .rtf file as bytes and pass the Uint8Array directly.`);
17
+ out[index] = code;
18
+ }
19
+ return out;
20
+ }
21
+ //#endregion
22
+ export { appendBytes, asciiStringFromBytes, rtfBytesFromLatin1 };
package/dist/codec.cjs ADDED
@@ -0,0 +1,29 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_read = require("./read.cjs");
3
+ const require_write = require("./write.cjs");
4
+ let zod = require("zod");
5
+ let document_schema_js = require("document-schema.js");
6
+ //#region src/codec.ts
7
+ const RTF_MAGIC = [
8
+ 123,
9
+ 92,
10
+ 114,
11
+ 116,
12
+ 102
13
+ ];
14
+ function hasRtfMagic(bytes) {
15
+ return RTF_MAGIC.every((byte, index) => bytes[index] === byte);
16
+ }
17
+ const RtfBytesSchema = zod.z.instanceof(Uint8Array).refine(hasRtfMagic, { message: "does not begin with '{\\rtf'" });
18
+ const rtfCodec = zod.z.codec(RtfBytesSchema, document_schema_js.DocumentTreeSchema, {
19
+ decode: (bytes) => require_read.readRtf(bytes).documentPackage,
20
+ encode: (documentPackage) => require_write.writeRtf(documentPackage)
21
+ });
22
+ const rtfContentCodec = zod.z.codec(RtfBytesSchema, document_schema_js.ContentDocumentSchema, {
23
+ decode: (bytes) => require_read.readRtfContent(bytes).document,
24
+ encode: (document) => require_write.writeRtfContent(document)
25
+ });
26
+ //#endregion
27
+ exports.RtfBytesSchema = RtfBytesSchema;
28
+ exports.rtfCodec = rtfCodec;
29
+ exports.rtfContentCodec = rtfContentCodec;