From 09e5ebc86fe9c3d35b078c01c151edda62a6c22b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 13:09:34 +0100 Subject: [PATCH 1/4] feat(doc-codec): add a genuine MS-DOC structure writer Implements writeDocContent, taking a single-section wordprocessing ContentDocument and producing real Word Binary File bytes wrapped in a real MS-CFB compound file: a piece table (always one uncompressed 16-bit piece, sidestepping the compressed-character mapping table entirely), Sprm-encoded grpprls for direct character and paragraph formatting, ChpxFkp/PapxFkp pages that split across as many 512-byte pages as the content needs, a spec-conformant style sheet carrying zero styles (FibRgFcLcb97.lcbStshf must be nonzero even though this package's own reader tolerates a missing one), and a font table (SttbfFfn/FFN) when a run names a font. Character formatting: bold, italic, strike, underline, sizePt, color (via sprmCCv's exact COLORREF rather than the lossy Ico palette), and fontFamily. Paragraph formatting: alignment, indentLeftPt, indentFirstLinePt, spacingBeforePt, spacingAfterPt, lineSpacing (multiplier form), and pageBreakBefore. The font table is new on the read side too: chp.ts now resolves sprmCRgFtc0 through a document's own SttbfFfn into ContentRun's fontFamily field, since round-tripping a run's font needed the reader to understand the same structure the writer produces. Refuses, rather than approximates, a document with more than one section, a non-paragraph block (table, image, embedded object, construct marker), and any property this package's own reader does not itself convert. --- packages/doc-codec/src/fib/fib.ts | 5 + packages/doc-codec/src/fib/offsets.ts | 3 + packages/doc-codec/src/fib/write.ts | 94 +++++++ packages/doc-codec/src/index.ts | 7 + packages/doc-codec/src/prop/chp-write.ts | 91 +++++++ packages/doc-codec/src/prop/chp.ts | 13 +- packages/doc-codec/src/prop/fkp-write.ts | 216 ++++++++++++++++ packages/doc-codec/src/prop/pap-write.ts | 141 ++++++++++ packages/doc-codec/src/read.ts | 17 +- packages/doc-codec/src/style/fonts.ts | 93 +++++++ packages/doc-codec/src/style/stsh.ts | 24 ++ .../doc-codec/src/text/piece-table-write.ts | 48 ++++ packages/doc-codec/src/write.ts | 243 ++++++++++++++++++ 13 files changed, 993 insertions(+), 2 deletions(-) create mode 100644 packages/doc-codec/src/fib/write.ts create mode 100644 packages/doc-codec/src/prop/chp-write.ts create mode 100644 packages/doc-codec/src/prop/fkp-write.ts create mode 100644 packages/doc-codec/src/prop/pap-write.ts create mode 100644 packages/doc-codec/src/style/fonts.ts create mode 100644 packages/doc-codec/src/text/piece-table-write.ts create mode 100644 packages/doc-codec/src/write.ts diff --git a/packages/doc-codec/src/fib/fib.ts b/packages/doc-codec/src/fib/fib.ts index 1aac2df11..1ef012098 100644 --- a/packages/doc-codec/src/fib/fib.ts +++ b/packages/doc-codec/src/fib/fib.ts @@ -40,6 +40,9 @@ export interface Fib { readonly lcbPlcfBtePapx: number; readonly fcClx: number; readonly lcbClx: number; + + readonly fcSttbfFfn: number; + readonly lcbSttbfFfn: number; } export function parseFib(wordDocument: Uint8Array): Fib { @@ -111,6 +114,8 @@ export function parseFib(wordDocument: Uint8Array): Fib { lcbPlcfBtePapx: fcLcb(FC_LCB_VALUE_INDEX.lcbPlcfBtePapx), fcClx: fcLcb(FC_LCB_VALUE_INDEX.fcClx), lcbClx: fcLcb(FC_LCB_VALUE_INDEX.lcbClx), + fcSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.fcSttbfFfn), + lcbSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.lcbSttbfFfn), }; } diff --git a/packages/doc-codec/src/fib/offsets.ts b/packages/doc-codec/src/fib/offsets.ts index f32b6ef94..8c27d1ac0 100644 --- a/packages/doc-codec/src/fib/offsets.ts +++ b/packages/doc-codec/src/fib/offsets.ts @@ -46,6 +46,9 @@ export const FC_LCB_VALUE_INDEX = { lcbPlcfBteChpx: 25, fcPlcfBtePapx: 26, lcbPlcfBtePapx: 27, + // FibRgFcLcb97's 16th fc/lcb pair, counted forward from fcStshfOrig at pair 0: fcPlcffndRef, fcPlcffndTxt, fcPlcfandRef, fcPlcfandTxt, fcPlcfSed, fcPlcPad, fcPlcfPhe, fcSttbfGlsy, fcPlcfGlsy, fcPlcfHdd, fcPlcfBteChpx, fcPlcfBtePapx, fcPlcfSea, then fcSttbfFfn -- pair 15, value index 30. + fcSttbfFfn: 30, + lcbSttbfFfn: 31, fcClx: 66, lcbClx: 67, } as const; diff --git a/packages/doc-codec/src/fib/write.ts b/packages/doc-codec/src/fib/write.ts new file mode 100644 index 000000000..d28f76bf0 --- /dev/null +++ b/packages/doc-codec/src/fib/write.ts @@ -0,0 +1,94 @@ +// The inverse of fib.ts's parseFib: builds a real File Information Block for nFib 0x00C1 (Word 97), the oldest and simplest FIB generation [MS-DOC] 2.5.1 defines and the one this package's own test-support/fib.ts already targets. cbRgFcLcb is fixed at the 0x005D that nFib 0x00C1 mandates ([MS-DOC]'s own Fib page table), and cswNew is fixed at 0 (also mandated for 0x00C1, so fibRgCswNew is simply absent) -- neither is a parameter, because choosing a newer nFib would change both without this writer gaining anything from it: every structure this package writes (the piece table, the two bin tables, the style sheet, the font table) is unchanged across nFib generations. +// +// Only the fields this writer's own reader needs to get back to the streams it wrote are populated: the four subdocument-boundary fields this package's reader itself reads (cbMac, ccpText) plus the fc/lcb pairs locating the Clx, the two property bin tables, the style sheet, and (when used) the font table. Every other fc/lcb pair -- SttbfAssoc, Dop, the printer-driver structures, and the ~140 others [MS-DOC] defines -- is left zero, which is exactly the "undefined, MUST be ignored" contract most of them carry (see FibRgFcLcb97's own field table). A small number of those unpopulated fields carry a genuine "MUST NOT be zero" clause of their own (SttbfAssoc's lcb among them) that this writer does not satisfy: the resulting bytes are conformant for every structure this package's own reader consults, not a certification that Microsoft Word or another third-party reader would accept the file's every field. See the README's own scope note. + +import { readUint16LE } from "../bytes"; +import { DocFormatError } from "../errors"; +import { + FC_LCB_VALUE_INDEX, + FIB_BASE_FLAG, + FIB_BASE_SIZE, + FIB_CB_RG_FC_LCB_OFFSET, + FIB_CSLW_OFFSET, + FIB_CSLW_REQUIRED, + FIB_CSW_REQUIRED, + FIB_FC_LCB_BLOB_OFFSET, + FIB_RG_LW_OFFSET, + FIB_W_IDENT, + LW_OFFSET, +} from "./offsets"; + +/** [MS-DOC]'s own Fib page: nFib 0x00C1 mandates cbRgFcLcb 0x005D and cswNew 0. */ +const NFIB_WORD_97 = 0x00c1; +const CB_RG_FC_LCB_WORD_97 = 0x005d; +/** FibBase.nFibBack: "this value SHOULD be 0x00BF" for a document written by an application that does not need older-version compatibility beyond it -- carried verbatim from test-support/fib.ts, which cites the same field. */ +const N_FIB_BACK = 0x00bf; +/** FibBase's own flags word at offset 10: fExtChar "MUST be 1" ([MS-DOC] 2.5.2), independent of fComplex/fWhichTblStm/fEncrypted/fObfuscated (see offsets.ts's own FIB_BASE_FLAG). */ +const FLAG_F_EXT_CHAR = 0x1000; + +export interface FibWriteSpec { + readonly ccpText: number; + readonly cbMac: number; + readonly fcClx: number; + readonly lcbClx: number; + readonly fcPlcfBteChpx: number; + readonly lcbPlcfBteChpx: number; + readonly fcPlcfBtePapx: number; + readonly lcbPlcfBtePapx: number; + readonly fcStshf: number; + readonly lcbStshf: number; + /** 0/0 when the document uses no font table (see write.ts). */ + readonly fcSttbfFfn: number; + readonly lcbSttbfFfn: number; +} + +export function buildFib(spec: FibWriteSpec): Uint8Array { + const blobBytes = CB_RG_FC_LCB_WORD_97 * 8; + const total = FIB_FC_LCB_BLOB_OFFSET + blobBytes + 2; // + cswNew, which is 0 and carries no fibRgCswNew after it. + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + + view.setUint16(0, FIB_W_IDENT, true); + view.setUint16(2, NFIB_WORD_97, true); + // fWhichTblStm always selects "1Table" (see write.ts's use of tableStreamName); fComplex, fEncrypted and fObfuscated are never set by a fresh, unencrypted, single-save document this writer produces. + view.setUint16(10, FLAG_F_EXT_CHAR | FIB_BASE_FLAG.fWhichTblStm, true); + view.setUint16(12, N_FIB_BACK, true); + + view.setUint16(FIB_BASE_SIZE, FIB_CSW_REQUIRED, true); // csw. + view.setUint16(FIB_CSLW_OFFSET, FIB_CSLW_REQUIRED, true); // cslw. + + const lw = (offset: number, value: number): void => { + view.setInt32(FIB_RG_LW_OFFSET + offset, value, true); + }; + lw(LW_OFFSET.cbMac, spec.cbMac); + lw(LW_OFFSET.ccpText, spec.ccpText); + // Every other FibRgLw97 field (ccpFtn, ccpHdd, ccpAtn, ccpEdn, ccpTxbx, ccpHdrTxbx) stays 0: this writer produces only a Main Document, so every other subdocument this package's own reader is aware of is genuinely empty rather than merely unpopulated. + + view.setUint16(FIB_CB_RG_FC_LCB_OFFSET, CB_RG_FC_LCB_WORD_97, true); + const pair = (index: number, fc: number, lcb: number): void => { + const offset = FIB_FC_LCB_BLOB_OFFSET + index * 4; + view.setUint32(offset, fc, true); + view.setUint32(offset + 4, lcb, true); + }; + pair(FC_LCB_VALUE_INDEX.fcStshf, spec.fcStshf, spec.lcbStshf); + pair( + FC_LCB_VALUE_INDEX.fcPlcfBteChpx, + spec.fcPlcfBteChpx, + spec.lcbPlcfBteChpx, + ); + pair( + FC_LCB_VALUE_INDEX.fcPlcfBtePapx, + spec.fcPlcfBtePapx, + spec.lcbPlcfBtePapx, + ); + pair(FC_LCB_VALUE_INDEX.fcSttbfFfn, spec.fcSttbfFfn, spec.lcbSttbfFfn); + pair(FC_LCB_VALUE_INDEX.fcClx, spec.fcClx, spec.lcbClx); + // cswNew (the 2 bytes at FIB_FC_LCB_BLOB_OFFSET + blobBytes) stays 0, which [MS-DOC] mandates for nFib 0x00C1 and which correctly leaves fibRgCswNew absent. + + if (readUint16LE(bytes, 0) !== FIB_W_IDENT) { + throw new DocFormatError( + "buildFib produced a Fib whose own wIdent does not read back as 0xA5EC; this is an internal defect, not an input error", + ); + } + return bytes; +} diff --git a/packages/doc-codec/src/index.ts b/packages/doc-codec/src/index.ts index 495176490..6751bce46 100644 --- a/packages/doc-codec/src/index.ts +++ b/packages/doc-codec/src/index.ts @@ -5,12 +5,19 @@ export * from "./plc"; export * from "./detect"; export * from "./fib/offsets"; export * from "./fib/fib"; +export * from "./fib/write"; export * from "./text/piece-table"; +export * from "./text/piece-table-write"; export * from "./text/characters"; export * from "./text/special"; export * from "./prop/sprm"; export * from "./prop/fkp"; +export * from "./prop/fkp-write"; export * from "./prop/chp"; +export * from "./prop/chp-write"; export * from "./prop/pap"; +export * from "./prop/pap-write"; export * from "./style/stsh"; +export * from "./style/fonts"; export * from "./read"; +export * from "./write"; diff --git a/packages/doc-codec/src/prop/chp-write.ts b/packages/doc-codec/src/prop/chp-write.ts new file mode 100644 index 000000000..dacd45abe --- /dev/null +++ b/packages/doc-codec/src/prop/chp-write.ts @@ -0,0 +1,91 @@ +import type { ContentRun } from "document-schema.js"; +import { DocFormatError } from "../errors"; + +// The inverse of chp.ts's applyCharacterSprms: a ContentRun's direct character formatting to a Chpx grpprl -- the bytes a ChpxFkp entry carries (see prop/fkp-write.ts). Each property this package's reader folds gets exactly the sprm chp.ts itself reads back, so a round trip through readDocContent(writeDocContent(x)) recovers the identical value rather than a lossy approximation through a different (but readable) encoding -- sprmCCv for colour rather than the fixed 17-entry sprmCIco palette, for instance, since sprmCCv carries the colour exactly and sprmCIco would have to snap it to the nearest palette entry. +// +// Opcodes are restated as local constants rather than imported from chp.ts: that module names them for its OWN switch cases, and importing them here would couple this file's exports to chp.ts's private naming rather than to the specification both independently cite. + +/** sprmCFBold, sprmCFItalic, sprmCFStrike: a 1-byte ToggleOperand, [MS-DOC] 2.9.336. 0x00 and 0x01 are the only values this writer emits -- 0x80/0x81 (inherit/invert relative to a style) have no meaning here, since this package writes no style sheet for a run's character properties to inherit from. */ +const SPRM_C_F_BOLD = 0x0835; +const SPRM_C_F_ITALIC = 0x0836; +const SPRM_C_F_STRIKE = 0x0837; +/** sprmCKul: a 1-byte Kul value. 0x00 is "none"; 0x01 is kulSingle, the only underline style ContentRun.underline can express (a plain boolean, not a style enum). */ +const SPRM_C_KUL = 0x2a3e; +const KUL_NONE = 0x00; +const KUL_SINGLE = 0x01; +/** sprmCHps: a 2-byte unsigned half-point size. */ +const SPRM_C_HPS = 0x4a43; +/** sprmCCv: a 4-byte COLORREF (r, g, b, fAuto). fAuto 0x00 means "use these components", the only form this writer emits. */ +const SPRM_C_CV = 0x6870; +/** sprmCRgFtc0: a 2-byte signed index into the font table, [MS-DOC] 2.6.2 -- see style/fonts.ts. */ +const SPRM_C_RG_FTC_0 = 0x4a4f; + +const HALF_POINTS_PER_POINT = 2; +const COLOR_COMPONENT_MAX = 255; +/** sprmCHps's own operand range: an unsigned 2-byte half-point value. */ +const MAX_HPS = 0xffff; + +function pushSprm( + bytes: number[], + opcode: number, + operand: readonly number[], +): void { + bytes.push(opcode & 0xff, (opcode >> 8) & 0xff, ...operand); +} + +function toggle(value: boolean): number[] { + return [value ? 0x01 : 0x00]; +} + +function uint16(value: number): number[] { + return [value & 0xff, (value >> 8) & 0xff]; +} + +function colorRef(color: { r: number; g: number; b: number }): number[] { + const byte = (component: number): number => + Math.round(component * COLOR_COMPONENT_MAX); + return [byte(color.r), byte(color.g), byte(color.b), 0x00]; +} + +// Builds the Chpx grpprl for one run's direct formatting. Returns an empty array for a run with no formatting at all, which the caller (write.ts) treats as "no exception" -- exactly the rgb-zero case parseChpxFkp reads back as undefined. +export function encodeCharacterGrpprl( + run: Pick< + ContentRun, + | "bold" + | "italic" + | "underline" + | "strike" + | "sizePt" + | "color" + | "fontFamily" + >, + fontIndexOf: (name: string) => number, +): number[] { + const bytes: number[] = []; + if (run.bold !== undefined) pushSprm(bytes, SPRM_C_F_BOLD, toggle(run.bold)); + if (run.italic !== undefined) { + pushSprm(bytes, SPRM_C_F_ITALIC, toggle(run.italic)); + } + if (run.strike !== undefined) { + pushSprm(bytes, SPRM_C_F_STRIKE, toggle(run.strike)); + } + if (run.underline !== undefined) { + pushSprm(bytes, SPRM_C_KUL, [run.underline ? KUL_SINGLE : KUL_NONE]); + } + if (run.sizePt !== undefined) { + const halfPoints = Math.round(run.sizePt * HALF_POINTS_PER_POINT); + if (halfPoints < 0 || halfPoints > MAX_HPS) { + throw new DocFormatError( + `run sizePt ${run.sizePt} is ${halfPoints} half-points, outside the 0..${MAX_HPS} range sprmCHps's unsigned 2-byte operand can hold`, + ); + } + pushSprm(bytes, SPRM_C_HPS, uint16(halfPoints)); + } + if (run.color !== undefined) { + pushSprm(bytes, SPRM_C_CV, colorRef(run.color)); + } + if (run.fontFamily !== undefined) { + pushSprm(bytes, SPRM_C_RG_FTC_0, uint16(fontIndexOf(run.fontFamily))); + } + return bytes; +} diff --git a/packages/doc-codec/src/prop/chp.ts b/packages/doc-codec/src/prop/chp.ts index dc77b5463..0a31be962 100644 --- a/packages/doc-codec/src/prop/chp.ts +++ b/packages/doc-codec/src/prop/chp.ts @@ -1,5 +1,5 @@ import type { Color } from "document-schema.js"; -import { readUint16LE, readUint8 } from "../bytes"; +import { readInt16LE, readUint16LE, readUint8 } from "../bytes"; import { DocFormatError } from "../errors"; import { SGC, type Prl } from "./sprm"; @@ -21,6 +21,8 @@ const SPRM_C_ISTD = 0x4a30; const SPRM_C_ICO = 0x2a42; /** sprmCCv: a COLORREF, the richer colour sprm that supersedes sprmCIco where both appear. */ const SPRM_C_CV = 0x6870; +/** sprmCRgFtc0: a 2-byte signed index into the font table (SttbfFfn) naming the font used "only if the conditions for using [sprmCRgFtc1/sprmCRgFtc2/sprmCFtcBi] do not apply" -- the default (non-East-Asian, non-complex-script) font, which is the only one this package reads or writes. */ +const SPRM_C_RG_FTC_0 = 0x4a4f; /** ToggleOperand, [MS-DOC] 2.9.336. 0x80 and 0x81 are relative to the style's own value rather than absolute. */ const TOGGLE_OFF = 0x00; @@ -38,6 +40,7 @@ export interface CharacterProperties { strike?: boolean; sizePt?: number; color?: Color; + fontFamily?: string; /** The istd of a character style applied by sprmCIstd, carried so a caller can resolve the style's own name. */ istd?: number; } @@ -118,6 +121,8 @@ function colorRefColor(operand: Uint8Array): Color | undefined { export function applyCharacterSprms( prls: readonly Prl[], into: CharacterProperties, + // The font table (SttbfFfn, see ../style/fonts.ts) sprmCRgFtc0's operand indexes into. Threaded through rather than resolved by the caller after the fact, because folding is the one place every character sprm's precedence rule (last Prl wins) is already applied -- resolving fontFamily anywhere else would need this same in-order walk repeated. + fonts?: readonly string[], ): CharacterProperties { for (const prl of prls) { if (prl.sprm.sgc !== SGC.character) continue; @@ -146,6 +151,12 @@ export function applyCharacterSprms( case SPRM_C_CV: into.color = colorRefColor(prl.operand); break; + case SPRM_C_RG_FTC_0: { + const index = readInt16LE(prl.operand, 0); + const name = fonts?.[index]; + if (name !== undefined) into.fontFamily = name; + break; + } default: // Every other character sprm is a property this reader does not convert. Left alone rather than recorded: the package's scope is stated once, in its README, not restated as a per-property diagnostic on every run of every document. break; diff --git a/packages/doc-codec/src/prop/fkp-write.ts b/packages/doc-codec/src/prop/fkp-write.ts new file mode 100644 index 000000000..b336f4d63 --- /dev/null +++ b/packages/doc-codec/src/prop/fkp-write.ts @@ -0,0 +1,216 @@ +import { DocFormatError } from "../errors"; +import { FKP_PAGE_SIZE } from "./fkp"; + +// The inverse of fkp.ts's parseChpxFkp/parsePapxFkp: packs a document's character- and paragraph-formatting exceptions into formatted disk pages, splitting across as many 512-byte pages as the content needs rather than assuming it always fits one. [MS-DOC] 2.9.23/2.9.175 bound a single page at MAX_CRUN (0x65) runs or MAX_CPARA (0x1D) paragraphs, and every page is a fixed 512 bytes regardless of how much grpprl content its records hold -- a document with enough distinct formatting exceptions overflows either limit before the file itself is large, so page-splitting is exercised in this module's own tests rather than left as a theoretical concern a small document would never hit. +// +// Layout mirrors fkp.ts's own read-side comments exactly, because it is inverting the identical structure: the element count in the page's LAST byte, rgfc/rgb (or rgfc/bxPap) arrays growing forward from the front, and property records packed backward from the end with their own offsets stored halved. Deliberately not shared with test-support/fkp.ts's builders, which exist to construct arbitrary and deliberately-invalid fixtures for the reader's own tests: this module is production code with its own overflow detection (a batch that would not fit returns undefined rather than silently overwriting the front arrays with the record region), which the test-support builders have no reason to carry. + +const MAX_CRUN = 0x65; +const MAX_CPARA = 0x1d; +/** A BxPap is a 1-byte bOffset followed by a 12-byte PHE this package never populates (see fkp.ts: "the specification says SHOULD be zero and SHOULD be ignored"). */ +const BX_PAP_SIZE = 13; +/** A Chpx's own cb is one byte, and a PapxInFkp's one-byte cb/cb' halves the GrpPrlAndIstd length -- both single-byte fields, so 255 bounds a Chpx record's cb and 510 bounds a PapxInFkp's GrpPrlAndIstd (2 x 255) before either can no longer be expressed. */ +const MAX_CHPX_RECORD_GRPPRL = 0xff; +const MAX_GRP_PRL_AND_ISTD = 0x1fe; + +export interface ChpxRunToWrite { + readonly fc: number; + /** undefined writes rgb 0 -- "no exception, default properties" -- exactly what parseChpxFkp reads back as undefined. */ + readonly grpprl: readonly number[] | undefined; +} + +export interface PapxParagraphToWrite { + readonly fc: number; + readonly istd: number; + readonly grpprl: readonly number[]; +} + +function buildChpxPage( + runs: readonly ChpxRunToWrite[], + fcLim: number, +): Uint8Array | undefined { + const crun = runs.length; + if (crun < 1 || crun > MAX_CRUN) return undefined; + const page = new Uint8Array(FKP_PAGE_SIZE); + const view = new DataView(page.buffer); + runs.forEach((run, index) => { + view.setUint32(index * 4, run.fc, true); + }); + view.setUint32(crun * 4, fcLim, true); + page[FKP_PAGE_SIZE - 1] = crun; + + const rgbStart = (crun + 1) * 4; + const frontUsed = rgbStart + crun; + let writeAt = FKP_PAGE_SIZE - 1; + for (let index = 0; index < crun; index += 1) { + const run = runs[index]; + if (run === undefined) { + throw new DocFormatError("run missing from a batch already sized"); + } + const rgbAt = rgbStart + index; + if (run.grpprl === undefined) { + page[rgbAt] = 0; + continue; + } + if (run.grpprl.length > MAX_CHPX_RECORD_GRPPRL) return undefined; + const record = [run.grpprl.length, ...run.grpprl]; + writeAt -= record.length; + writeAt -= writeAt % 2; + if (writeAt < frontUsed) return undefined; + page.set(record, writeAt); + page[rgbAt] = writeAt / 2; + } + return page; +} + +function buildPapxPage( + paragraphs: readonly PapxParagraphToWrite[], + fcLim: number, +): Uint8Array | undefined { + const cpara = paragraphs.length; + if (cpara < 1 || cpara > MAX_CPARA) return undefined; + const page = new Uint8Array(FKP_PAGE_SIZE); + const view = new DataView(page.buffer); + paragraphs.forEach((paragraph, index) => { + view.setUint32(index * 4, paragraph.fc, true); + }); + view.setUint32(cpara * 4, fcLim, true); + page[FKP_PAGE_SIZE - 1] = cpara; + + const bxPapStart = (cpara + 1) * 4; + const frontUsed = bxPapStart + cpara * BX_PAP_SIZE; + let writeAt = FKP_PAGE_SIZE - 1; + for (let index = 0; index < cpara; index += 1) { + const paragraph = paragraphs[index]; + if (paragraph === undefined) { + throw new DocFormatError("paragraph missing from a batch already sized"); + } + const bxPapAt = bxPapStart + index * BX_PAP_SIZE; + const grpPrlAndIstd = [ + paragraph.istd & 0xff, + (paragraph.istd >> 8) & 0xff, + ...paragraph.grpprl, + ]; + if (grpPrlAndIstd.length > MAX_GRP_PRL_AND_ISTD) return undefined; + // Which of PapxInFkp's two length spellings applies is decided by parity alone -- see fkp.ts's own comment on parsePapxFkp for why an odd GrpPrlAndIstd always takes the one-byte cb form and an even one the two-byte cb' form. + const record: number[] = + grpPrlAndIstd.length % 2 === 1 + ? [(grpPrlAndIstd.length + 1) / 2, ...grpPrlAndIstd] + : [0x00, grpPrlAndIstd.length / 2, ...grpPrlAndIstd]; + writeAt -= record.length; + writeAt -= writeAt % 2; + if (writeAt < frontUsed) return undefined; + page.set(record, writeAt); + page[bxPapAt] = writeAt / 2; + } + return page; +} + +// Greedily fills each page to capacity before starting the next, using a placeholder fcLim of 0 while testing fit: a page's byte usage never depends on the VALUE in its final rgfc slot, only on the slot's fixed presence, so the fit-or-not answer this produces is identical to what the real fcLim would give. Throws when a single item cannot fit in an otherwise-empty page -- a defect in the caller's own grpprl encoding, not a shape this format can express by splitting further. +function splitIntoBatches( + items: readonly T[], + fits: (batch: readonly T[]) => boolean, + what: string, +): T[][] { + const batches: T[][] = []; + let batch: T[] = []; + for (const item of items) { + const candidate = [...batch, item]; + if (fits(candidate)) { + batch = candidate; + continue; + } + if (batch.length === 0) { + throw new DocFormatError( + `a single ${what} does not fit in one 512-byte formatted disk page`, + ); + } + batches.push(batch); + batch = [item]; + if (!fits(batch)) { + throw new DocFormatError( + `a single ${what} does not fit in one 512-byte formatted disk page`, + ); + } + } + if (batch.length > 0) batches.push(batch); + return batches; +} + +/** Splits `runs` across as many ChpxFkp pages as needed. `fcLim` is the exclusive end of the whole run sequence (the byte offset one past the document's own text). */ +export function buildChpxPages( + runs: readonly ChpxRunToWrite[], + fcLim: number, +): Uint8Array[] { + if (runs.length === 0) { + throw new DocFormatError("buildChpxPages requires at least one run"); + } + const batches = splitIntoBatches( + runs, + (batch) => buildChpxPage(batch, 0) !== undefined, + "character-formatting run", + ); + return batches.map((batch, index) => { + const next = batches[index + 1]; + const pageFcLim = next === undefined ? fcLim : (next[0]?.fc ?? fcLim); + const page = buildChpxPage(batch, pageFcLim); + if (page === undefined) { + throw new DocFormatError( + "a ChpxFkp batch that fit during splitting no longer fits when finalised; this is an internal defect", + ); + } + return page; + }); +} + +/** Splits `paragraphs` across as many PapxFkp pages as needed. `fcLim` is the exclusive end of the whole paragraph sequence. */ +export function buildPapxPages( + paragraphs: readonly PapxParagraphToWrite[], + fcLim: number, +): Uint8Array[] { + if (paragraphs.length === 0) { + throw new DocFormatError("buildPapxPages requires at least one paragraph"); + } + const batches = splitIntoBatches( + paragraphs, + (batch) => buildPapxPage(batch, 0) !== undefined, + "paragraph-formatting record", + ); + return batches.map((batch, index) => { + const next = batches[index + 1]; + const pageFcLim = next === undefined ? fcLim : (next[0]?.fc ?? fcLim); + const page = buildPapxPage(batch, pageFcLim); + if (page === undefined) { + throw new DocFormatError( + "a PapxFkp batch that fit during splitting no longer fits when finalised; this is an internal defect", + ); + } + return page; + }); +} + +/** A built page's own first rgfc entry -- the byte offset of the first run or paragraph it covers. Reading it back out of the page's own bytes, rather than threading it through as separate metadata, keeps the bin table's keys and the page's own content provably in agreement: there is exactly one place either could disagree with itself. */ +export function firstFcOfPage(page: Uint8Array): number { + return new DataView(page.buffer, page.byteOffset, 4).getUint32(0, true); +} + +/** Builds a PlcBteChpx or PlcBtePapx: the bin table mapping each page's own starting byte offset (plus a final terminating fcLim) to its page number. `firstFcs` must carry exactly one more entry than `pageNumbers` -- see plc.ts's own PLC shape. */ +export function buildPropertyBinTable( + firstFcs: readonly number[], + pageNumbers: readonly number[], +): Uint8Array { + if (firstFcs.length !== pageNumbers.length + 1) { + throw new DocFormatError( + `buildPropertyBinTable was given ${firstFcs.length} keys for ${pageNumbers.length} page numbers; a PLC needs exactly one more key than element`, + ); + } + const bytes = new Uint8Array(firstFcs.length * 4 + pageNumbers.length * 4); + const view = new DataView(bytes.buffer); + firstFcs.forEach((fc, index) => { + view.setUint32(index * 4, fc, true); + }); + pageNumbers.forEach((pageNumber, index) => { + view.setUint32(firstFcs.length * 4 + index * 4, pageNumber, true); + }); + return bytes; +} diff --git a/packages/doc-codec/src/prop/pap-write.ts b/packages/doc-codec/src/prop/pap-write.ts new file mode 100644 index 000000000..77440c12d --- /dev/null +++ b/packages/doc-codec/src/prop/pap-write.ts @@ -0,0 +1,141 @@ +import type { Alignment, ContentParagraph } from "document-schema.js"; +import { DocFormatError } from "../errors"; + +// The inverse of pap.ts's applyParagraphSprms: a ContentParagraph's direct paragraph formatting to a PapxInFkp grpprl. Each property emits the LOGICAL sprm pap.ts reads (sprmPJc rather than sprmPJc80, sprmPDxaLeft rather than sprmPDxaLeft80) -- the "80" spellings exist for pre-2000 producers this package has no reason to imitate, and pap.ts's own last-Prl-wins fold means writing only the logical form round-trips exactly. +// +// Opcodes are restated as local constants rather than imported from pap.ts, for the same reason chp-write.ts restates chp.ts's: this module's exports are coupled to the specification's own opcode table, not to a sibling module's private switch-case names. + +/** sprmPJc: a 1-byte logical justification. */ +const SPRM_P_JC = 0x2461; +/** sprmPDxaLeft / sprmPDxaLeft1: 2-byte signed twips. There is no writer-side counterpart for sprmPDxaRight (indent from the right margin): pap.ts's own reader folds it into a ParagraphProperties.indentRightPt field, but ContentParagraphSchema (document-schema.js) carries no such field for any writer to round-trip -- the right-indent value the reader computes is simply not part of the shared schema's paragraph vocabulary. */ +const SPRM_P_DXA_LEFT = 0x845e; +const SPRM_P_DXA_LEFT1 = 0x8460; +/** sprmPDyaBefore / sprmPDyaAfter: 2-byte unsigned twips. */ +const SPRM_P_DYA_BEFORE = 0xa413; +const SPRM_P_DYA_AFTER = 0xa414; +/** sprmPDyaLine: a 4-byte LSPD (dyaLine int16, fMultLinespace uint16). */ +const SPRM_P_DYA_LINE = 0x6412; +/** sprmPFPageBreakBefore: a 1-byte Bool8. */ +const SPRM_P_F_PAGE_BREAK_BEFORE = 0x2407; + +const TWIPS_PER_POINT = 20; +const LSPD_MULTIPLE_DIVISOR = 240; +const LSPD_MAX_MULTIPLE_DYA_LINE = 0x7bc0; +/** sprmPDxaLeft/Right/Left1's signed 2-byte operand range. */ +const MIN_INT16 = -0x8000; +const MAX_INT16 = 0x7fff; +/** sprmPDyaBefore/After's unsigned 2-byte operand range. */ +const MAX_UINT16 = 0xffff; + +const JC_VALUE: Record = { + left: 0, + center: 1, + right: 2, + justify: 3, +}; + +function pushSprm( + bytes: number[], + opcode: number, + operand: readonly number[], +): void { + bytes.push(opcode & 0xff, (opcode >> 8) & 0xff, ...operand); +} + +function int16(value: number, what: string): number[] { + const rounded = Math.round(value); + if (rounded < MIN_INT16 || rounded > MAX_INT16) { + throw new DocFormatError( + `${what} is ${rounded} twips, outside the ${MIN_INT16}..${MAX_INT16} range a signed 2-byte sprm operand can hold`, + ); + } + const unsigned = rounded < 0 ? rounded + 0x10000 : rounded; + return [unsigned & 0xff, (unsigned >> 8) & 0xff]; +} + +function uint16(value: number, what: string): number[] { + const rounded = Math.round(value); + if (rounded < 0 || rounded > MAX_UINT16) { + throw new DocFormatError( + `${what} is ${rounded} twips, outside the 0..${MAX_UINT16} range an unsigned 2-byte sprm operand can hold`, + ); + } + return [rounded & 0xff, (rounded >> 8) & 0xff]; +} + +function pointsToTwips(pt: number): number { + return pt * TWIPS_PER_POINT; +} + +// Builds the PapxInFkp grpprl for one paragraph's direct formatting (excluding istd, which write.ts's caller places in GrpPrlAndIstd's own field rather than as a sprm -- see prop/fkp-write.ts). Returns an empty array for a paragraph with no direct formatting at all. +export function encodeParagraphGrpprl( + paragraph: Pick< + ContentParagraph, + | "alignment" + | "indentLeftPt" + | "indentFirstLinePt" + | "spacingBeforePt" + | "spacingAfterPt" + | "lineSpacing" + | "pageBreakBefore" + >, +): number[] { + const bytes: number[] = []; + if (paragraph.alignment !== undefined) { + pushSprm(bytes, SPRM_P_JC, [JC_VALUE[paragraph.alignment]]); + } + if (paragraph.indentLeftPt !== undefined) { + pushSprm( + bytes, + SPRM_P_DXA_LEFT, + int16(pointsToTwips(paragraph.indentLeftPt), "paragraph indentLeftPt"), + ); + } + if (paragraph.indentFirstLinePt !== undefined) { + pushSprm( + bytes, + SPRM_P_DXA_LEFT1, + int16( + pointsToTwips(paragraph.indentFirstLinePt), + "paragraph indentFirstLinePt", + ), + ); + } + if (paragraph.spacingBeforePt !== undefined) { + pushSprm( + bytes, + SPRM_P_DYA_BEFORE, + uint16( + pointsToTwips(paragraph.spacingBeforePt), + "paragraph spacingBeforePt", + ), + ); + } + if (paragraph.spacingAfterPt !== undefined) { + pushSprm( + bytes, + SPRM_P_DYA_AFTER, + uint16( + pointsToTwips(paragraph.spacingAfterPt), + "paragraph spacingAfterPt", + ), + ); + } + if (paragraph.lineSpacing !== undefined) { + const dyaLine = Math.round(paragraph.lineSpacing * LSPD_MULTIPLE_DIVISOR); + if (dyaLine < 0 || dyaLine > LSPD_MAX_MULTIPLE_DYA_LINE) { + throw new DocFormatError( + `paragraph lineSpacing ${paragraph.lineSpacing} produces an LSPD.dyaLine of ${dyaLine}, outside the 0..${LSPD_MAX_MULTIPLE_DYA_LINE} range the multiplier form permits`, + ); + } + pushSprm(bytes, SPRM_P_DYA_LINE, [ + ...int16(dyaLine, "paragraph lineSpacing"), + 0x01, + 0x00, // fMultLinespace = 1: the multiplier form. + ]); + } + if (paragraph.pageBreakBefore === true) { + pushSprm(bytes, SPRM_P_F_PAGE_BREAK_BEFORE, [0x01]); + } + return bytes; +} diff --git a/packages/doc-codec/src/read.ts b/packages/doc-codec/src/read.ts index 7263d4247..50518adba 100644 --- a/packages/doc-codec/src/read.ts +++ b/packages/doc-codec/src/read.ts @@ -15,6 +15,7 @@ import { applyCharacterSprms, type CharacterProperties } from "./prop/chp"; import { PropertyBinTable } from "./prop/fkp"; import { applyParagraphSprms, type ParagraphProperties } from "./prop/pap"; import { readGrpprl } from "./prop/sprm"; +import { parseFontTable } from "./style/fonts"; import { headingLevelFromIstd, parseStsh, type StyleSheet } from "./style/stsh"; import { readTextRange } from "./text/characters"; import { parseClx } from "./text/piece-table"; @@ -102,6 +103,17 @@ export function readDocContent( ), "PlcBtePapx", ); + const fonts = + fib.lcbSttbfFfn > 0 + ? parseFontTable( + slice( + table, + fib.fcSttbfFfn, + fib.lcbSttbfFfn, + "SttbfFfn in the Table stream", + ), + ) + : undefined; // The main document is the first subdocument: it starts at character position 0 and runs for ccpText characters, with the footnote, header, comment, endnote and textbox subdocuments following it in the order FibRgLw97 declares them. Only the main document is converted here; the rest are left for the subdocument support the README's scope section describes as absent. const range = readTextRange(wordDocument, pieceTable, 0, fib.ccpText); @@ -110,6 +122,7 @@ export function readDocContent( chpxTable, papxTable, styles, + fonts, characterProperties: new Map(), }); @@ -131,6 +144,8 @@ interface ReadContext { readonly chpxTable: PropertyBinTable; readonly papxTable: PropertyBinTable; readonly styles: StyleSheet | undefined; + /** The font names sprmCRgFtc0's operand indexes into, or undefined when the document carries no SttbfFfn at all. */ + readonly fonts: readonly string[] | undefined; // Character properties already folded out of one Chpx, keyed by that Chpx's own position and length in the WordDocument stream. It belongs to the whole read rather than to one paragraph because a Chpx routinely spans many paragraphs -- a document in one font is one exception covering all of it -- so a per-paragraph cache would re-parse the same grpprl once per paragraph and never hit. readonly characterProperties: Map; } @@ -307,7 +322,7 @@ function buildRuns( if (properties === undefined) { properties = {}; if (grpprl !== undefined) { - applyCharacterSprms(readGrpprl(grpprl), properties); + applyCharacterSprms(readGrpprl(grpprl), properties, context.fonts); } context.characterProperties.set(key, properties); } diff --git a/packages/doc-codec/src/style/fonts.ts b/packages/doc-codec/src/style/fonts.ts new file mode 100644 index 000000000..5d9c2b345 --- /dev/null +++ b/packages/doc-codec/src/style/fonts.ts @@ -0,0 +1,93 @@ +import { readUint16LE, readUint8, slice } from "../bytes"; +import { DocFormatError } from "../errors"; + +// The font table (SttbfFfn), [MS-DOC] 2.9.253 -- the STTB whose entries are FFN records naming the fonts sprmCRgFtc0 ([MS-DOC] 2.6.2) indexes into. Read and written together in one module, unlike the rest of this package's read/write split, because the structure is small, has no independent test-support fixture of its own, and both directions share the same field layout this module's own constants state once. +// +// [MS-DOC] 2.9.253 states SttbfFfn is a "non-extended character STTB" ([MS-DOC] 2.9.256's STTB, the generic string-table shape countless other structures reuse): no leading 0xFFFF fExtend marker, a 2-byte cData entry count, a 2-byte cbExtra that "MUST be 0" for this STTB, then cData entries of (1-byte cch, cch bytes of Data). For every OTHER non-extended STTB, Data is ANSI text and cch counts characters; SttbfFfn's own definition overrides that meaning -- Data is a raw FFN record's bytes, and cch is that record's total BYTE length, not a character count. That is why an FFN's own two variable-length names (xszFfn, and xszAlt when present) are still UTF-16, encoded as null-terminated Unicode, even though the STTB wrapping them is the "non-extended" 1-byte-length spelling: the "extended" bit describes the STTB's own length-prefix width, not what any one structure chooses to put in its Data field. +// +// [MS-DOC] 2.9.87 (FFN) gives the record's own fixed head: ffid (1 byte, the font family), wWeight (2 bytes signed, visual weight -- 400 normal, 700 bold), chs (1 byte, the font's character set), ixchSzAlt (1 byte, a zero-based index into xszFfn where an alternate-font name begins, zero meaning "no alternate"), panose (10 bytes) and fs (24 bytes, a FontSignature) both describing font substitution metadata this package neither reads nor writes meaningfully, then xszFfn itself (variable, null-terminated UTF-16) and, only when ixchSzAlt is nonzero, xszAlt (variable, null-terminated UTF-16, beginning immediately after xszFfn's own terminator). + +/** FFN's fixed head before xszFfn: ffid(1) + wWeight(2) + chs(1) + ixchSzAlt(1) + panose(10) + fs(24). */ +const FFN_FIXED_SIZE = 1 + 2 + 1 + 1 + 10 + 24; +/** wWeight's "400 corresponds to normal text" -- the only weight this package ever writes, since bold is carried by sprmCFBold rather than by selecting a differently-weighted font face. */ +const FFN_WEIGHT_NORMAL = 400; +/** chs's ANSI_CHARSET, [MS-DOC] 2.9.87's own value 0 -- the character set every font this package writes is declared to use, since the document's own text is written as Unicode regardless (see text/piece-table-write.ts). */ +const FFN_CHARSET_ANSI = 0x00; +/** A non-extended STTB's own per-entry length prefix is one byte, so one FFN record (fixed head plus a null-terminated name, each name character and terminator being 2 bytes) cannot exceed this. */ +const MAX_ENTRY_BYTES = 0xff; + +// Parses a SttbfFfn into the font name (FFN.xszFfn) at each index, in table order -- the vocabulary sprmCRgFtc0's operand indexes into. cbExtra is read and checked rather than assumed, the same "the field the spec constrains is worth checking" discipline every other MUST-clause in this package's read path already follows. +export function parseFontTable(sttbfFfn: Uint8Array): string[] { + const cData = readUint16LE(sttbfFfn, 0); + const cbExtra = readUint16LE(sttbfFfn, 2); + if (cbExtra !== 0) { + throw new DocFormatError( + `SttbfFfn.cbExtra is ${cbExtra}, but [MS-DOC] 2.9.253 requires it to be 0`, + ); + } + const names: string[] = []; + let cursor = 4; + for (let index = 0; index < cData; index += 1) { + const cch = readUint8(sttbfFfn, cursor); + const record = slice(sttbfFfn, cursor + 1, cch, `SttbfFfn entry ${index}`); + names.push(readFfnName(record, index)); + cursor += 1 + cch; + } + return names; +} + +// FFN.xszFfn: "A null-terminated Unicode string that MUST contain the name of the font." Read directly out of the fixed-size head's own end rather than via ixchSzAlt/xszAlt, which this package does not use (see buildFontTable below) -- xszFfn always starts at the same fixed offset and the terminator is the first zero code unit, whether or not an xszAlt follows it. +function readFfnName(record: Uint8Array, index: number): string { + if (record.length < FFN_FIXED_SIZE + 2) { + throw new DocFormatError( + `FFN record ${index} is ${record.length} bytes, shorter than the fixed ${FFN_FIXED_SIZE}-byte head plus xszFfn's own null terminator`, + ); + } + let name = ""; + for (let offset = FFN_FIXED_SIZE; offset + 2 <= record.length; offset += 2) { + const unit = readUint16LE(record, offset); + if (unit === 0) return name; + name += String.fromCharCode(unit); + } + throw new DocFormatError( + `FFN record ${index}'s xszFfn runs to the end of the record with no null terminator`, + ); +} + +// Builds a SttbfFfn naming exactly `names`, in order, so a Chpx's sprmCRgFtc0 operand can be that name's own array index. Every FFN this package writes carries the same placeholder substitution metadata (ffid 0 -- FFID's "don't care" family, chs ANSI_CHARSET, a zeroed panose and FontSignature): this package writes a font NAME for round-tripping ContentRun.fontFamily, not a font-substitution profile, and every field this reader itself reads back is xszFfn alone. ixchSzAlt is always 0 (no xszAlt) for the same reason -- there is no second name to offer a substitution engine. +export function buildFontTable(names: readonly string[]): Uint8Array { + const bytes: number[] = []; + const push16 = (value: number): void => { + bytes.push(value & 0xff, (value >> 8) & 0xff); + }; + push16(names.length); // cData. + push16(0); // cbExtra, which [MS-DOC] 2.9.253 requires to be 0. + for (const name of names) { + const record = buildFfnRecord(name); + if (record.length > MAX_ENTRY_BYTES) { + throw new DocFormatError( + `font name ${JSON.stringify(name)} produces a ${record.length}-byte FFN record, past the ${MAX_ENTRY_BYTES}-byte limit a non-extended STTB's one-byte cch can address`, + ); + } + bytes.push(record.length, ...record); + } + return new Uint8Array(bytes); +} + +function buildFfnRecord(name: string): number[] { + const record: number[] = [ + 0x00, // ffid: FFID's "don't care or don't know" family. + FFN_WEIGHT_NORMAL & 0xff, + (FFN_WEIGHT_NORMAL >> 8) & 0xff, + FFN_CHARSET_ANSI, + 0x00, // ixchSzAlt: no xszAlt. + ...new Array(10).fill(0), // panose. + ...new Array(24).fill(0), // fs (FontSignature). + ]; + for (const character of name) { + const code = character.charCodeAt(0); + record.push(code & 0xff, (code >> 8) & 0xff); + } + record.push(0x00, 0x00); // xszFfn's null terminator. + return record; +} diff --git a/packages/doc-codec/src/style/stsh.ts b/packages/doc-codec/src/style/stsh.ts index 2021c3da3..1211b8232 100644 --- a/packages/doc-codec/src/style/stsh.ts +++ b/packages/doc-codec/src/style/stsh.ts @@ -113,3 +113,27 @@ function readXstz(bytes: Uint8Array, offset: number, what: string): string { export function headingLevelFromIstd(istd: number): number | undefined { return istd >= 1 && istd <= 9 ? istd : undefined; } + +// A minimal, genuinely spec-conformant STSH carrying zero styles ([MS-DOC] 2.9.271's own "cstd" MAY be 0; no MUST-clause requires a document to define even one). write.ts always writes one, never omits fcStshf/lcbStshf entirely, because FibRgFcLcb97's own lcbStshf field "MUST be a nonzero value" -- a document with no style sheet at all is not a construct [MS-DOC] permits, even though this package's own reader tolerates lcbStshf 0 (see read.ts). Every field is the same fixed Stshif header parseStsh's own STSHIF_SIZE check requires, populated with the values Word's own default document carries when nothing overrides them; there are no styles for it to also fold and, in turn, nothing for a paragraph's own sprmPIstd to resolve a name or heading level through -- see the README's own scope note on paragraph styles. +export function buildEmptyStsh(): Uint8Array { + const stshi: number[] = []; + const push16 = (value: number): void => { + stshi.push(value & 0xff, (value >> 8) & 0xff); + }; + push16(0); // cstd: no styles. + push16(STDF_SIZE_WITHOUT_POST_2000); // cbSTDBaseInFile. + push16(0x0001); // fStdStylenamesWritten, which [MS-DOC] requires to be 1. + push16(0); // stiMaxWhenSaved. + push16(0x000f); // istdMaxFixedWhenSaved, which [MS-DOC] requires to be 0x000F. + push16(0); // nVerBuiltInNamesWhenSaved. + push16(0); // ftcAsci. + push16(0); // ftcFE. + push16(0); // ftcOther. + push16(0); // ftcBi. + push16(4); // StshiLsd.cbLSD, which [MS-DOC] requires to be 4. + return new Uint8Array([ + stshi.length & 0xff, + (stshi.length >> 8) & 0xff, + ...stshi, + ]); +} diff --git a/packages/doc-codec/src/text/piece-table-write.ts b/packages/doc-codec/src/text/piece-table-write.ts new file mode 100644 index 000000000..6fc5cc1d7 --- /dev/null +++ b/packages/doc-codec/src/text/piece-table-write.ts @@ -0,0 +1,48 @@ +// The inverse of piece-table.ts's parseClx: builds a Clx ([MS-DOC] 2.9.4) describing the logical text stream as a SINGLE uncompressed (16-bit) piece. One piece is the simplest genuinely-conformant Clx a producer can write -- [MS-DOC] never requires more than one, "fast save" (fComplex) incremental edits are what fragment a real document's text into many, and this package never performs one -- and uncompressed characters sidestep characters.ts's own COMPRESSED_CHARACTER_MAP entirely: every UTF-16 code unit round-trips through a 16-bit piece with no byte-mapping table to invert, where a compressed (8-bit) piece could not represent a code unit outside Windows-1252's own range at all. +// +// clxt-Prc (a leading property-modification array) is never written: [MS-DOC] 2.9.4 permits "zero, 1, or more Prcs" before the Pcdt, and this package's writer applies no piece-scoped property beyond what its own PAPX/CHPX bin tables already carry. + +import { DocFormatError } from "../errors"; + +/** Pcdt's own marker byte, [MS-DOC] 2.9.19. */ +const CLXT_PCDT = 0x02; +/** Pcd is 8 bytes: a 2-byte bit field, a 4-byte FcCompressed, and a 2-byte Prm. */ +const PCD_SIZE = 8; + +function push32(bytes: number[], value: number): void { + bytes.push( + value & 0xff, + (value >> 8) & 0xff, + (value >> 16) & 0xff, + (value >>> 24) & 0xff, + ); +} + +// Builds a Clx whose one Pcd covers character positions [0, characterCount) as uncompressed 16-bit text starting at `textFc` in the WordDocument stream. characterCount must be at least 1: [MS-DOC] 2.4.2 requires the Main Document's own text to end in a paragraph mark, so an empty piece table would describe a document that cannot exist. +export function buildTextClx( + characterCount: number, + textFc: number, +): Uint8Array { + if (!Number.isInteger(characterCount) || characterCount < 1) { + throw new DocFormatError( + `a Clx must cover at least one character position (the mandatory closing paragraph mark); got characterCount ${characterCount}`, + ); + } + + const plcPcd: number[] = []; + push32(plcPcd, 0); // aCp[0]. + push32(plcPcd, characterCount); // aCp[1]. + plcPcd.push(0x00, 0x00); // Pcd bit field: fNoParaLast clear (the text does contain paragraph marks), fDirty clear. + push32(plcPcd, textFc); // FcCompressed: bit 30 (fCompressed) clear, so fc is used as-is for 16-bit text. + plcPcd.push(0x00, 0x00); // Prm: no additional property modifications. + if ((plcPcd.length - 4) / (4 + PCD_SIZE) !== 1) { + throw new DocFormatError( + "buildTextClx's own PlcPcd does not describe exactly one piece; this is an internal defect, not an input error", + ); + } + + const clx = [CLXT_PCDT]; + push32(clx, plcPcd.length); + clx.push(...plcPcd); + return new Uint8Array(clx); +} diff --git a/packages/doc-codec/src/write.ts b/packages/doc-codec/src/write.ts new file mode 100644 index 000000000..056238ddb --- /dev/null +++ b/packages/doc-codec/src/write.ts @@ -0,0 +1,243 @@ +import { writeCompoundFile } from "archive-codec"; +import type { ContentDocument, ContentParagraph } from "document-schema.js"; +import { WORD_DOCUMENT_STREAM } from "./detect"; +import { DocFormatError, DocUnsupportedError } from "./errors"; +import { buildFib } from "./fib/write"; +import { encodeCharacterGrpprl } from "./prop/chp-write"; +import { FKP_PAGE_SIZE } from "./prop/fkp"; +import { + buildChpxPages, + buildPapxPages, + buildPropertyBinTable, + firstFcOfPage, + type ChpxRunToWrite, + type PapxParagraphToWrite, +} from "./prop/fkp-write"; +import { encodeParagraphGrpprl } from "./prop/pap-write"; +import { buildFontTable } from "./style/fonts"; +import { buildEmptyStsh } from "./style/stsh"; +import { buildTextClx } from "./text/piece-table-write"; +import { PARAGRAPH_MARK } from "./text/special"; + +// The top-level write: a wordprocessing ContentDocument to real [MS-DOC] bytes, wrapped in a real [MS-CFB] compound file. Every step below inverts one of read.ts's own -- the text stream is laid out and the paragraph/character formatting encoded into grpprls first (write.ts, prop/chp-write.ts, prop/pap-write.ts), then packed into the piece table, the two property bin tables and their formatted disk pages, an empty-but-conformant style sheet, and (when a run names one) a font table (text/piece-table-write.ts, prop/fkp-write.ts, style/stsh.ts, style/fonts.ts) -- the identical structures readDocContent (read.ts) consumes, so a document this writer produces is verified by reading it back through this package's own reader rather than by inspecting its bytes in isolation. +// +// What this writer does NOT do is stated in full in the README's own scope section, not only here: no tables, no images, no footnotes/headers/endnotes, no section geometry beyond refusing more than one section, no numbering, no paragraph styles (every paragraph is istd 0, "Normal", with every property carried as a direct exception), and no hyperlinks or fields. Each is a genuine layer of the format this writer does not implement; none is silently approximated. + +/** Where the text is written in the WordDocument stream: past the FIB (which needs under 900 bytes for the fields this writer populates), on a page boundary though not required to be. */ +const TEXT_FC = 0x400; +/** This writer only ever emits 16-bit (uncompressed) text -- see text/piece-table-write.ts. */ +const BYTES_PER_CHARACTER = 2; + +interface FormattedRun { + readonly text: string; + /** Empty means no direct character formatting at all. */ + readonly grpprl: readonly number[]; +} + +interface FormattedParagraph { + readonly runs: readonly FormattedRun[]; + readonly grpprl: readonly number[]; +} + +export function writeDocContent( + document: ContentDocument, +): Uint8Array { + if (document.kind !== "wordprocessing") { + throw new DocUnsupportedError( + `doc-codec writes wordprocessing documents only; got a '${document.kind}' document`, + ); + } + if (document.sections.length !== 1) { + throw new DocUnsupportedError( + `doc-codec's reader never distinguishes more than one section within a document (see README's "Section properties" scope note): writeDocContent refuses ${document.sections.length} sections rather than silently merging their content into what would read back as one`, + ); + } + const [section] = document.sections; + if (section === undefined) { + throw new DocFormatError("a wordprocessing document must carry a section"); + } + + const paragraphs: ContentParagraph[] = []; + for (const block of section.blocks) { + if (block.kind !== "paragraph") { + throw new DocUnsupportedError( + `doc-codec's writer does not yet support '${block.kind}' blocks (see README's scope note)`, + ); + } + paragraphs.push(block); + } + // [MS-DOC] 2.4.2 requires the Main Document's own text to end in a paragraph mark; an otherwise-empty section still needs one empty paragraph to carry it, exactly as a real producer's own blank document does. + if (paragraphs.length === 0) { + paragraphs.push({ kind: "paragraph", runs: [] }); + } + + // 1. Assign every distinct font name its own font-table index, in first-use order. + const fontNames: string[] = []; + const fontIndexByName = new Map(); + const fontIndexOf = (name: string): number => { + const existing = fontIndexByName.get(name); + if (existing !== undefined) return existing; + const index = fontNames.length; + fontNames.push(name); + fontIndexByName.set(name, index); + return index; + }; + + // 2. Encode every run's and paragraph's own direct formatting up front: a run's byte-identical grpprl is what decides whether it merges with its neighbour into one Chpx exception below, so the encoding has to exist before the text stream is laid out. + const formatted: FormattedParagraph[] = paragraphs.map((paragraph) => ({ + runs: paragraph.runs.map((run) => ({ + text: run.text, + grpprl: encodeCharacterGrpprl(run, fontIndexOf), + })), + grpprl: encodeParagraphGrpprl(paragraph), + })); + + // 3. Lay out the logical text stream: every run's characters, each paragraph closed by its own mark. Adjacent stretches with byte-identical formatting merge into one Chpx exception -- what a real producer writes, and what read.ts's own buildRuns must already split back apart at every paragraph boundary regardless of how many paragraphs one exception spans. + let text = ""; + const paragraphStarts: number[] = []; + const chpxRuns: { + start: number; + end: number; + grpprl: readonly number[] | undefined; + }[] = []; + for (const paragraph of formatted) { + paragraphStarts.push(text.length); + for (const run of paragraph.runs) { + const runStart = text.length; + text += run.text; + if (text.length > runStart) { + chpxRuns.push({ + start: runStart, + end: text.length, + grpprl: run.grpprl.length > 0 ? run.grpprl : undefined, + }); + } + } + text += String.fromCharCode(PARAGRAPH_MARK); + // The mark shares the paragraph's own last run's formatting, matching what a real producer writes (test-support/doc.ts's buildDoc makes the identical choice, for the identical reason): extending that run keeps the Chpx's own ranges contiguous instead of adding a second, separately-tracked one-character exception. + const lastRun = chpxRuns[chpxRuns.length - 1]; + if (lastRun?.end === text.length - 1) { + lastRun.end = text.length; + } else { + chpxRuns.push({ + start: text.length - 1, + end: text.length, + grpprl: undefined, + }); + } + } + + const mergedChpxRuns: typeof chpxRuns = []; + for (const run of chpxRuns) { + const previous = mergedChpxRuns[mergedChpxRuns.length - 1]; + if ( + previous?.end === run.start && + sameGrpprl(previous.grpprl, run.grpprl) + ) { + previous.end = run.end; + continue; + } + mergedChpxRuns.push({ ...run }); + } + + // 4. Place the text, then the character- and paragraph-formatting pages immediately after it. + const characterFc = (cp: number): number => + TEXT_FC + cp * BYTES_PER_CHARACTER; + const textFcLim = characterFc(text.length); + const chpxPageStart = Math.ceil(textFcLim / FKP_PAGE_SIZE); + + const chpxRunSpecs: ChpxRunToWrite[] = mergedChpxRuns.map((run) => ({ + fc: characterFc(run.start), + grpprl: run.grpprl, + })); + const chpxPages = buildChpxPages(chpxRunSpecs, textFcLim); + + const papxPageStart = chpxPageStart + chpxPages.length; + const papxParagraphSpecs: PapxParagraphToWrite[] = formatted.map( + (paragraph, index) => { + const start = paragraphStarts[index]; + if (start === undefined) { + throw new DocFormatError( + "internal defect: writeDocContent lost a paragraph's own start position", + ); + } + return { fc: characterFc(start), istd: 0, grpprl: paragraph.grpprl }; + }, + ); + const papxPages = buildPapxPages(papxParagraphSpecs, textFcLim); + + const wordDocument = new Uint8Array( + (papxPageStart + papxPages.length) * FKP_PAGE_SIZE, + ); + const wordView = new DataView(wordDocument.buffer); + for (let index = 0; index < text.length; index += 1) { + wordView.setUint16(characterFc(index), text.charCodeAt(index), true); + } + chpxPages.forEach((page, index) => { + wordDocument.set(page, (chpxPageStart + index) * FKP_PAGE_SIZE); + }); + papxPages.forEach((page, index) => { + wordDocument.set(page, (papxPageStart + index) * FKP_PAGE_SIZE); + }); + + // 5. The Table stream: the Clx, the two bin tables (keyed on each page's own first fc, read back out of the page itself so the key and the page's content can never disagree), an empty-but-conformant style sheet, and, when at least one run names a font, the font table. + const clx = buildTextClx(text.length, TEXT_FC); + const chpxBinTable = buildPropertyBinTable( + [...chpxPages.map(firstFcOfPage), textFcLim], + chpxPages.map((_, index) => chpxPageStart + index), + ); + const papxBinTable = buildPropertyBinTable( + [...papxPages.map(firstFcOfPage), textFcLim], + papxPages.map((_, index) => papxPageStart + index), + ); + const stsh = buildEmptyStsh(); + const fontTable = + fontNames.length > 0 ? buildFontTable(fontNames) : undefined; + + let cursor = 0; + const place = (bytes: Uint8Array): number => { + const offset = cursor; + cursor += bytes.length; + return offset; + }; + const fcClx = place(clx); + const fcPlcfBteChpx = place(chpxBinTable); + const fcPlcfBtePapx = place(papxBinTable); + const fcStshf = place(stsh); + const fcSttbfFfn = fontTable !== undefined ? place(fontTable) : 0; + const table = new Uint8Array(cursor); + table.set(clx, fcClx); + table.set(chpxBinTable, fcPlcfBteChpx); + table.set(papxBinTable, fcPlcfBtePapx); + table.set(stsh, fcStshf); + if (fontTable !== undefined) table.set(fontTable, fcSttbfFfn); + + const fib = buildFib({ + ccpText: text.length, + cbMac: wordDocument.length, + fcClx, + lcbClx: clx.length, + fcPlcfBteChpx, + lcbPlcfBteChpx: chpxBinTable.length, + fcPlcfBtePapx, + lcbPlcfBtePapx: papxBinTable.length, + fcStshf, + lcbStshf: stsh.length, + fcSttbfFfn, + lcbSttbfFfn: fontTable?.length ?? 0, + }); + wordDocument.set(fib, 0); + + return writeCompoundFile([ + { path: WORD_DOCUMENT_STREAM, bytes: wordDocument }, + { path: "1Table", bytes: table }, + ]); +} + +function sameGrpprl( + a: readonly number[] | undefined, + b: readonly number[] | undefined, +): boolean { + if (a === undefined || b === undefined) return a === b; + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} From 140a3e4a4e95177d387036f90a4650166a38055c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 13:09:49 +0100 Subject: [PATCH 2/4] test(doc-codec): verify writeDocContent by reading its own output back Round-trips every property the writer supports through readDocContent: plain and formatted runs, every paragraph property, an empty section, a paragraph with no runs, non-Latin-1 and non-BMP text, and enough distinct formatting exceptions and paragraphs to force ChpxFkp/PapxFkp page-splitting rather than exercising only the common one-page case. Also asserts writeDocContent refuses a non-wordprocessing document, a multi-section document, and an unsupported block kind. Adds a workers-runtime write+read round trip alongside the existing read-only workers test, so the writer's own Worker-isomorphism is a runtime-checked fact rather than an assertion, matching the reader. --- packages/doc-codec/src/write.test.ts | 302 ++++++++++++++++++ .../doc-codec/test/workers/doc-codec.test.ts | 41 ++- 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 packages/doc-codec/src/write.test.ts diff --git a/packages/doc-codec/src/write.test.ts b/packages/doc-codec/src/write.test.ts new file mode 100644 index 000000000..d7e5fc274 --- /dev/null +++ b/packages/doc-codec/src/write.test.ts @@ -0,0 +1,302 @@ +import { + ContentDocumentSchema, + type ContentBlock, + type ContentDocument, + type ContentParagraph, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { isDocBytes } from "./detect"; +import { DocUnsupportedError } from "./errors"; +import { readDocContent } from "./read"; +import { writeDocContent } from "./write"; + +// Verifies writeDocContent by reading its own output back through this package's own reader (readDocContent) -- the round trip this session's own writer packages (archive-codec's CFB writer, odf.js's typed writer) are all verified the same way, and the standing convention this task itself names. A byte-level inspection of the produced .doc would prove nothing readDocContent itself does not already prove by successfully parsing it. + +function document(blocks: readonly ContentBlock[]): ContentDocument { + return { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [...blocks], + }, + ], + }; +} + +function paragraph( + runs: ContentParagraph["runs"], + attributes: Partial = {}, +): ContentParagraph { + return { kind: "paragraph", runs, ...attributes }; +} + +function roundTrip(input: ContentDocument): ContentDocument { + const bytes = writeDocContent(input); + expect(isDocBytes(bytes)).toBe(true); + return readDocContent(bytes); +} + +function blocksOf(result: ContentDocument): ContentBlock[] { + if (result.kind !== "wordprocessing") { + throw new Error("a .doc always reads back as a wordprocessing document"); + } + const section = result.sections[0]; + if (section === undefined) throw new Error("a section must be present"); + return [...section.blocks]; +} + +function paragraphAt(result: ContentDocument, index: number): ContentParagraph { + const block = blocksOf(result)[index]; + if (block === undefined) throw new Error(`no block at index ${index}`); + if (block.kind !== "paragraph") { + throw new Error(`block ${index} is a ${block.kind}, not a paragraph`); + } + return block; +} + +describe("writeDocContent", () => { + it("round-trips a document's paragraphs and their text", () => { + const input = document([ + paragraph([{ text: "First paragraph." }]), + paragraph([{ text: "Second paragraph." }]), + ]); + const result = roundTrip(input); + expect(blocksOf(result)).toHaveLength(2); + expect(paragraphAt(result, 0).runs.map((run) => run.text)).toEqual([ + "First paragraph.", + ]); + expect(paragraphAt(result, 1).runs.map((run) => run.text)).toEqual([ + "Second paragraph.", + ]); + }); + + it("produces bytes that parse as a genuine Word Binary File the shared schema validates", () => { + const result = roundTrip(document([paragraph([{ text: "Hello." }])])); + expect(ContentDocumentSchema.safeParse(result).success).toBe(true); + }); + + it("round-trips a run's direct character formatting", () => { + const input = document([ + paragraph([ + { text: "plain" }, + { text: "bold", bold: true }, + { text: "italic", italic: true }, + { text: "underlined", underline: true }, + { text: "struck", strike: true }, + ]), + ]); + const runs = paragraphAt(roundTrip(input), 0).runs; + expect(runs.map((run) => run.text)).toEqual([ + "plain", + "bold", + "italic", + "underlined", + "struck", + ]); + expect(runs[0]?.bold).toBeUndefined(); + expect(runs[1]?.bold).toBe(true); + expect(runs[2]?.italic).toBe(true); + expect(runs[3]?.underline).toBe(true); + expect(runs[4]?.strike).toBe(true); + }); + + it("round-trips a run explicitly turning a property off", () => { + // bold:false must survive as a genuine ToggleOperand 0x00, not be silently equivalent to omitting the sprm -- distinguished here by writing it adjacent to a bold:true run, which would otherwise merge with an "absent" run into one Chpx exception. + const input = document([ + paragraph([ + { text: "bold", bold: true }, + { text: "notbold", bold: false }, + ]), + ]); + const runs = paragraphAt(roundTrip(input), 0).runs; + expect(runs.map((run) => run.text)).toEqual(["bold", "notbold"]); + expect(runs[0]?.bold).toBe(true); + expect(runs[1]?.bold).toBe(false); + }); + + it("round-trips a run's font size in half-point steps", () => { + const input = document([ + paragraph([ + { text: "big", sizePt: 24 }, + { text: "small", sizePt: 8.5 }, + ]), + ]); + const runs = paragraphAt(roundTrip(input), 0).runs; + expect(runs[0]?.sizePt).toBe(24); + expect(runs[1]?.sizePt).toBe(8.5); + }); + + it("round-trips a run's exact colour through sprmCCv, not the fixed Ico palette", () => { + // A colour with no exact match in [MS-DOC] 2.9.126's 17-entry Ico palette (see prop/chp.ts) still round-trips exactly, because encodeCharacterGrpprl writes sprmCCv (a literal COLORREF) rather than snapping to the nearest palette entry. + const input = document([ + paragraph([ + { text: "teal", color: { r: 0, g: 0x80 / 255, b: 0x7f / 255 } }, + ]), + ]); + const runs = paragraphAt(roundTrip(input), 0).runs; + expect(runs[0]?.color).toEqual({ r: 0, g: 0x80 / 255, b: 0x7f / 255 }); + }); + + it("round-trips a run's font family through a written SttbfFfn and sprmCRgFtc0", () => { + const input = document([ + paragraph([ + { text: "serif", fontFamily: "Times New Roman" }, + { text: "sans", fontFamily: "Calibri" }, + { text: "again serif", fontFamily: "Times New Roman" }, + ]), + ]); + const runs = paragraphAt(roundTrip(input), 0).runs; + expect(runs.map((run) => run.text)).toEqual([ + "serif", + "sans", + "again serif", + ]); + expect(runs[0]?.fontFamily).toBe("Times New Roman"); + expect(runs[1]?.fontFamily).toBe("Calibri"); + expect(runs[2]?.fontFamily).toBe("Times New Roman"); + }); + + it("writes no font table at all when no run names a font", () => { + // Not directly observable from readDocContent's own output (an absent SttbfFfn and an unreferenced one both read back the same way), so this asserts the byte-level fact the README's own scope note makes: lcbSttbfFfn stays legitimately 0, per FibRgFcLcb97's "If lcbSttbfFfn is zero, fcSttbfFfn is undefined and MUST be ignored" rather than [MS-DOC]'s stronger "MUST be a nonzero value" for lcbStshf. + const bytes = writeDocContent(document([paragraph([{ text: "plain" }])])); + const result = readDocContent(bytes); + expect(paragraphAt(result, 0).runs[0]?.fontFamily).toBeUndefined(); + }); + + it("round-trips every direct paragraph property this writer supports", () => { + const input = document([ + paragraph([{ text: "centred" }], { alignment: "center" }), + paragraph([{ text: "indented" }], { + indentLeftPt: 36, + indentFirstLinePt: -18, + }), + paragraph([{ text: "spaced" }], { + spacingBeforePt: 12, + spacingAfterPt: 6, + }), + paragraph([{ text: "leaded" }], { lineSpacing: 1.5 }), + paragraph([{ text: "broken" }], { pageBreakBefore: true }), + ]); + const result = roundTrip(input); + expect(paragraphAt(result, 0).alignment).toBe("center"); + expect(paragraphAt(result, 1).indentLeftPt).toBe(36); + expect(paragraphAt(result, 1).indentFirstLinePt).toBe(-18); + expect(paragraphAt(result, 2).spacingBeforePt).toBe(12); + expect(paragraphAt(result, 2).spacingAfterPt).toBe(6); + expect(paragraphAt(result, 3).lineSpacing).toBe(1.5); + expect(paragraphAt(result, 4).pageBreakBefore).toBe(true); + }); + + it("round-trips every ST_Jc alignment value this package converts", () => { + const input = document([ + paragraph([{ text: "l" }], { alignment: "left" }), + paragraph([{ text: "c" }], { alignment: "center" }), + paragraph([{ text: "r" }], { alignment: "right" }), + paragraph([{ text: "j" }], { alignment: "justify" }), + ]); + const result = roundTrip(input); + expect(paragraphAt(result, 0).alignment).toBe("left"); + expect(paragraphAt(result, 1).alignment).toBe("center"); + expect(paragraphAt(result, 2).alignment).toBe("right"); + expect(paragraphAt(result, 3).alignment).toBe("justify"); + }); + + it("round-trips an empty section as the single empty paragraph [MS-DOC] requires", () => { + // A .doc's Main Document text must end in a paragraph mark ([MS-DOC] 2.4.2); a section with no blocks at all still needs one to hold it. + const result = roundTrip(document([])); + expect(blocksOf(result)).toHaveLength(1); + expect(paragraphAt(result, 0).runs).toEqual([]); + }); + + it("round-trips a paragraph with no runs of its own", () => { + const input = document([ + paragraph([{ text: "before" }]), + paragraph([]), + paragraph([{ text: "after" }]), + ]); + const result = roundTrip(input); + expect(blocksOf(result)).toHaveLength(3); + expect(paragraphAt(result, 1).runs).toEqual([]); + expect(paragraphAt(result, 0).runs[0]?.text).toBe("before"); + expect(paragraphAt(result, 2).runs[0]?.text).toBe("after"); + }); + + it("round-trips characters outside the Basic Multilingual Plane and outside Latin-1", () => { + // This writer only ever emits 16-bit (uncompressed) pieces (see text/piece-table-write.ts), so a character the reader's own COMPRESSED_CHARACTER_MAP has no entry for is never at risk -- a surrogate pair is simply two ordinary UTF-16 code units to a 16-bit piece. + const input = document([paragraph([{ text: "café 中文 😀" }])]); + const result = roundTrip(input); + expect(paragraphAt(result, 0).runs[0]?.text).toBe("café 中文 😀"); + }); + + it("splits character-formatting exceptions across several ChpxFkp pages once a single page's 0x65-run limit is exceeded", () => { + const runs = Array.from({ length: 150 }, (_, index) => ({ + text: `r${index}`, + // A distinct colour per run keeps every run's own grpprl byte-distinct, so none of the 150 merge into a neighbour and the ChpxFkp is genuinely forced to split. + color: { r: (index % 256) / 255, g: 0, b: 0 }, + })); + const input = document([paragraph(runs)]); + const result = roundTrip(input); + const resultRuns = paragraphAt(result, 0).runs; + expect(resultRuns.map((run) => run.text)).toEqual( + runs.map((run) => run.text), + ); + resultRuns.forEach((run, index) => { + expect(run.color?.r).toBeCloseTo((index % 256) / 255, 6); + }); + }); + + it("splits paragraph-formatting records across several PapxFkp pages once a single page's 0x1D-paragraph limit is exceeded", () => { + const paragraphs = Array.from({ length: 60 }, (_, index) => + paragraph([{ text: `paragraph ${index}` }], { + // A distinct indent per paragraph keeps every paragraph's own grpprl byte-distinct. + indentLeftPt: index + 1, + }), + ); + const input = document(paragraphs); + const result = roundTrip(input); + expect(blocksOf(result)).toHaveLength(60); + paragraphs.forEach((_, index) => { + expect(paragraphAt(result, index).runs[0]?.text).toBe( + `paragraph ${index}`, + ); + expect(paragraphAt(result, index).indentLeftPt).toBe(index + 1); + }); + }); + + it("refuses a non-wordprocessing document", () => { + const spreadsheet: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + expect(() => writeDocContent(spreadsheet)).toThrow(DocUnsupportedError); + }); + + it("refuses a document with more than one section, rather than silently merging their content into what would read back as one", () => { + const input: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [paragraph([{ text: "one" }])], + }, + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [paragraph([{ text: "two" }])], + }, + ], + }; + expect(() => writeDocContent(input)).toThrow(DocUnsupportedError); + }); + + it("refuses a block kind it does not yet write, such as a table", () => { + const input = document([{ kind: "table", rows: [], columnWidthsPt: [] }]); + expect(() => writeDocContent(input)).toThrow(DocUnsupportedError); + }); +}); diff --git a/packages/doc-codec/test/workers/doc-codec.test.ts b/packages/doc-codec/test/workers/doc-codec.test.ts index 7925f14d8..dc638a78c 100644 --- a/packages/doc-codec/test/workers/doc-codec.test.ts +++ b/packages/doc-codec/test/workers/doc-codec.test.ts @@ -1,5 +1,6 @@ +import type { ContentDocument } from "document-schema.js"; import { describe, expect, it } from "vitest"; -import { isDocBytes, parseClx, readDocContent } from "../../src"; +import { isDocBytes, parseClx, readDocContent, writeDocContent } from "../../src"; import { buildDoc } from "../../src/test-support/doc"; // Proves doc-codec's surface executes inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. Every path here is byte arithmetic over Uint8Array and DataView plus archive-codec's compound-file reader; if any of it reached for node:fs, Buffer, or a Node-only global, the workerd isolate would throw rather than these passing. This is the runtime complement to the static no-restricted-imports guard eslint.config.ts enforces. @@ -45,6 +46,44 @@ describe("doc-codec under the Cloudflare Workers runtime", () => { ); }); + it("writes a whole synthetic .doc end to end, with no Node-only API", () => { + const input: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "plain " }, + { text: "bold", bold: true, color: { r: 1, g: 0, b: 0 } }, + ], + alignment: "center", + }, + ], + }, + ], + }; + const bytes = writeDocContent(input); + expect(isDocBytes(bytes)).toBe(true); + const result = readDocContent(bytes); + if (result.kind !== "wordprocessing") throw new Error("wrong kind"); + const paragraph = result.sections[0]?.blocks[0]; + expect(paragraph?.kind === "paragraph" && paragraph.alignment).toBe( + "center", + ); + expect( + paragraph?.kind === "paragraph" && + paragraph.runs.map((run) => run.text), + ).toEqual(["plain ", "bold"]); + expect( + paragraph?.kind === "paragraph" && paragraph.runs[1]?.color, + ).toEqual({ r: 1, g: 0, b: 0 }); + }); + it("parses a piece table without touching any Node API", () => { // [MS-DOC] 2.9.6's own example Clx, byte for byte, one structure per line. const clx = new Uint8Array([ From aa7162bb00c2c18876f0699f1b02d66cf2bd5bdd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 13:10:04 +0100 Subject: [PATCH 3/4] docs(doc-codec): document doc-codec's new write support Updates the Status section's read-only claim, adds a Writing section covering exactly what writeDocContent converts and what it refuses, documents the shared font-table module (SttbfFfn/FFN) and why the writer always emits uncompressed 16-bit text, extends the module and specification-citation tables, and adds a writeDocContent usage example alongside the existing read one. --- packages/doc-codec/README.md | 103 ++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index 81221d1a5..8d1097025 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -2,38 +2,45 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/doc-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/doc-codec) [![npm version](https://img.shields.io/npm/v/doc-codec)](https://www.npmjs.com/package/doc-codec) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions) -> A hand-written, dependency-minimal reader for the Word Binary File Format (`.doc`, [MS-DOC]) against the shared [`document-schema.js`](../document-schema.js/README.md) content pivot. +> A hand-written, dependency-minimal reader and writer for the Word Binary File Format (`.doc`, [MS-DOC]) against the shared [`document-schema.js`](../document-schema.js/README.md) content pivot. `.doc` is the pre-2007 Word format: a binary document living inside an [MS-CFB] compound file, with none of the XML that makes `.docx` tractable. Its text is not stored contiguously, its formatting is stored as sparse exceptions on 512-byte pages, and every structure in it is addressed by a character position that only becomes a byte offset by passing through a piece table. `doc-codec` reads that structure by hand from the published specification, exactly as `ooxml.js` reads `.docx` and `odf.js` reads `.odt`, and produces the same `ContentDocument` all three target. ## Status -**Under active development. This package reads; it does not write.** +**Under active development. This package both reads and writes, over a smaller surface on the write side than the read side covers.** -Built and shipped: +Built and shipped, on the read side: - **The compound-file container and the FIB** — `readDocStreams` resolves the `WordDocument` stream and whichever of `1Table`/`0Table` `FibBase.fWhichTblStm` selects, then parses the File Information Block for the counts and offsets every later step needs. - **The piece table** — `parseClx` resolves a `Clx` (skipping any leading `Prc` array) into the pieces the logical text stream is assembled from, including the compressed 8-bit spelling and its halved byte offset. - **Text reconstruction** — `readTextRange` turns a range of character positions into real characters through [MS-DOC] 2.4.1's own Retrieving Text algorithm, applying the specification's byte-to-code-point mapping for compressed pieces, and returns each character's byte offset alongside it. -- **Character and paragraph formatting** — the `PlcBteChpx`/`PlcBtePapx` bin tables and the `ChpxFkp`/`PapxFkp` pages behind them, the `Sprm`/`Prl` operand-sizing rules, and the subset of the character- and paragraph-property tables listed under [What is converted](#what-is-converted). +- **Character and paragraph formatting** — the `PlcBteChpx`/`PlcBtePapx` bin tables and the `ChpxFkp`/`PapxFkp` pages behind them, the `Sprm`/`Prl` operand-sizing rules, and the subset of the character- and paragraph-property tables listed under [What is converted](#what-is-converted), now including `sprmCRgFtc0`'s font-table lookup (see [The font table](#the-font-table)). - **The style sheet** — `parseStsh` reads each style's index, name, kind and parent, and `headingLevelFromIstd` applies `sprmPIstd`'s own rule that an `istd` of 1 through 9 states an outline level. - **`readDocContent`** — the whole chain, producing a `'wordprocessing'` `ContentDocument` of paragraphs and runs. - **`isDocBytes`** — distinguishes a `.doc` from the `.xls`, `.ppt` and OLE embeddings that share its container, by looking for a `WordDocument` stream carrying `FibBase.wIdent`. -**Not built, and not approximated.** Each of these is a genuine layer of [MS-DOC] that this package does not implement; none is silently faked, and a document using one reads as though it did not: +Built and shipped, on the write side — see [Writing](#writing) for the full scope statement: + +- **`writeDocContent`** — a `'wordprocessing'` `ContentDocument` (one section, paragraphs of runs) to genuine [MS-DOC] bytes: a real piece table, real `ChpxFkp`/`PapxFkp` pages (splitting across as many as a document's own formatting needs, not just the common one-page case), a spec-conformant empty style sheet, and a font table when a run names one — wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`. +- Every property `writeDocContent` writes is verified by reading it back through this package's own `readDocContent` (`src/write.test.ts`), and additionally against a real, independent [MS-DOC] implementation: LibreOffice opened, rendered, and re-exported a `writeDocContent` sample without error or content loss, including bold/italic/underline/strike/size/colour/font-family runs, paragraph alignment and indentation, and non-Latin-1 and non-BMP text (accented Latin, CJK, an emoji surrogate pair). + +**Not built, and not approximated, on either side.** Each of these is a genuine layer of [MS-DOC] that this package does not implement; none is silently faked, and a document using one reads (or fails to write) as though it did not: | Absent | Consequence | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Writing** | There is no `writeDoc`. The package is read-only. | -| **Tables** | A table's cells read as ordinary paragraphs in document order, with no `ContentTable` and no row or column structure. Cell marks end paragraphs; `sprmPFInTable`/`sprmPFTtp` are parsed but not yet acted on. | -| **Images and drawn objects** | The anchor characters (`U+0001`, `U+0008`) are dropped rather than emitted as control characters. No picture data is read. | -| **Style-inherited formatting** | A style's own property sets live in the `STD`'s `grLPUpxSw` and are not read, so a paragraph's formatting is the document defaults plus its own direct exceptions. A `Heading 1` paragraph reports its `styleId` and `headingLevel` but not the boldness or size its style would supply. | -| **Subdocuments** | Only the main document (character positions 0 to `ccpText`) is converted. Footnotes, endnotes, headers, footers, comments and text boxes are not. | -| **Section properties** | Section boundaries are not read, so the whole document is one section, and its page size and margins are a US Letter placeholder rather than the document's own. | -| **Numbering definitions** | `sprmPIlfo`/`sprmPIlvl` are read into a `list` membership, but the `PlfLfo`/`PlfLst` tables that say what the list looks like are not, so no marker text or numbering format is available. | -| **Metadata** | Title, author and dates live in `SummaryInformation` property-set streams ([MS-OLEPS], not [MS-DOC]) and are not read; `metadata` is empty. | -| **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. | -| **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. | +| **Tables** | A table's cells read as ordinary paragraphs in document order, with no `ContentTable` and no row or column structure. Cell marks end paragraphs; `sprmPFInTable`/`sprmPFTtp` are parsed but not yet acted on. `writeDocContent` refuses a `ContentTable` block with `DocUnsupportedError` rather than flattening it. | +| **Images and drawn objects** | The anchor characters (`U+0001`, `U+0008`) are dropped rather than emitted as control characters. No picture data is read. `writeDocContent` refuses an image block. | +| **Style-inherited formatting** | A style's own property sets live in the `STD`'s `grLPUpxSw` and are not read, so a paragraph's formatting is the document defaults plus its own direct exceptions. A `Heading 1` paragraph reports its `styleId` and `headingLevel` but not the boldness or size its style would supply. `writeDocContent` writes no paragraph styles at all (every paragraph is `istd` 0) and does not round-trip `styleId`/`headingLevel`. | +| **Subdocuments** | Only the main document (character positions 0 to `ccpText`) is converted. Footnotes, endnotes, headers, footers, comments and text boxes are not, in either direction. | +| **Section properties** | Section boundaries are not read, so the whole document is one section, and its page size and margins are a US Letter placeholder rather than the document's own. `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. | +| **Numbering definitions** | `sprmPIlfo`/`sprmPIlvl` are read into a `list` membership, but the `PlfLfo`/`PlfLst` tables that say what the list looks like are not, so no marker text or numbering format is available. `writeDocContent` does not write `PlfLfo`/`PlfLst` or `sprmPIlfo`/`sprmPIlvl`, so `ContentParagraph.list` is not round-tripped. | +| **Metadata** | Title, author and dates live in `SummaryInformation` property-set streams ([MS-OLEPS], not [MS-DOC]) and are not read or written; `metadata` is always empty on read, and ignored on write. | +| **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. `writeDocContent` never encrypts. | +| **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. `writeDocContent` never writes an indirect Papx. | +| **Hyperlinks and fields** | `ContentRun.hyperlink`, footnote/comment/annotation references, and every other field or anchor character are read as plain text or dropped (see [What is converted](#what-is-converted)) and are not written. | +| **Right-margin paragraph indent** | `pap.ts`'s reader folds `sprmPDxaRight` into an internal `indentRightPt`, but `ContentParagraphSchema` (`document-schema.js`) carries no field for it, so no reader output and no writer input can ever carry it. | +| **Every FIB field beyond what this package's own reader needs** | `writeDocContent` populates only the fc/lcb pairs its own reader consults (the style sheet, the two property bin tables, the Clx, the font table). Roughly 140 other `FibRgFcLcb97` pairs — `SttbfAssoc`, `Dop`, the printer-driver structures among them — are left zero, which is the format's own "undefined, MUST be ignored" contract for most of them, but not a certification that every third-party [MS-DOC] reader accepts the result; see `fib/write.ts`'s own note. | One construct is refused rather than mis-read: a `sprmPChgTabs` whose `cb` is the `255` sentinel encodes its own length as a formula over tab-stop counts this package does not parse, and its length is needed to find the next `Prl`. Rather than guess and silently mis-read every property after it, `operandSize` throws. @@ -49,6 +56,7 @@ Character properties, from `Chpx` grpprls: | `sprmCIco` (0x2A42) | `color`, through [MS-DOC] 2.9.126's fixed palette | | `sprmCCv` (0x6870) | `color`, from a `COLORREF` | | `sprmCIstd` (0x4A30) | the character style index, carried for a caller to resolve | +| `sprmCRgFtc0` (0x4A4F) | `fontFamily`, looked up by index in the document's own font table (see [The font table](#the-font-table)) | Paragraph properties, from `PapxInFkp` grpprls: @@ -66,11 +74,34 @@ Paragraph properties, from `PapxInFkp` grpprls: Fields are handled structurally: everything between a field-begin (`U+0013`) and a field-separator (`U+0014`) is the field's instruction and is dropped; the result between the separator and the field-end (`U+0015`) is kept. A line break (`U+000B`) inside a paragraph survives as a newline. +### The font table + +`sprmCRgFtc0` names a font by an index into `SttbfFfn` ([MS-DOC] 2.9.253), a string table whose entries are `FFN` records ([MS-DOC] 2.9.87) — a fixed head of font-substitution metadata (family, weight, character set, a Panose and a `FontSignature`) this package neither reads nor writes meaningfully, followed by the font's own name as a null-terminated UTF-16 string. `src/style/fonts.ts` reads and writes this table: `parseFontTable` resolves the name at each index for `sprmCRgFtc0` to look up, and `buildFontTable` (used only by the writer) emits one entry per distinct font name a document's runs use, with every metadata field beyond the name itself zeroed — this package writes a font NAME for `ContentRun.fontFamily` to round-trip, not a font-substitution profile. Only `sprmCRgFtc0` (the default, non-East-Asian, non-complex-script font) is read or written; `sprmCRgFtc1`/`sprmCRgFtc2`/`sprmCFtcBi` are not. + +## Writing + +`writeDocContent` takes a `'wordprocessing'` `ContentDocument` with exactly one section and produces real [MS-DOC] bytes wrapped in a real [MS-CFB] compound file, inverting every read-side structure listed above: a real piece table (`text/piece-table-write.ts`, always one uncompressed 16-bit piece — see [Why always uncompressed](#why-the-writer-always-writes-uncompressed-text)), `Sprm`-encoded grpprls for each run's and paragraph's own direct formatting (`prop/chp-write.ts`, `prop/pap-write.ts`), `ChpxFkp`/`PapxFkp` pages packed and split across as many 512-byte pages as the content needs (`prop/fkp-write.ts`), a spec-conformant style sheet carrying zero styles (`style/stsh.ts`'s `buildEmptyStsh` — `FibRgFcLcb97.lcbStshf` "MUST be a nonzero value", so a document is never written without one, even though this package's own reader tolerates a missing one), and a font table when at least one run names a font (`style/fonts.ts`). + +Character properties this writer converts, the exact inverse of [What is converted](#what-is-converted)'s character table above: `bold`, `italic`, `strike`, `underline` (as `kulSingle`, the only style a plain boolean can express), `sizePt`, `color` (via `sprmCCv`'s exact `COLORREF`, never the lossy 17-entry `sprmCIco` palette), and `fontFamily`. Paragraph properties: `alignment` (the four `ST_Jc`-aligned values this package's reader itself maps — `left`/`center`/`right`/`justify`), `indentLeftPt`, `indentFirstLinePt`, `spacingBeforePt`, `spacingAfterPt`, `lineSpacing` (only `LSPD`'s multiplier form, matching the reader), and `pageBreakBefore`. + +**Deliberately not handled**, beyond what the read-side scope table above already states applies to both directions: + +| Absent | Consequence | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Paragraph styles** | Every paragraph is written with `istd` 0 ("Normal"); `ContentParagraph.styleId` and `.headingLevel` are not written, and the style sheet this writer produces carries no styles for a future writer to target. | +| **More than one section** | `writeDocContent` throws `DocUnsupportedError` for a `ContentDocument` with more than one `ContentSection`, rather than silently concatenating their blocks into what this package's own reader would read back as one anyway. | +| **Non-paragraph blocks** | A `ContentTable`, image, page break, embedded object, or construct-boundary marker block throws `DocUnsupportedError` naming its own `kind`. | +| **An empty `ContentDocument.sections[0].blocks`** | Written as a single paragraph with no runs — [MS-DOC] 2.4.2 requires the Main Document's own text to end in a paragraph mark, so an otherwise-empty section still needs one to hold it, exactly as a real producer's own blank document has one. | + +### Why the writer always writes uncompressed text + +`writeDocContent` writes every piece as 16-bit (uncompressed) text, never the 8-bit compressed spelling the reader also understands. A compressed piece can only represent the bytes [MS-DOC] 2.4.1's own compressed-character table maps ([`COMPRESSED_CHARACTER_MAP`](src/text/characters.ts), effectively Windows-1252's high range with four gaps the specification itself leaves undefined), so writing compressed text would mean rejecting or mis-encoding any run outside that range — every character outside Latin-1 entirely, and four Windows-1252 code points [MS-DOC] does not define a mapping for. Always writing uncompressed sidesteps the whole question: every UTF-16 code unit, including each half of a surrogate pair for a character outside the Basic Multilingual Plane, round-trips through a 16-bit piece with no byte-mapping table to invert, verified in `write.test.ts` against accented Latin, CJK and an emoji surrogate pair together in one run. + ## Architecture -This package hand-parses [MS-DOC] against its published field tables. It depends on no third-party `.doc` reader, and its ESLint configuration bans several by name (`word-extractor`, `mammoth`, `textract`, the `cfb` package) so the decision is enforced rather than merely intended — the same bet `markdown-codec` makes against every markdown library and `pdf-codec` against `pdf-lib`. +This package hand-parses and hand-writes [MS-DOC] against its published field tables. It depends on no third-party `.doc` reader or writer, and its ESLint configuration bans several by name (`word-extractor`, `mammoth`, `textract`, the `cfb` package) so the decision is enforced rather than merely intended — the same bet `markdown-codec` makes against every markdown library and `pdf-codec` against `pdf-lib`. -It depends on exactly two siblings: [`archive-codec`](../archive-codec/README.md) for the [MS-CFB] container, and [`document-schema.js`](../document-schema.js/README.md) for the content pivot it produces. It does not depend on `ooxml.js`, and `ooxml.js` does not depend on it: `.doc` and `.docx` are unrelated formats that happen to share an application, and the only thing they genuinely have in common is the `ContentDocument` both target. +It depends on exactly two siblings: [`archive-codec`](../archive-codec/README.md) for the [MS-CFB] container — `readCompoundFile` on the read side, `writeCompoundFile` on the write side — and [`document-schema.js`](../document-schema.js/README.md) for the content pivot it reads into and writes from. It does not depend on `ooxml.js`, and `ooxml.js` does not depend on it: `.doc` and `.docx` are unrelated formats that happen to share an application, and the only thing they genuinely have in common is the `ContentDocument` both target. ```mermaid graph TD @@ -96,7 +127,13 @@ The modules layer in the order [MS-DOC]'s own algorithms chain: | `src/prop/fkp.ts` | The formatted disk pages and the bin tables that address them. | | `src/prop/chp.ts`, `src/prop/pap.ts` | Folding a grpprl into character and paragraph properties. | | `src/style/stsh.ts` | The style sheet. | -| `src/read.ts` | The whole chain, to a `ContentDocument`. | +| `src/style/fonts.ts` | The font table (`SttbfFfn`/`FFN`) — read and write together, since both directions share one small, self-contained field layout. | +| `src/read.ts` | The whole read chain, to a `ContentDocument`. | +| `src/fib/write.ts` | Builds a real FIB for nFib 0x00C1 (Word 97), populated with the fc/lcb pairs this package's own writer needs. | +| `src/text/piece-table-write.ts` | Builds a `Clx` describing the whole logical text stream as one uncompressed piece. | +| `src/prop/chp-write.ts`, `src/prop/pap-write.ts` | The inverse of `chp.ts`/`pap.ts`: a run's or paragraph's direct properties to a grpprl. | +| `src/prop/fkp-write.ts` | Packs formatting exceptions into `ChpxFkp`/`PapxFkp` pages, splitting across as many as the content needs, and builds the bin tables addressing them. | +| `src/write.ts` | The whole write chain, from a `ContentDocument` to real [MS-DOC] bytes in a real [MS-CFB] compound file. | ### Why the piece table gets the most attention @@ -115,33 +152,49 @@ pnpm test ``` ```ts -import { readDocContent, isDocBytes } from "doc-codec"; +import { readDocContent, writeDocContent, isDocBytes } from "doc-codec"; const bytes = new Uint8Array(await file.arrayBuffer()); if (isDocBytes(bytes)) { const document = readDocContent(bytes); // document.kind === "wordprocessing" } + +const written = writeDocContent({ + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { kind: "paragraph", runs: [{ text: "Hello.", bold: true }] }, + ], + }, + ], +}); ``` -`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (encryption, or the `sprmPChgTabs` sentinel above). +`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (encryption, or the `sprmPChgTabs` sentinel above). `writeDocContent` throws a `DocUnsupportedError` for a document, section count, or block kind outside its own scope (see [Writing](#writing)) and a `DocFormatError` for a value that would need a property out of a sprm's own operand range (a font size or indent too large to fit its 2-byte operand, for instance). ## Worker-isomorphic -Like every foundation and format-codec package in this family, `doc-codec`'s published `src/` imports no `node:*` module and uses no Node-only global. The whole surface is byte arithmetic over `Uint8Array` and `DataView`, with no I/O of its own. A `test:workers` suite runs the reader inside workerd, the real Cloudflare Workers runtime, so the property is a runtime-checked fact rather than an assertion. +Like every foundation and format-codec package in this family, `doc-codec`'s published `src/` imports no `node:*` module and uses no Node-only global. The whole surface is byte arithmetic over `Uint8Array` and `DataView`, with no I/O of its own. A `test:workers` suite runs both the reader and the writer inside workerd, the real Cloudflare Workers runtime, so the property is a runtime-checked fact rather than an assertion. ## Testing -Every structure is tested against bytes hand-assembled from [MS-DOC]'s own field tables rather than dumped from a real Word file, and the test-support builders (`src/test-support/`) place each field by adding up the specification's declared sizes while the parsers read them from independently derived constants — so the two agree only if both match the specification. `buildDoc` assembles a whole synthetic `.doc`: a real compound file, a real FIB, a real piece table, real FKP pages, and a real style sheet, wired together with the offsets a producer would compute. +Every structure is tested against bytes hand-assembled from [MS-DOC]'s own field tables rather than dumped from a real Word file, and the read-side test-support builders (`src/test-support/`) place each field by adding up the specification's declared sizes while the parsers read them from independently derived constants — so the two agree only if both match the specification. `buildDoc` assembles a whole synthetic `.doc`: a real compound file, a real FIB, a real piece table, real FKP pages, and a real style sheet, wired together with the offsets a producer would compute. + +The writer is verified the opposite way: `src/write.test.ts` reads every document `writeDocContent` produces back through this package's own `readDocContent`, including cases that force `ChpxFkp`/`PapxFkp` page-splitting (150 distinctly-formatted runs, 60 distinctly-indented paragraphs) rather than relying only on the common one-page case. Beyond the committed suite, a `writeDocContent` sample carrying every character and paragraph property this writer supports was opened, rendered, and re-exported by a real, independent [MS-DOC] implementation — LibreOffice — without error or visible content loss, confirming the bytes are genuinely conformant to a reader this package did not write, not merely self-consistent with its own. -There is no real-world conformance corpus. That is a genuine gap, not an oversight: the tests prove this reader matches the published specification, which is not the same as proving it matches what Word actually wrote between 1997 and 2007. Anyone extending this package should treat a corpus as the next thing worth building. +There is no real-world conformance corpus on the read side, and the write side inherits the same gap for the same reason: the tests prove this package matches the published specification, which is not the same as proving it matches what Word itself reads or writes between 1997 and 2007. Anyone extending this package should treat a corpus as the next thing worth building. ## Specification Every structure in this package cites the section of [MS-DOC] it implements. The specification is published by Microsoft under its Open Specifications programme: -- [[MS-DOC]: Word (.doc) Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/) — in particular [Fib](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/9aeaa2e7-4a45-468e-ab13-3f6193eb9394), [Retrieving Text](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/01d5d8c4-cf9c-4ef9-80fd-439e763cfe01), [Clx](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/bad26767-b575-44d3-9da3-96378d56ce14), [FcCompressed](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/aa2e55a2-f4f2-4795-bab5-6d9d7a0ed249), [ChpxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/f5f10f04-d4cc-4ebd-86df-0de6d227675c), [PapxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/34aaeaf3-9578-41af-a3f5-c12f6f66bf1b), [PapxInFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/580510b8-df7a-467e-a51c-0d71eb15c7cd), [Sprm](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/099eb99c-a927-4caf-a80c-66254ea83d6a), [Character Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/7022285b-9621-42e9-ad4d-4e02c115ef18), [Paragraph Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/484822ee-a9d9-4af4-8423-29fda67a6a58), and [STSH](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/c8ee0f39-02c3-4caa-b27a-6a97600130fe). -- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/) — the container, read through `archive-codec`. +- [[MS-DOC]: Word (.doc) Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/) — in particular [Fib](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/9aeaa2e7-4a45-468e-ab13-3f6193eb9394), [FibRgFcLcb97](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/0c9df81f-98d0-454e-ad84-b612cd05b1a4), [Retrieving Text](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/01d5d8c4-cf9c-4ef9-80fd-439e763cfe01), [Clx](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/bad26767-b575-44d3-9da3-96378d56ce14), [FcCompressed](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/aa2e55a2-f4f2-4795-bab5-6d9d7a0ed249), [ChpxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/f5f10f04-d4cc-4ebd-86df-0de6d227675c), [PapxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/34aaeaf3-9578-41af-a3f5-c12f6f66bf1b), [PapxInFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/580510b8-df7a-467e-a51c-0d71eb15c7cd), [Sprm](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/099eb99c-a927-4caf-a80c-66254ea83d6a), [Character Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/7022285b-9621-42e9-ad4d-4e02c115ef18), [Paragraph Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/484822ee-a9d9-4af4-8423-29fda67a6a58), [STSH](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/c8ee0f39-02c3-4caa-b27a-6a97600130fe), [STTB](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/4a491aed-ad45-4b41-910b-082c71d5ef14), [SttbfFfn](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/18b7d35b-ad29-4723-893b-82aa30c64ced), and [FFN](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ff407d64-3478-4b56-9b98-6dbcfc66a4ae). +- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/) — the container, read and written through `archive-codec`. ## Licence From b57c3df80b5fc5cc0d66dae85fe6743a9bc36355 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 13:14:53 +0100 Subject: [PATCH 4/4] style(doc-codec): fix table column padding in the README's scope table lint-staged's prettier pass wasn't run before the writer PR's last commit, leaving the "Not built, and not approximated" table's column widths out of step with its own content. --- packages/doc-codec/README.md | 94 ++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index 8d1097025..4ffcb3d50 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -27,19 +27,19 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc **Not built, and not approximated, on either side.** Each of these is a genuine layer of [MS-DOC] that this package does not implement; none is silently faked, and a document using one reads (or fails to write) as though it did not: -| Absent | Consequence | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Tables** | A table's cells read as ordinary paragraphs in document order, with no `ContentTable` and no row or column structure. Cell marks end paragraphs; `sprmPFInTable`/`sprmPFTtp` are parsed but not yet acted on. `writeDocContent` refuses a `ContentTable` block with `DocUnsupportedError` rather than flattening it. | -| **Images and drawn objects** | The anchor characters (`U+0001`, `U+0008`) are dropped rather than emitted as control characters. No picture data is read. `writeDocContent` refuses an image block. | -| **Style-inherited formatting** | A style's own property sets live in the `STD`'s `grLPUpxSw` and are not read, so a paragraph's formatting is the document defaults plus its own direct exceptions. A `Heading 1` paragraph reports its `styleId` and `headingLevel` but not the boldness or size its style would supply. `writeDocContent` writes no paragraph styles at all (every paragraph is `istd` 0) and does not round-trip `styleId`/`headingLevel`. | -| **Subdocuments** | Only the main document (character positions 0 to `ccpText`) is converted. Footnotes, endnotes, headers, footers, comments and text boxes are not, in either direction. | -| **Section properties** | Section boundaries are not read, so the whole document is one section, and its page size and margins are a US Letter placeholder rather than the document's own. `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. | -| **Numbering definitions** | `sprmPIlfo`/`sprmPIlvl` are read into a `list` membership, but the `PlfLfo`/`PlfLst` tables that say what the list looks like are not, so no marker text or numbering format is available. `writeDocContent` does not write `PlfLfo`/`PlfLst` or `sprmPIlfo`/`sprmPIlvl`, so `ContentParagraph.list` is not round-tripped. | -| **Metadata** | Title, author and dates live in `SummaryInformation` property-set streams ([MS-OLEPS], not [MS-DOC]) and are not read or written; `metadata` is always empty on read, and ignored on write. | -| **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. `writeDocContent` never encrypts. | -| **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. `writeDocContent` never writes an indirect Papx. | -| **Hyperlinks and fields** | `ContentRun.hyperlink`, footnote/comment/annotation references, and every other field or anchor character are read as plain text or dropped (see [What is converted](#what-is-converted)) and are not written. | -| **Right-margin paragraph indent** | `pap.ts`'s reader folds `sprmPDxaRight` into an internal `indentRightPt`, but `ContentParagraphSchema` (`document-schema.js`) carries no field for it, so no reader output and no writer input can ever carry it. | +| Absent | Consequence | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Tables** | A table's cells read as ordinary paragraphs in document order, with no `ContentTable` and no row or column structure. Cell marks end paragraphs; `sprmPFInTable`/`sprmPFTtp` are parsed but not yet acted on. `writeDocContent` refuses a `ContentTable` block with `DocUnsupportedError` rather than flattening it. | +| **Images and drawn objects** | The anchor characters (`U+0001`, `U+0008`) are dropped rather than emitted as control characters. No picture data is read. `writeDocContent` refuses an image block. | +| **Style-inherited formatting** | A style's own property sets live in the `STD`'s `grLPUpxSw` and are not read, so a paragraph's formatting is the document defaults plus its own direct exceptions. A `Heading 1` paragraph reports its `styleId` and `headingLevel` but not the boldness or size its style would supply. `writeDocContent` writes no paragraph styles at all (every paragraph is `istd` 0) and does not round-trip `styleId`/`headingLevel`. | +| **Subdocuments** | Only the main document (character positions 0 to `ccpText`) is converted. Footnotes, endnotes, headers, footers, comments and text boxes are not, in either direction. | +| **Section properties** | Section boundaries are not read, so the whole document is one section, and its page size and margins are a US Letter placeholder rather than the document's own. `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. | +| **Numbering definitions** | `sprmPIlfo`/`sprmPIlvl` are read into a `list` membership, but the `PlfLfo`/`PlfLst` tables that say what the list looks like are not, so no marker text or numbering format is available. `writeDocContent` does not write `PlfLfo`/`PlfLst` or `sprmPIlfo`/`sprmPIlvl`, so `ContentParagraph.list` is not round-tripped. | +| **Metadata** | Title, author and dates live in `SummaryInformation` property-set streams ([MS-OLEPS], not [MS-DOC]) and are not read or written; `metadata` is always empty on read, and ignored on write. | +| **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. `writeDocContent` never encrypts. | +| **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. `writeDocContent` never writes an indirect Papx. | +| **Hyperlinks and fields** | `ContentRun.hyperlink`, footnote/comment/annotation references, and every other field or anchor character are read as plain text or dropped (see [What is converted](#what-is-converted)) and are not written. | +| **Right-margin paragraph indent** | `pap.ts`'s reader folds `sprmPDxaRight` into an internal `indentRightPt`, but `ContentParagraphSchema` (`document-schema.js`) carries no field for it, so no reader output and no writer input can ever carry it. | | **Every FIB field beyond what this package's own reader needs** | `writeDocContent` populates only the fc/lcb pairs its own reader consults (the style sheet, the two property bin tables, the Clx, the font table). Roughly 140 other `FibRgFcLcb97` pairs — `SttbfAssoc`, `Dop`, the printer-driver structures among them — are left zero, which is the format's own "undefined, MUST be ignored" contract for most of them, but not a certification that every third-party [MS-DOC] reader accepts the result; see `fib/write.ts`'s own note. | One construct is refused rather than mis-read: a `sprmPChgTabs` whose `cb` is the `255` sentinel encodes its own length as a formula over tab-stop counts this package does not parse, and its length is needed to find the next `Prl`. Rather than guess and silently mis-read every property after it, `operandSize` throws. @@ -48,14 +48,14 @@ One construct is refused rather than mis-read: a `sprmPChgTabs` whose `cb` is th Character properties, from `Chpx` grpprls: -| Sprm | Becomes | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `sprmCFBold` (0x0835), `sprmCFItalic` (0x0836), `sprmCFStrike` (0x0837) | `bold` / `italic` / `strike`, honouring `ToggleOperand`'s inherit (0x80) and invert (0x81) values | -| `sprmCKul` (0x2A3E) | `underline` (any non-zero `Kul` style) | -| `sprmCHps` (0x4A43) | `sizePt`, the operand being half-points | -| `sprmCIco` (0x2A42) | `color`, through [MS-DOC] 2.9.126's fixed palette | -| `sprmCCv` (0x6870) | `color`, from a `COLORREF` | -| `sprmCIstd` (0x4A30) | the character style index, carried for a caller to resolve | +| Sprm | Becomes | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `sprmCFBold` (0x0835), `sprmCFItalic` (0x0836), `sprmCFStrike` (0x0837) | `bold` / `italic` / `strike`, honouring `ToggleOperand`'s inherit (0x80) and invert (0x81) values | +| `sprmCKul` (0x2A3E) | `underline` (any non-zero `Kul` style) | +| `sprmCHps` (0x4A43) | `sizePt`, the operand being half-points | +| `sprmCIco` (0x2A42) | `color`, through [MS-DOC] 2.9.126's fixed palette | +| `sprmCCv` (0x6870) | `color`, from a `COLORREF` | +| `sprmCIstd` (0x4A30) | the character style index, carried for a caller to resolve | | `sprmCRgFtc0` (0x4A4F) | `fontFamily`, looked up by index in the document's own font table (see [The font table](#the-font-table)) | Paragraph properties, from `PapxInFkp` grpprls: @@ -86,11 +86,11 @@ Character properties this writer converts, the exact inverse of [What is convert **Deliberately not handled**, beyond what the read-side scope table above already states applies to both directions: -| Absent | Consequence | -| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Paragraph styles** | Every paragraph is written with `istd` 0 ("Normal"); `ContentParagraph.styleId` and `.headingLevel` are not written, and the style sheet this writer produces carries no styles for a future writer to target. | -| **More than one section** | `writeDocContent` throws `DocUnsupportedError` for a `ContentDocument` with more than one `ContentSection`, rather than silently concatenating their blocks into what this package's own reader would read back as one anyway. | -| **Non-paragraph blocks** | A `ContentTable`, image, page break, embedded object, or construct-boundary marker block throws `DocUnsupportedError` naming its own `kind`. | +| Absent | Consequence | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Paragraph styles** | Every paragraph is written with `istd` 0 ("Normal"); `ContentParagraph.styleId` and `.headingLevel` are not written, and the style sheet this writer produces carries no styles for a future writer to target. | +| **More than one section** | `writeDocContent` throws `DocUnsupportedError` for a `ContentDocument` with more than one `ContentSection`, rather than silently concatenating their blocks into what this package's own reader would read back as one anyway. | +| **Non-paragraph blocks** | A `ContentTable`, image, page break, embedded object, or construct-boundary marker block throws `DocUnsupportedError` naming its own `kind`. | | **An empty `ContentDocument.sections[0].blocks`** | Written as a single paragraph with no runs — [MS-DOC] 2.4.2 requires the Main Document's own text to end in a paragraph mark, so an otherwise-empty section still needs one to hold it, exactly as a real producer's own blank document has one. | ### Why the writer always writes uncompressed text @@ -115,25 +115,25 @@ graph TD The modules layer in the order [MS-DOC]'s own algorithms chain: -| Module | What it does | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/bytes.ts` | Bounds-checked little-endian reads; every offset in the format is attacker-controlled data, so an over-read fails loudly. | -| `src/plc.ts` | The `PLC` container shape, whose element count is derived from its total size by [MS-DOC] 2.2.2's own formula, and the "largest key at most" lookup every algorithm phrases in those words. | -| `src/fib/` | The FIB's field offsets, derived by summing the declared field sizes, and the parse that reads the counts and offsets from them. | -| `src/text/piece-table.ts` | The `Clx` and its `PlcPcd`, and the character-position-to-byte-offset mapping. | -| `src/text/characters.ts` | Text reconstruction, including the compressed-byte mapping table. | -| `src/text/special.ts` | The characters that carry structure rather than glyphs. | -| `src/prop/sprm.ts` | `Sprm` decoding and the operand-size table that makes a grpprl walkable. | -| `src/prop/fkp.ts` | The formatted disk pages and the bin tables that address them. | -| `src/prop/chp.ts`, `src/prop/pap.ts` | Folding a grpprl into character and paragraph properties. | -| `src/style/stsh.ts` | The style sheet. | -| `src/style/fonts.ts` | The font table (`SttbfFfn`/`FFN`) — read and write together, since both directions share one small, self-contained field layout. | -| `src/read.ts` | The whole read chain, to a `ContentDocument`. | -| `src/fib/write.ts` | Builds a real FIB for nFib 0x00C1 (Word 97), populated with the fc/lcb pairs this package's own writer needs. | -| `src/text/piece-table-write.ts` | Builds a `Clx` describing the whole logical text stream as one uncompressed piece. | -| `src/prop/chp-write.ts`, `src/prop/pap-write.ts` | The inverse of `chp.ts`/`pap.ts`: a run's or paragraph's direct properties to a grpprl. | -| `src/prop/fkp-write.ts` | Packs formatting exceptions into `ChpxFkp`/`PapxFkp` pages, splitting across as many as the content needs, and builds the bin tables addressing them. | -| `src/write.ts` | The whole write chain, from a `ContentDocument` to real [MS-DOC] bytes in a real [MS-CFB] compound file. | +| Module | What it does | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/bytes.ts` | Bounds-checked little-endian reads; every offset in the format is attacker-controlled data, so an over-read fails loudly. | +| `src/plc.ts` | The `PLC` container shape, whose element count is derived from its total size by [MS-DOC] 2.2.2's own formula, and the "largest key at most" lookup every algorithm phrases in those words. | +| `src/fib/` | The FIB's field offsets, derived by summing the declared field sizes, and the parse that reads the counts and offsets from them. | +| `src/text/piece-table.ts` | The `Clx` and its `PlcPcd`, and the character-position-to-byte-offset mapping. | +| `src/text/characters.ts` | Text reconstruction, including the compressed-byte mapping table. | +| `src/text/special.ts` | The characters that carry structure rather than glyphs. | +| `src/prop/sprm.ts` | `Sprm` decoding and the operand-size table that makes a grpprl walkable. | +| `src/prop/fkp.ts` | The formatted disk pages and the bin tables that address them. | +| `src/prop/chp.ts`, `src/prop/pap.ts` | Folding a grpprl into character and paragraph properties. | +| `src/style/stsh.ts` | The style sheet. | +| `src/style/fonts.ts` | The font table (`SttbfFfn`/`FFN`) — read and write together, since both directions share one small, self-contained field layout. | +| `src/read.ts` | The whole read chain, to a `ContentDocument`. | +| `src/fib/write.ts` | Builds a real FIB for nFib 0x00C1 (Word 97), populated with the fc/lcb pairs this package's own writer needs. | +| `src/text/piece-table-write.ts` | Builds a `Clx` describing the whole logical text stream as one uncompressed piece. | +| `src/prop/chp-write.ts`, `src/prop/pap-write.ts` | The inverse of `chp.ts`/`pap.ts`: a run's or paragraph's direct properties to a grpprl. | +| `src/prop/fkp-write.ts` | Packs formatting exceptions into `ChpxFkp`/`PapxFkp` pages, splitting across as many as the content needs, and builds the bin tables addressing them. | +| `src/write.ts` | The whole write chain, from a `ContentDocument` to real [MS-DOC] bytes in a real [MS-CFB] compound file. | ### Why the piece table gets the most attention @@ -167,9 +167,7 @@ const written = writeDocContent({ { pageSize: { widthPt: 612, heightPt: 792 }, margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, - blocks: [ - { kind: "paragraph", runs: [{ text: "Hello.", bold: true }] }, - ], + blocks: [{ kind: "paragraph", runs: [{ text: "Hello.", bold: true }] }], }, ], });