wpd-codec
v3.5.6
Published
Hand-written read-only WordPerfect 6.x-X6 (.wpd) reader over document-schema.js's ContentDocument
Downloads
8,321
Maintainers
Readme
wpd-codec
Hand-written, read-only WordPerfect 6.x-X6 (
.wpd) reading intodocument-schema.js'sContentDocument, from Corel's own published File Format SDK — part of the documents.js family. Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate.
Status: under active development. The read path below is tested against the specification's own worked examples, against hand-built fixtures derived from its field tables, and — since the corpus check described in Evidence — against 93 real WordPerfect documents, 90 of which read and 3 of which are refused correctly. See Remaining scope for what is deliberately not handled yet.
Created for documents.js#819. The premise that made the issue worth acting on is that WordPerfect is not a reverse-engineered format: Corel shipped a File Format SDK as a supported developer product, and one specification covers the entire modern lineage — its own document-structure page states outright that "Files created in WordPerfect 6.x, through X6 are structured the same", so 1993 through 2012 is one format, not a family of them. There is also no JavaScript or TypeScript reader for it at all: libwpd is LGPL C++, WP_Reader is C#, and the SDK's own surviving mirror ships an Ada implementation.
Sources
Everything this package does is derived from the vendor's own documentation, and every non-obvious decision in the source cites the page it comes from — with one deliberate, cited exception: the character-set tables below, which the mirrored SDK pages state the mechanism for but do not themselves tabulate (see the row below and src/stream/character-sets.ts's own top-of-file comment).
| Source | What it gives |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WordPerfect File Format SDK help | The specification itself, mirrored in full: document structure, the prefix packet catalogue, single-byte characters and functions, every variable-length function group, the fixed-length functions, and table formulas. |
| WPFF Document Structure | The file header, the index and packet data areas, the function-code stream's shape, the units glossary, and a complete annotated hex dump of a conforming generic prefix. |
| WPFF Single-Byte Characters and Functions | The character model and the eighty single-byte function codes. |
| WPFF D0 EOL Functions | The End-of-Line group and, crucially, its "Conversion/Search mappings" column — the specification stating what a converting application should turn each break code into. |
| WPFF Fixed-Length Multi-Byte Functions | Attribute On/Off, the Extended Character function, and the size of every fixed-length code. |
| WPFF D3 Paragraph and D4 Character | Justification, font face and size changes, colour, and the rest of the paragraph- and character-oriented functions. |
| WPFF D1 Page and D2 Column | Page geometry: the Form function's own page size, and the four margin functions split across the two groups. |
| WPFF DD Style and DA Display Number | The system style number enumeration this package's heading and outline-level recovery rests on, and the paragraph-number display pair. |
| WPFF E0 Tab | The tab definition bitfield, which this group carries in place of a subfunction number. |
| WPFF prefix packet catalogue | The packet types, including the font typeface descriptor layout this package reads a run's font family out of, and the Extended Document Summary the document's own metadata comes from. |
| Corel's File Format SDK product page | The provenance: a supported Corel developer product documenting "the entire document format, document prefix and document codes". |
| libwpd's libwpd_internal.cpp and WP6TibetanMap.h | The character-set-to-Unicode tables for character sets 1 through 14, which the mirrored SDK pages state the mechanism for but do not tabulate. libwpd is the mature LGPL/MPL WordPerfect reader used by AbiWord and LibreOffice; only the factual (set, number) → code point correspondence is transcribed, cross-checked against this package's own independent transcription of the SDK's thirty-two shorthands and against the Unicode Character Database — see src/stream/character-sets.ts. |
Getting started
Requires Node.js >=20 and pnpm 11.6.0.
pnpm install
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts, one file set per src module)
pnpm typecheck # tsc -p tsconfig.json && tsc -p tsconfig.node.json, plus attw --pack
pnpm lint # eslint . --fix --cache --max-warnings 0
pnpm test # vitest run --project unit
pnpm test:watch # vitest --project unit
pnpm test:workers # vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
pnpm test:smoke # builds dist/, then loads the built ESM and CJS barrels and every advertised deep importTo run a single test file, pass its path to vitest directly, e.g. pnpm exec vitest run src/stream/tokenise.test.ts.
Usage
import { readWpdContent } from "wpd-codec";
// Both containers are accepted, decided by inspecting the bytes: a bare
// WordPerfect 6.x file, and a WP7-and-later OLE compound file whose
// PerfectOffice_MAIN stream holds the identical byte stream.
const document = readWpdContent(bytes);
document.sections[0].pageSize; // the document's own form, not an assumed default
document.sections[0].blocks; // paragraphs, tables, page breaks
document.metadata; // from the document's own Extended Document SummaryreadWpd is the same read one level up, returning the tree-form DocumentTree every other codec in the family also offers. wpdContentCodec states the read half as document-schema.js's own ContentCodec port, so a consumer dispatching over formats treats WordPerfect uniformly with the rest.
Anything that would silently lose information is reported through an optional diagnostic sink rather than swallowed:
readWpdContent(bytes, {
sink: (diagnostic) => {
diagnostic.code; // 'wpd/unmapped-character', 'wpd/table-flattened', ...
diagnostic.message;
},
});Structural nonconformance is not a diagnostic — it throws. WpdNotAWordPerfectFileError, WpdEncryptedDocumentError, WpdWrongPasswordError, WpdUnsupportedVersionError, and the general WpdFormatError are all exported, and all extend the last.
What it provides
Every module is importable by package-relative path as well as through the barrel — tsdown builds one dist file per src module (root: 'src', the layout archive-codec and ooxml.js also ship), and package.json's ./* exports wildcard maps each subpath onto it. The smoke suite is the guard on that advertisement: it loads each module below from the built dist/ in both module systems.
| Module | Exports |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| read | readWpdContent (bytes to ContentDocument), readWpd (bytes to DocumentTree), ReadWpdOptions |
| codec | wpdContentCodec (ContentCodec), WpdBytesSchema |
| format | WPD_MEDIA_TYPE, WPD_FILE_EXTENSION |
| diagnostics | WpdDiagnostic, WpdDiagnosticSink, WpdDiagnosticCodes, NOOP_WPD_DIAGNOSTIC_SINK |
| errors | WpdFormatError and its three subclasses |
| container/container | openWpdDocument (container, header, packets, and document-area bounds in one), PERFECT_OFFICE_MAIN_STREAM, PERFECT_OFFICE_OBJECTS_STORAGE |
| container/header | readFileHeader, hasWordPerfectFileId, WPD_FILE_ID, WPD_PREFIX_HEADER_SIZE |
| container/prefix | readPrefixPackets, packetByPrefixId, readTypefaceName, readGeneralWpTextBlocks, WPD_INDEX_RECORD_SIZE, PACKET_TYPE_DESIRED_FONT_DESCRIPTOR, PACKET_TYPE_GENERAL_WP_TEXT |
| container/summary | readDocumentSummary (the Extended Document Summary packet as a LayoutMetadata), PACKET_TYPE_EXTENDED_DOCUMENT_SUMMARY |
| stream/tokenise | tokeniseDocumentArea and the four token types |
| stream/characters | decodeWpCharacter, decodeSingleByteCharacter, decodeWordString, UNMAPPED_CHARACTER |
| stream/eol | eolMappingForSubfunction, subfunctionForSingleByteEol, isSingleByteEol |
| stream/attributes | decodeAttributeByte, runAttributesFrom, WpdAttribute |
| stream/units | pointsFromWpu, WPU_PER_INCH, POINTS_PER_INCH |
| stream/page | readPageForm, readMarginPt, the Page and Column group constants, and the WordPerfect default page |
| stream/style | styleSemanticsFor, readSystemStyleNumber, readStyleBeginBlock, the style scope predicates, PACKET_TYPE_NORMAL_STYLE, and the Display Number group's paragraph-number pair |
| stream/table | readEmbeddedSubfunctions, readTableColumnWidthPt, readRowInformation, readCellInformation, readCellSpanning, readCellFill, CELL_FORMULA_SUBFUNCTION |
| stream/formula | readTableFormula (a New Cell Formula embedded subfunction's own tokenised formula to its own linear text) |
| stream/box | readBoxContent (a box function's own override-flag walk to its content type, content prefix ID, and frame), the box content type constants |
| stream/tab | tabEffectFor, TAB_GROUP, WpdTabEffect |
| stream/ole | readOleObject, readOleDescriptor, readGraphicsChildIds, WpdOleObject, and the Graphics Filename child packet constants (cached file data 0x6F, OLE Object Descriptor 0x70, OLE Object Data 0x71) |
| stream/wpg | decodeWpgGraphic (a WPG 2.x record stream to the shared drawing vocabulary, with the WP-text fold injected by the caller), WpgDecode, WpgTextFold |
The container and stream layers are public deliberately, not by accident: a consumer inspecting a WordPerfect file — a migration audit, a forensic tool, a reader for a construct this package does not yet lift into the shared schema — needs the parsed prefix and the raw function stream, not only the document they fold into.
Architecture
A WordPerfect 6.x-X6 file is a prefix followed by a document area, optionally wrapped in an OLE compound file. Reading it is three layers, each in its own directory.
The container (src/container/) resolves the wrapper and the prefix. A 16-byte header gives the offset of the document area, the product/type/version bytes, an encryption word, and the offset of the index area; a 496-byte extended header follows it, of which only the file size is documented. The index area is a run of 14-byte records — the first is the index header, and each of the rest points at one packet in the packet data area. A packet holds data referenced many times but not part of the document's content: a font descriptor, a style definition, a comment's text. Functions in the document area name a packet by its prefix ID, which is its 1-based position among the index entries, not its packet type.
The tokeniser (src/stream/tokenise.ts) walks the document area. Bytes at or below 0x7F are characters; above it, four ranges of function codes — single-byte (0x80-0xCF), variable-length multi-byte (0xD0-0xEF, self-describing through a size field), fixed-length multi-byte (0xF0-0xFE, sized by a table), and 0xFF, which cannot appear at all. Every multi-byte function is bracketed by matching begin and end gates, and the variable form repeats its size before the end gate; this package verifies all three redundancies, because they are the format's own integrity check — a stream that has gone out of step fails at the very next function rather than decoding rubbish for the rest of the file.
The fold (src/read.ts) turns tokens into a ContentDocument. Characters accumulate into the current run, an attribute or font change closes that run and opens another, and an end-of-line function closes the paragraph. Nothing recurses and nothing looks ahead, which is what makes a hand-written reader for this format tractable at all. It has exactly one nesting concept, and it is not recursion: while a table definition is open a closed paragraph joins the cell being built rather than the section's own block list, which is as deep as this format's own grid model goes.
A table is stated in two halves, in two different groups
Neither half is nested inside the other, and knowing that is most of what reading one takes. The definition opens with Table Definition (0xD42A, "Table On"), is followed by one Table Column function (0xD42C) per column, and closes with Define Table End (0xD42B) — column widths and gutters, no content. The content follows as ordinary document text delimited by End-of-Line codes: subfunction 10 ends a cell, 11 through 16 end a cell and its row, 17 through 19 end the table. That is also why a reader with no table support recovers a table's text in reading order anyway: every boundary is a line break too.
The per-cell facts are not functions of their own. Spanning, justification, background fill and fixed row height ride inside the End-of-Line function that ends the cell, as "embedded subfunctions" in its non-deletable data — a layout unique to this group ("This format is unique in that the non-deletable data area also contains deletable data"), so that region opens with a size word for the deletable half and the documented subfunctions sit after it. Each embedded subfunction is gated by its own code the way every multi-byte function is, and the SDK prints a size against each; src/stream/table.ts holds that column as a table, because a record whose size the specification does not state cannot be stepped over — the walk stops there and says so rather than guessing a length and decoding the rest as rubbish.
A heading is a heading because the file says which style it is
WordPerfect states a style's identity twice: as a prefix ID naming the style's own packet, and — for a style the product defines rather than the user — as a system style number in the function's own data. The SDK enumerates that number, and its entries include "68 = heading level 1 style" through "75 = heading level 8 style", "52 = level 1 style (indented)" through "67 = level 8 style (not indented)", "31 = list" and "48 = bullets". Those are the whole basis for this package's heading and list recovery: nothing here infers a heading from a short line or a large font.
One detail of the fold exists for this. A style region ends at its own closing code, which in a real document sits before the hard return that ends the paragraph — so a paragraph's heading level is captured when its first character arrives, not when it closes, which would find the scope already popped.
Two containers, one document
WordPerfect 6.x writes the byte stream straight to disk. From WP7 onwards it may be wrapped in an OLE compound file, with the document in a PerfectOffice_MAIN stream — but the SDK is explicit that the wrapper is optional even then ("When creating WordPerfect 7/8 documents you do not need to include the OLE Compound Document wrapper"), so the container is decided by inspecting the bytes, never by the file's extension or its version bytes. Both paths produce the identical document, which the test suite asserts directly.
The compound-file half is archive-codec's bounded [MS-CFB] reader rather than anything written here: sectors, FAT chains, and directory entries are container structure with no document-format knowledge in them, which is exactly that package's charter.
Byte 0x20 is not a space
The one part of the character model that looks like a bug on first reading, so it is worth stating plainly. The SDK maps byte values 1 through 32 to thirty-two "Default Extended International Characters" — a shorthand for common accented letters — and byte values 33 through 127 to ASCII. Byte 0x20 is therefore the sharp s, not a space. A space is the single-byte Soft Space function 0x80, which the specification describes as "Equivalent of an ASCII 0x20", or the Hard Space function 0x81. Both statements appear twice in the SDK, and the design reason is plain from the function list: WordPerfect must distinguish a justifiable soft space from a hard one, so neither can be a plain text byte. src/stream/characters.ts owns the whole mapping in one place.
Deliberately not depending on libwpd
The only mature reader for this format is libwpd, which is LGPL C++ — so binding it would forfeit both this family's MIT licensing and its Worker portability in one step, and it could not run in a browser or a Workers isolate at all. Writing the parser by hand against the vendor's own specification is the same bet markdown-codec makes against micromark and pdf-codec makes against pdf-lib, and here it is not really a bet: the format is documented at byte level by the company that wrote it. An ESLint rule bans importing any libwpd binding by name rather than leaving the decision to memory.
This is a bet against libwpd's code, not against citing its data: the character-set tables in src/stream/character-sets.ts transcribe libwpd's own (character set, character number) → Unicode correspondence, because the mirrored Corel SDK pages state the mechanism but tabulate no character set beyond the thirty-two Default Extended International Characters. A factual mapping table is not the parsing logic the ESLint rule and this section guard against — nothing here imports libwpd, links against it, or depends on its code at runtime.
Scope
Read-only, WordPerfect 6.0 through X6. Two deliberate exclusions, both decided before any code was written:
- No writer. WordPerfect File Format is complete enough to write against, but a lossless round-trip through a function-code stream — keeping prefix packet indices, use counts, and the document's own well-formedness invariants consistent — is a much larger job than reading one, and a half-correct writer is worse than no writer given the lossless bar the rest of this family holds to.
- No WordPerfect 4.2, 5.x, or Macintosh generations. Those share the file ID but not the structure; they are separate formats with their own vendor documentation, not earlier drafts of this one. A 5.x file reaches
WpdUnsupportedVersionErroron its major version byte rather than being misparsed as a 6.x file.
What is handled
- Both containers: a bare WordPerfect file, and an OLE compound file's
PerfectOffice_MAINstream. - The file header, with encryption, product type, file type, and major version all checked rather than assumed.
- The index area and packet data area, with prefix IDs resolvable to packets.
- The full document-area token stream: characters, all four function-code ranges, prefix ID references, non-deletable data, and gate/size verification.
- The character model: ASCII, the thirty-two international shorthands, and the Extended Character function for all fourteen of WordPerfect's own named character sets — Multinational, Phonetic Symbols, Box Drawing, Typographic Symbols, Iconic Symbols, Math/Scientific, Math/Scientific Extended, Greek, Hebrew, Cyrillic, Japanese, Tibetan, Arabic, and Arabic Script — transcribed from libwpd's own WP6-to-Unicode tables, since the mirrored SDK pages state the (set, number) mechanism but tabulate no character-set table of their own beyond the thirty-two shorthands. See
src/stream/character-sets.ts's own top-of-file comment for the source and how it was cross-checked. - Paragraph structure from the End-of-Line group, in both its single-byte and multi-byte spellings, using the specification's own conversion table — hard returns become paragraphs, soft returns become spaces, hard end-of-page becomes a
pageBreakblock. - Character attributes: bold, italics, underline (plain and double), and strikeout, including the specification's "ignore" bit for a nested duplicate.
- Font family, from the Desired Font Descriptor packet a Font Face Change names; font size, from a Font Size Change; character colour.
- Paragraph justification.
- The Start/End of Text to Skip pair, whose contents the formatter does not display and this reader drops.
- Page geometry: the page size from the Form function (0xD111) and all four margins from their own two groups — the vertical pair in the Page group (0xD100/0xD101), the horizontal pair in the Column group (0xD200/0xD201). Each dimension falls back to the WordPerfect default independently, so a document overriding only its top margin keeps US Letter and the other three inches rather than the whole default set.
- Tables, as real
ContentTablegrids: column widths from the Table Column functions, cells and rows from the End-of-Line boundaries, and — from the embedded subfunctions riding inside those boundaries — merged cells ascolSpan/rowSpanwith the positions they cover dropped, cell background colour, per-cell justification applied to the paragraphs it holds, and fixed row heights. - Heading levels and outline list levels, from the Style group's system style numbers.
- Outline numbering, from the Display Number group's Paragraph Number Display pair: the level becomes a list membership and the pair's rendered digits are dropped in favour of it, since a counter's display is generated content rather than typed text.
- Document metadata, from the Extended Document Summary prefix packet (type 0x12): the Descriptive Name as the title, plus author, subject, keywords, and the creation and revision dates.
- Tabs and line-scoped alignment, from the Tab group (0xE0), whose byte in the subfunction position is the tab definition itself rather than a subfunction number. A type that advances to a tab stop becomes a tab character; centre-on-margins, centre-on-current-position and flush-right instead begin the line-scoped alignment the single-byte End of Center Align functions already terminate.
- Style packets (the 0xDD group's own type-0x30 definitions), beyond their system style numbers: a style scope opened with a prefix ID resolves that packet and applies its own "beginning style text" block — font face/size/colour changes, attribute on/off — exactly as if the author had typed those codes at the point the scope opened, restoring the pre-scope formatting when the scope closes. See
src/stream/style.ts's ownreadStyleBeginBlock. - Boxes whose content resolves to a General WP Text packet (type 0x08): a text or linked-text box lifts into an embedded
wordprocessingdocument (ContentEmbeddedObjectBlock), read through the identical tokeniser and fold the main document area uses; an equation box's own notation — WordPerfect's own equation-editor syntax, neither MathML nor LaTeX — is carried verbatim asContentFormula's quarantined residue (source: { format: 'wpd', xml }) rather than mistranslated into either. A box's real content is always named by its own function-level override (the box override-flag walk insrc/stream/box.ts), never by its template, so a box with no content override genuinely has nothing to lift. Frame position and size are read from that same override when it states an absolute page-space offset and an explicit width/height; a box relying on its template's own inherited geometry is not lifted (see Remaining scope). - Merge fields (the Merge group's FIELD On/Off pair, 0xDE 0x4C/0x4D): tagged as a
fieldconstruct (document-schema.js'sRunConstructExtent/FieldDescriptor) on the paragraph the field sits in,instructionbeing the field's own displayed placeholder text — which stays in the run flow exactly as WordPerfect itself displays a template's own field codes, now tagged as a placeholder rather than indistinguishable prose. - Native OLE objects (an image box's Graphics Filename packet children,
0x70OLE Object Descriptor /0x71OLE Object Data). The descriptor's own marker states the generation: a WP7 "OLE 2.0" object's wordstring names a stream inside the compound wrapper'sPerfectOffice_OBJECTSstorage, which the container collects and the read resolves; a WP6 "OLE 1.0" object carries all of its data inline in the descriptor packet's own trailing bytes, which works in a bare file with no wrapper at all. The bytes are carried, not interpreted — a native OLE payload is an OLE server's own stream rather than a nested document package — withreadWpdcarrying one as an attachments-table entry (the identical flat/tree split note bodies take, and the entry vocabulary documents.js stamps PDF embedded files with) while flatreadWpdContentreports it throughwpd/ole-object-dropped. The undocumented0x71data packet is skipped and named rather than guessed at. - Watermarks (the 0xD6 group's A/B watermark subfunctions): page furniture with a parity, not a band at a page edge. They share the header/footer function layout and narrow onto the shared furniture vocabulary the same way — odd-only or both parities the default slot, even-only the even slot (
ContentSection.watermarks) — with the body folded through the identical nested fold a header's takes, first claimant per slot, and a slot collision reported throughwpd/header-footer-droppedexactly as a colliding header's is. - WPG vector graphics (a Graphics Filename packet's
0x6Fcached-file-data children): a WPG 2.x record stream decodes into the shared drawing vocabulary and lifts as a one-pagedrawingdocument (ContentEmbeddedObjectBlock) placed by the box's own frame. What decodes is the layered subsetsrc/stream/wpg.ts's own scope statement names: the record framing itself (Class/Type/Extension/Length with the 1/3/5-byte count fields), Start WPG (the units, precision, and extent every coordinate conversion needs), the flat pen and brush colour and pen-size attributes with their double-precision variants, Polyline (two unclosed points as the model's own line variant, more points or a closed one as a path), Rectangle (square corners as a rect, rounded corners as a path of kappa-approximated quarter-ellipses), an Arc with identical endpoint offsets (the full ellipse the specification itself defines that spelling to mean), and a Text Block with its Text Data folded through the same tokeniser and fold body text takes. Everything else a graphic carries is skipped whole and named throughwpd/wpg-records-undecoded(see Remaining scope). - Table formulas (
WPFF_TableFormulas): a New Cell Formula embedded subfunction's own tokenised formula decodes to its own linear text onContentTableCell.formula, carried verbatim in a spreadsheet-style cell-reference spelling (A1,$A$1:B3) — the same "structure yes, formula content no" boundaryContentSheetCell.formulaalready draws elsewhere in the family. Seesrc/stream/formula.ts's own top-of-file comment for why this is a linear substitution rather than an RPN evaluation, and for the honest-or-nothing contract that aborts the whole formula rather than guessing at an undocumented code.
Remaining scope
Everything below is recognised by the tokeniser and skipped by the fold, so a document containing it still reads — losing that construct's own structure, never the surrounding text. Each is reported through the diagnostic sink rather than passed over in silence.
- A box whose content is a presentation, video, macro, sound, or external payload, and a box relying on its own template's inherited geometry rather than an explicit function-level position/size override. Reported through
wpd/box-content-unresolvedandwpd/box-frame-unresolvedrespectively. An image box IS lifted when its content packet carries a whole PNG or JPEG payload — the packet's raw bytes are scanned by signature and structural walk (src/stream/image.ts— the chunk chain to IEND for PNG, the marker segments to EOI for JPEG), never by guessing at a container header, and the span lifts as aContentImageBlocksized by the box's own frame, with an absolute-from-page-edge position carried as the image'sfloatPosition— when it carries a WPG vector graphic the WPG decoder reads, or when it names a native OLE object whose bytes are recovered as tree-form attachments (both under What is handled). A box with no function-level content override at all — relying entirely on its template's own rendering defaults — is reported throughwpd/box-dropped, unchanged from before. - A second header, footer, or watermark claiming a slot a first already filled. WordPerfect's own A/B two-slot-per-kind mechanism is a shape the shared one-flow-per-slot vocabulary does not carry; the first function to claim a slot is the one lifted, and the collision is reported through
wpd/header-footer-dropped.
Headers and footers themselves are LIFTED (ExaDev/documents.js#1128): a D6 function's occurrence bits narrow onto the shared furniture vocabulary's slots (ContentSection.headers/footers, default/even — odd-only and both-parities are the default slot, even-only the even slot), its body folded from the General WP Text packet its first prefix ID names. Footnotes and endnotes are anchored in the flat form (a footnote/endnote anchor construct around the reference site, definition naming note-1, note-2, ... in document order) and their bodies are carried by readWpd as definitions-table entries in the tree form — the flat readWpdContent reports each still-borne body through wpd/note-dropped, since the flat ContentDocument genuinely has no home for one (rtf-codec documents the same split for its own equivalent constructs).
- The long tail of the WPG record vocabulary. The WPG decoder is deliberately a layered subset, not "WPG support" (see What is handled for what does decode). Everything else a WPG 2.x record stream can carry — Polyspline, Polycurve, Compound Polygon, Bitmap and Bitmap Data, Text Line, Text Path, Chart and its style/data companions, Object Image, Object Capsule, the pen style/pattern and brush pattern/gradient/texture families (a non-flat pattern has no flat colour this reader could honestly approximate it with), and the page-settings records — is recognised by the walk, skipped whole, and named through
wpd/wpg-records-undecoded, so a partially-decoding graphic still lifts what did decode with the skipped types named. Any record carrying a transformation in its characterisation flags (taper, translate, skew, scale, rotate) is refused rather than decoded with the transformation dropped — its geometry would be wrong, not partial. WPG 1.0-major graphics are refused outright (a separate record vocabulary the vendor pages this package builds from do not document, and misparsing one as WPG 2.x would decode rubbish rather than refuse), as are encrypted graphics. - Interpreting a native OLE object's payload. The object's bytes are recovered as tree-form attachments (see What is handled), but interpreting them would mean understanding every OLE server's on-disk format — the same boundary
ooxml.jsdraws for a classic OLE1.binpayload with noPackagestream — so they are carried opaque rather than decoded. - The counter groups (0xD8, 0xD9, 0xDB, 0xDC): setting, numbering-method, increment and decrement carry no text and change no structure this reader models, so only the Display Number group's own paragraph-number pair is read.
- Every merge subfunction other than FIELD (ASSIGN, CALL, IF, FOR, CASE, and the rest of WordPerfect's own merge scripting language) and cross-references (0xD5). A cross-reference's displayed text survives as ordinary text; its target binding does not. Reported through
wpd/merge-code-droppedandwpd/cross-reference-flattened. Unlike FIELD, these can legitimately wrap whole paragraphs of body text as control flow, which the run-scoped field construct's own one-paragraph extent cannot express regardless — a schema gap for a scripting language's control flow, not a parsing gap. - WordPerfect 9-and-later "enhanced encryption", which throws: a different, unpublished cipher whose header word is not the standard mode's password checksum, so a
passwordread option either verifies against that checksum and decrypts the standard ("original") mode or throwsWpdWrongPasswordErrornaming both possible readings of the mismatch. The standard mode itself is decrypted for real (seesrc/container/encryption.tsfor the cipher's two independent sources, and for the honest limit that no open-source reference implementation of the 6.x wiring exists to cross-check against a WordPerfect-produced encrypted file).
Evidence
Two independent kinds, which answer different questions.
The specification's own worked examples and field tables answer "does this match what Corel documented". Every unit test is built either from a worked example — the annotated generic-prefix hex dump, the can't extended-character example, the com<0x83>ment soft-hyphen example — or from a byte sequence assembled directly from a field table, so each expectation is checkable against the page it cites without a file to hand.
A real corpus answers "does what Corel documented match what WordPerfect actually wrote", which the first kind cannot. The check ran against every WordPerfect 6.x-X6 file (file ID FF 57 50 43, major version 2) in libwpd's own oss-fuzz seed corpus — 93 files: hand-authored feature tests, real bug-report attachments from the AbiWord, OpenOffice, LibreOffice and freedesktop trackers, and a batch of anonymised real-world documents — alongside the two WordPerfect 6 samples in the Open Preservation Foundation's format corpus.
Ninety read. Three are refused, and each refusal is the right answer rather than a failure: a WordPerfect 3 for Macintosh file is rejected on its major-version byte (a separate format that shares the file ID — see Scope), an encrypted document is rejected because nothing past its header is intelligible without the password, and one file's function gates genuinely do not match — an AbiWord bug-report attachment, in a corpus whose whole purpose is to collect files that broke something. That last one is the tokeniser's own design working: it fails at the first byte the format says cannot be there, naming the offset, rather than decoding rubbish for the rest of the file.
What the corpus settles:
- The character model is right. This was the assumption most likely to make every real document read back as nonsense — bytes 1 through 32 are accented-letter shorthands rather than ASCII, so byte
0x20is the sharp s and a space is the Soft Space function. Real documents read back as correct prose, which they could not do if that were wrong. - Page geometry, tables, metadata, and outline numbering are exercised by real files, not only by fixtures: a quarter of the corpus states a page size other than US Letter, a third contains a table, and a sixth carries a document summary.
- The heading mapping is not. No document in the corpus uses WordPerfect's own heading styles (system style numbers 68 through 75), so heading recovery is still evidenced by the specification's enumeration alone. The style group itself is heavily exercised — footnote-number, endnote-number, document and hypertext system styles all appear, and all correctly carry no structure.
- Character sets 2 and above were the largest fidelity gap this check found, and set 4 was most of it. Two thirds of the extended characters in the corpus named character set 4, with the untabulated part of set 1 next, then sets 13, 6, 5, 8, 12 and 3, every one of them rendering as U+FFFD. That measurement is what made the missing character-set tables a concrete, prioritised piece of work rather than a theoretical one — see the section above: all fourteen of WordPerfect's own named character sets now decode, transcribed from libwpd's own tables rather than left as this package's own gap. This bullet stands as the corpus check's original finding, not a re-run against the closed tables; the fixed-byte-sequence tests in
src/stream/characters.test.tsare what checks the new tables themselves, one decoded example per set against the same cited source.
What is still not proven
- Two readings the specification does not settle, both chosen deliberately. The Form function states its desired width and its desired length as two independent fields and its orientation as a third, and says nothing about whether the pair is written before or after the rotation — so a landscape form's dimensions go through exactly as written and the flag is reported through
wpd/landscape-orientation-unmappedrather than rotated on this package's own inference. And a Table Column's[width]is the one horizontal dimension in the format the SDK does not tag(WPU), so it is read as WordPerfect Units on the strength of the two gutter fields immediately after it, which are. - Reading a document is not rendering one. The corpus check confirms that each file reads, that its text is prose, and that the structures above are recovered; it does not compare the result against what WordPerfect itself would display, which would need a reference renderer this family does not have.
- The document-area's own file-size bound. The header's file-size field is honoured only when self-consistent, because the SDK itself warns that a third-party writer failing to update it is a common real-world defect whose symptom is a document reading back blank.
- A partially-shaded cell fill's own compositing formula. A cell whose foreground and background colours are blended at a shading percentage other than 0 or 255 now resolves to a real two-colour
patternContentCellFill(ExaDev/documents.js#1024) rather than being flattened to its background colour alone, but the mirrored SDK pages document the RGBS byte layout, not how the shading byte composites the pair — so the background shade byte is read as that layer's own opacity and the foreground's implied coverage is snapped to the nearestpercentNstepContentCellPatternTypeSchemadefines, a derivation rather than a cited value. Reported throughwpd/cell-fill-blended.
Conventions
- Worker-isomorphic (see the family-wide convention): runtime
src/must not importnode:*, a bare Node builtin, or use theBufferglobal — enforced by ano-restricted-imports/no-restricted-globalsESLint rule and exercised in CI by running a test suite inside an actualworkerdisolate (pnpm test:workers). Test files undersrc/**/*.test.tsandsrc/test-support/are exempt. - Only
src/index.tsmay be namedindex.*, and it may contain only re-export statements. - Every non-obvious constant, offset, and mapping in
src/cites the SDK page it comes from. A number that cannot be traced to the specification does not belong in this package.
Install
pnpm add wpd-codec
# or
npm install wpd-codecRelease and publishing
Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the monorepo root README for the mechanism (topological per-package semantic-release via @exadev/semantic-release-workspace, OIDC trusted npm publishing, automatic sibling dependency-range rewriting).
Contributing
Conventional Commits, enforced workspace-wide by commitlint through a root commit-msg hook. Work inside packages/wpd-codec/; see CONTRIBUTING.md for the shared git hooks and history conventions.
License
MIT
