diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index eec255a8a..5f1fc9758 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -23,11 +23,12 @@ Built and shipped, on the read side: - **`isDocBytes`** — distinguishes a `.doc` from the `.xls`, `.ppt` and OLE embeddings that share its container, by looking for a `WordDocument` stream carrying `FibBase.wIdent`. - **Document metadata** — `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` read from a `"\x05SummaryInformation"` stream when one is present (see [Metadata](#metadata)); `comments` and `lastPrintedIso` remain unread, since `LayoutMetadata` has no field for either. - **Numbering definitions** — `readDocContent`'s own `numbering` field: every list's glyph/format, level-text template, and start-at value, resolved from `PlfLst`/`PlfLfo` and keyed by the same `listId` a paragraph's `ContentParagraph.list.numId` already carries. Read-only; see [Numbering definitions](#numbering-definitions). +- **The one section's own page size and margins** — `prop/sep.ts`'s `readSectionProperties` resolves `PlcfSed`/`Sepx` (`sprmSXaPage`/`sprmSYaPage`/`sprmSDxaLeft`/`sprmSDxaRight`/`sprmSDyaTop`/`sprmSDyaBottom`) into `ContentSection.pageSize`/`.margins`, falling back field by field to Word's own new-document default (US Letter, one-inch margins) for a document carrying no `PlcfSed` at all, or for any one sprm it leaves unstated. This reader only ever resolves the section spanning the whole main document — the "Section boundaries" row below states what "one section" still means. Built and shipped, on the write side — see [Writing](#writing) for the full scope statement: -- **`writeDocContent`** — a `'wordprocessing'` `ContentDocument` (one section, paragraphs of runs and tables) 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, a font table when a run names one, and a `"\x05SummaryInformation"` stream when the input's metadata carries anything that stream can hold (see [Metadata](#metadata)) — wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`. A `ContentTable` block is expanded by `table/write.ts`'s `flattenSectionBlocks` into the same flat paragraph sequence every other block already is (see [Tables](#tables)), so table paragraphs flow through the identical `ChpxFkp`/`PapxFkp` paging as every other paragraph rather than a separate table-only path. Each cell's own `background` and `borders` ride along into the row's TAP (see [Cell decoration](#cell-decoration)). -- 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, non-Latin-1 and non-BMP text (accented Latin, CJK, an emoji surrogate pair), and a table — recognised as a genuine `table:table`, its row/column/cell structure and both horizontal and vertical merges intact, matching real `table:number-columns-spanned`/`table:number-rows-spanned` attributes and `table:covered-table-cell` elements, and each cell's own background fill and per-side borders recovered with their exact colours, exactly as [Tables](#tables) confirms in full. +- **`writeDocContent`** — a `'wordprocessing'` `ContentDocument` (one section, paragraphs of runs and tables) 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, a font table when a run names one, a real `PlcfSed`/`Sepx` stating the one section's own page size and margins, and a `"\x05SummaryInformation"` stream when the input's metadata carries anything that stream can hold (see [Metadata](#metadata)) — wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`. A `ContentTable` block is expanded by `table/write.ts`'s `flattenSectionBlocks` into the same flat paragraph sequence every other block already is (see [Tables](#tables)), so table paragraphs flow through the identical `ChpxFkp`/`PapxFkp` paging as every other paragraph rather than a separate table-only path. Each cell's own `background` and `borders` ride along into the row's TAP (see [Cell decoration](#cell-decoration)). +- 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, non-Latin-1 and non-BMP text (accented Latin, CJK, an emoji surrogate pair), a section's own page size and all four margins (a non-default 600x800pt page with a 90/54/45/36pt left/right/top/bottom margin, confirmed against LibreOffice's own `fo:page-width`/`fo:page-height`/`fo:margin-*` export to the exact point), and a table — recognised as a genuine `table:table`, its row/column/cell structure and both horizontal and vertical merges intact, matching real `table:number-columns-spanned`/`table:number-rows-spanned` attributes and `table:covered-table-cell` elements, and each cell's own background fill and per-side borders recovered with their exact colours, exactly as [Tables](#tables) confirms in full. **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: @@ -38,7 +39,7 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc | **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. | +| **Section boundaries** | Section boundaries themselves are not read — the whole document is always treated as one section, spanning the entire main document — so a multi-section `.doc`'s later sections' own page size and margins are never consulted, only the first's (see the read-side page-size/margins bullet above for what is now read of that one section). `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. | | **Extended and user-defined document properties** | `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` are read from and written to a `"\x05SummaryInformation"` stream when present (see [Metadata](#metadata)); the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. | | **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. [MS-DOC] 2.4.3's own Overview of Tables text names `sprmPTableProps` as a real, legal alternative to `sprmTDefTable` some applications process — but a real producer's row mark is not shown to prefer it: a genuine LibreOffice-authored `.doc` table's own row mark states its TAP through the identical direct `sprmTDefTable` this package's reader and writer already use (confirmed by parsing a LibreOffice 26.2.5.2-authored table's raw `PapxFkp` bytes; see [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892)), matching 2.4.3's own compatibility guidance ("An application SHOULD use sprmTDefTable to define table cells for applications that do not process sprmPTableProps"). `writeDocContent` never writes an indirect Papx. | diff --git a/packages/doc-codec/src/fib/fib.ts b/packages/doc-codec/src/fib/fib.ts index ca18f1809..9f49cc820 100644 --- a/packages/doc-codec/src/fib/fib.ts +++ b/packages/doc-codec/src/fib/fib.ts @@ -41,6 +41,9 @@ export interface Fib { readonly fcClx: number; readonly lcbClx: number; + readonly fcPlcfSed: number; + readonly lcbPlcfSed: number; + readonly fcSttbfFfn: number; readonly lcbSttbfFfn: number; @@ -119,6 +122,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), + fcPlcfSed: fcLcb(FC_LCB_VALUE_INDEX.fcPlcfSed), + lcbPlcfSed: fcLcb(FC_LCB_VALUE_INDEX.lcbPlcfSed), fcSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.fcSttbfFfn), lcbSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.lcbSttbfFfn), fcPlfLst: fcLcb(FC_LCB_VALUE_INDEX.fcPlfLst), diff --git a/packages/doc-codec/src/fib/offsets.ts b/packages/doc-codec/src/fib/offsets.ts index ebd265d08..53f213e07 100644 --- a/packages/doc-codec/src/fib/offsets.ts +++ b/packages/doc-codec/src/fib/offsets.ts @@ -42,6 +42,9 @@ export const LW_OFFSET = { export const FC_LCB_VALUE_INDEX = { fcStshf: 2, lcbStshf: 3, + // Pair 6, counted the same way as the fcSttbfFfn comment below counts pair 15: fcStshfOrig(0), fcStshf(1), fcPlcffndRef(2), fcPlcffndTxt(3), fcPlcfandRef(4), fcPlcfandTxt(5), fcPlcfSed(6). + fcPlcfSed: 12, + lcbPlcfSed: 13, fcPlcfBteChpx: 24, lcbPlcfBteChpx: 25, fcPlcfBtePapx: 26, diff --git a/packages/doc-codec/src/fib/write.ts b/packages/doc-codec/src/fib/write.ts index d28f76bf0..dd0edcef9 100644 --- a/packages/doc-codec/src/fib/write.ts +++ b/packages/doc-codec/src/fib/write.ts @@ -31,6 +31,8 @@ export interface FibWriteSpec { readonly cbMac: number; readonly fcClx: number; readonly lcbClx: number; + readonly fcPlcfSed: number; + readonly lcbPlcfSed: number; readonly fcPlcfBteChpx: number; readonly lcbPlcfBteChpx: number; readonly fcPlcfBtePapx: number; @@ -83,6 +85,7 @@ export function buildFib(spec: FibWriteSpec): Uint8Array { ); pair(FC_LCB_VALUE_INDEX.fcSttbfFfn, spec.fcSttbfFfn, spec.lcbSttbfFfn); pair(FC_LCB_VALUE_INDEX.fcClx, spec.fcClx, spec.lcbClx); + pair(FC_LCB_VALUE_INDEX.fcPlcfSed, spec.fcPlcfSed, spec.lcbPlcfSed); // 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) { diff --git a/packages/doc-codec/src/index.ts b/packages/doc-codec/src/index.ts index e4d115581..61ff00486 100644 --- a/packages/doc-codec/src/index.ts +++ b/packages/doc-codec/src/index.ts @@ -18,6 +18,8 @@ export * from "./prop/chp"; export * from "./prop/chp-write"; export * from "./prop/pap"; export * from "./prop/pap-write"; +export * from "./prop/sep"; +export * from "./prop/sep-write"; export * from "./style/stsh"; export * from "./style/fonts"; export * from "./list/numbering"; diff --git a/packages/doc-codec/src/prop/sep-write.ts b/packages/doc-codec/src/prop/sep-write.ts new file mode 100644 index 000000000..8dc7626a5 --- /dev/null +++ b/packages/doc-codec/src/prop/sep-write.ts @@ -0,0 +1,141 @@ +import type { ContentSection } from "document-schema.js"; +import { DocFormatError } from "../errors"; + +// The inverse of sep.ts's applySectionSprms: a ContentSection's pageSize/margins to a Sepx grpprl. Every margin is written in the positive, minimum-margin form of its YAS operand (see sep.ts's own marginFromYas comment) -- the same form the specification's own worked example uses, and the one a plain point value naturally maps to, since this writer has no header/footer geometry to grow a minimum margin against. + +/** sprmSXaPage / sprmSYaPage: unsigned 2-byte twips, each constrained to [MS-DOC]'s own [144, 31680] page-dimension range. */ +const SPRM_S_XA_PAGE = 0xb01f; +const SPRM_S_YA_PAGE = 0xb020; +/** sprmSDxaLeft / sprmSDxaRight: an unsigned 2-byte twips margin. */ +const SPRM_S_DXA_LEFT = 0xb021; +const SPRM_S_DXA_RIGHT = 0xb022; +/** sprmSDyaTop / sprmSDyaBottom: a signed 2-byte twips margin, written positive (minimum-margin form). */ +const SPRM_S_DYA_TOP = 0x9023; +const SPRM_S_DYA_BOTTOM = 0x9024; + +const TWIPS_PER_POINT = 20; +/** sprmSXaPage/sprmSYaPage's own stated operand range, in twips. */ +const MIN_PAGE_DIMENSION_TWIPS = 144; +const MAX_PAGE_DIMENSION_TWIPS = 31680; +/** sprmSDxaLeft/sprmSDxaRight's unsigned 2-byte operand range. */ +const MAX_UINT16 = 0xffff; +/** sprmSDyaTop/sprmSDyaBottom's own stated magnitude range: "MUST be less than or equal to 31665 and greater than or equal to -31665." */ +const MAX_MARGIN_TWIPS = 31665; + +function pushSprm( + bytes: number[], + opcode: number, + operand: readonly number[], +): void { + bytes.push(opcode & 0xff, (opcode >> 8) & 0xff, ...operand); +} + +function pointsToTwips(pt: number): number { + return Math.round(pt * TWIPS_PER_POINT); +} + +function uint16( + value: number, + min: number, + max: number, + what: string, +): number[] { + if (value < min || value > max) { + throw new DocFormatError( + `${what} is ${value} twips, outside the ${min}..${max} range its sprm operand can hold`, + ); + } + return [value & 0xff, (value >> 8) & 0xff]; +} + +// Builds the Sepx grpprl for one section's page size and margins -- both required fields of ContentSection (document-schema.js), so this always emits all six sprms. +export function encodeSectionGrpprl( + section: Pick, +): number[] { + const bytes: number[] = []; + pushSprm( + bytes, + SPRM_S_XA_PAGE, + uint16( + pointsToTwips(section.pageSize.widthPt), + MIN_PAGE_DIMENSION_TWIPS, + MAX_PAGE_DIMENSION_TWIPS, + "section pageSize.widthPt", + ), + ); + pushSprm( + bytes, + SPRM_S_YA_PAGE, + uint16( + pointsToTwips(section.pageSize.heightPt), + MIN_PAGE_DIMENSION_TWIPS, + MAX_PAGE_DIMENSION_TWIPS, + "section pageSize.heightPt", + ), + ); + pushSprm( + bytes, + SPRM_S_DXA_LEFT, + uint16( + pointsToTwips(section.margins.leftPt), + 0, + MAX_UINT16, + "section margins.leftPt", + ), + ); + pushSprm( + bytes, + SPRM_S_DXA_RIGHT, + uint16( + pointsToTwips(section.margins.rightPt), + 0, + MAX_UINT16, + "section margins.rightPt", + ), + ); + pushSprm( + bytes, + SPRM_S_DYA_TOP, + uint16( + pointsToTwips(section.margins.topPt), + 0, + MAX_MARGIN_TWIPS, + "section margins.topPt", + ), + ); + pushSprm( + bytes, + SPRM_S_DYA_BOTTOM, + uint16( + pointsToTwips(section.margins.bottomPt), + 0, + MAX_MARGIN_TWIPS, + "section margins.bottomPt", + ), + ); + return bytes; +} + +/** Sepx, [MS-DOC] 2.9.279: a 2-byte cb (grpprl's own length) followed by the grpprl itself. */ +export function buildSepx(grpprl: readonly number[]): Uint8Array { + const bytes = new Uint8Array(2 + grpprl.length); + new DataView(bytes.buffer).setUint16(0, grpprl.length, true); + bytes.set(grpprl, 2); + return bytes; +} + +/** PlcfSed for exactly one section: two CPs (0 and ccpText) bracketing the single Sed ([MS-DOC] 2.9.269) this writer ever emits, whose fcSepx names where buildSepx's own bytes were placed in the WordDocument stream and whose fn/fnMpr/fcMpr fields carry the values [MS-DOC] states are ignored. */ +export function buildPlcfSed( + ccpText: number, + fcSepx: number, +): Uint8Array { + const bytes = new Uint8Array(4 + 4 + 12); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0, true); // cp[0]: the section starts at the beginning of the main document. + view.setUint32(4, ccpText, true); // cp[1]: the terminating CP, at or beyond the end of the main document. + view.setUint16(8, 0, true); // sed.fn -- ignored. + view.setUint32(10, fcSepx, true); // sed.fcSepx. + view.setUint16(14, 0, true); // sed.fnMpr -- ignored. + view.setUint32(16, 0xffffffff, true); // sed.fcMpr -- ignored. + return bytes; +} diff --git a/packages/doc-codec/src/prop/sep.ts b/packages/doc-codec/src/prop/sep.ts new file mode 100644 index 000000000..64045c9fd --- /dev/null +++ b/packages/doc-codec/src/prop/sep.ts @@ -0,0 +1,104 @@ +import { readInt16LE, readUint16LE, readUint32LE, slice } from "../bytes"; +import type { Fib } from "../fib/fib"; +import { parsePlc } from "../plc"; +import { SGC, readGrpprl, type Prl } from "./sprm"; + +/** Sed, [MS-DOC] 2.9.269: fn(2, ignored) + fcSepx(4) + fnMpr(2, ignored) + fcMpr(4, ignored) -- 12 bytes, the fixed element size parsePlc needs for PlcfSed. */ +const SED_SIZE = 12; +const SED_FC_SEPX_OFFSET = 2; + +// Section properties, [MS-DOC] 2.6.4 -- the subset of the section-property sprm table this reader converts: page size and the four page margins, which is all document-schema.js's own ContentSection (pageSize + margins) has anywhere to hold. Every other section sprm (columns, headers/footers distance, vertical justification, page borders, line numbering, the rest of the roughly seventy the specification names) is absent rather than present-and-ignored, the identical documented-gap convention pap.ts already uses for paragraph sprms this package does not convert. + +/** sprmSXaPage: page width, an unsigned 2-byte twips value. */ +const SPRM_S_XA_PAGE = 0xb01f; +/** sprmSYaPage: page height, an unsigned 2-byte twips value. */ +const SPRM_S_YA_PAGE = 0xb020; +/** sprmSDxaLeft / sprmSDxaRight: an XAS_nonNeg (unsigned 2-byte twips) left/right margin. */ +const SPRM_S_DXA_LEFT = 0xb021; +const SPRM_S_DXA_RIGHT = 0xb022; +/** sprmSDyaTop / sprmSDyaBottom: a YAS (signed 2-byte twips) top/bottom margin -- positive is a minimum margin that grows to avoid a header/footer, negative a fixed margin whose absolute value is used regardless. document-schema.js's Margins has no minimum/fixed distinction, so both forms report the same absolute size (see marginFromYas below). */ +const SPRM_S_DYA_TOP = 0x9023; +const SPRM_S_DYA_BOTTOM = 0x9024; + +const TWIPS_PER_POINT = 20; + +function twipsToPoints(twips: number): number { + return twips / TWIPS_PER_POINT; +} + +// YAS's minimum/fixed distinction is about how the margin interacts with header/footer space this reader does not model; either way, twips is the margin's own physical size, so the absolute value is what a plain Margins field states. +function marginFromYas(twips: number): number { + return twipsToPoints(Math.abs(twips)); +} + +export interface SectionProperties { + pageWidthPt?: number; + pageHeightPt?: number; + marginLeftPt?: number; + marginRightPt?: number; + marginTopPt?: number; + marginBottomPt?: number; +} + +// Folds a Sepx's grpprl into `into`, in order, so the last Prl to touch a property determines it -- the same precedence rule applyParagraphSprms/applyCharacterSprms already apply to their own property families ([MS-DOC] 2.6's Applying Properties). +export function applySectionSprms( + prls: readonly Prl[], + into: SectionProperties, +): SectionProperties { + for (const prl of prls) { + if (prl.sprm.sgc !== SGC.section) continue; + switch (prl.sprm.value) { + case SPRM_S_XA_PAGE: + into.pageWidthPt = twipsToPoints(readUint16LE(prl.operand, 0)); + break; + case SPRM_S_YA_PAGE: + into.pageHeightPt = twipsToPoints(readUint16LE(prl.operand, 0)); + break; + case SPRM_S_DXA_LEFT: + into.marginLeftPt = twipsToPoints(readUint16LE(prl.operand, 0)); + break; + case SPRM_S_DXA_RIGHT: + into.marginRightPt = twipsToPoints(readUint16LE(prl.operand, 0)); + break; + case SPRM_S_DYA_TOP: + into.marginTopPt = marginFromYas(readInt16LE(prl.operand, 0)); + break; + case SPRM_S_DYA_BOTTOM: + into.marginBottomPt = marginFromYas(readInt16LE(prl.operand, 0)); + break; + default: + // Every other section sprm is a property this reader does not convert; see this module's own top comment. + break; + } + } + return into; +} + +/** Resolves the document's first section's own page size and margins from PlcfSed/Sepx -- absent entirely when the file carries no PlcfSed at all (lcbPlcfSed 0), which read.ts's own DEFAULT_PAGE_SIZE/DEFAULT_MARGINS then stand in for field by field, exactly as an individual unstated sprm already does. This reader only ever resolves the section covering the whole main document (see read.ts's own DocContent comment on why more than one section is out of scope), so it reads PlcfSed's first Sed regardless of how many the file actually carries. */ +export function readSectionProperties( + wordDocument: Uint8Array, + table: Uint8Array, + fib: Pick, +): SectionProperties { + if (fib.lcbPlcfSed === 0) { + return {}; + } + const plc = parsePlc( + slice(table, fib.fcPlcfSed, fib.lcbPlcfSed, "PlcfSed in the Table stream"), + SED_SIZE, + "PlcfSed", + ); + if (plc.count === 0) { + return {}; + } + const sed = plc.element(0); + const fcSepx = readUint32LE(sed, SED_FC_SEPX_OFFSET); + const cb = readUint16LE(wordDocument, fcSepx); + const grpprl = slice( + wordDocument, + fcSepx + 2, + cb, + "Sepx grpprl in the WordDocument stream", + ); + return applySectionSprms(readGrpprl(grpprl), {}); +} diff --git a/packages/doc-codec/src/read.test.ts b/packages/doc-codec/src/read.test.ts index 91645247e..1082b72c4 100644 --- a/packages/doc-codec/src/read.test.ts +++ b/packages/doc-codec/src/read.test.ts @@ -28,6 +28,33 @@ const RED_TEXT = [0x42, 0x2a, 0x06]; // sprmCIco, palette entry 6. const CENTRED = [0x61, 0x24, 0x01]; // sprmPJc, logical centre. const SPACE_BEFORE_12PT = [0x13, 0xa4, 0xf0, 0x00]; // sprmPDyaBefore, 240 twips. const PAGE_BREAK_BEFORE = [0x07, 0x24, 0x01]; // sprmPFPageBreakBefore, Bool8 true. +// A section grpprl stating a page 600x800pt with a 90/54/45/36pt left/right/top/bottom margin -- one Prl per sprm, none of them the format's own default value, so a test reading them back proves the real field rather than coincidentally matching a fallback. +const SECTION_GEOMETRY = [ + 0x1f, + 0xb0, + 0xe0, + 0x2e, // sprmSXaPage, 12000 twips (600pt). + 0x20, + 0xb0, + 0x80, + 0x3e, // sprmSYaPage, 16000 twips (800pt). + 0x21, + 0xb0, + 0x08, + 0x07, // sprmSDxaLeft, 1800 twips (90pt). + 0x22, + 0xb0, + 0x38, + 0x04, // sprmSDxaRight, 1080 twips (54pt). + 0x23, + 0x90, + 0x84, + 0x03, // sprmSDyaTop, 900 twips (45pt). + 0x24, + 0x90, + 0xd0, + 0x02, // sprmSDyaBottom, 720 twips (36pt). +]; function paragraphs( document: ReturnType, @@ -178,6 +205,31 @@ describe("readDocContent", () => { }); }); + it("reads a fixed (negative YAS) top/bottom margin as its absolute size, the same as a minimum one", () => { + const document = readDocContent( + buildDoc({ + paragraphs: [{ runs: [{ text: "text" }] }], + sectionGrpprl: [ + 0x23, + 0x90, + 0x30, + 0xfd, // sprmSDyaTop, -720 twips fixed (36pt). + 0x24, + 0x90, + 0x60, + 0xfa, // sprmSDyaBottom, -1440 twips fixed (72pt). + ], + }), + ); + if (document.kind !== "wordprocessing") { + throw new Error("a .doc always reads as a wordprocessing document"); + } + const section = document.sections[0]; + if (section === undefined) throw new Error("a section must be present"); + expect(section.margins.topPt).toBe(36); + expect(section.margins.bottomPt).toBe(72); + }); + it("reads paragraph alignment, spacing and page breaks from the PAPX exception", () => { const document = readDocContent( buildDoc({ @@ -193,6 +245,45 @@ describe("readDocContent", () => { expect(paragraphAt(document, 2).pageBreakBefore).toBe(true); }); + it("reads a section's own page size and margins from its Sepx", () => { + const document = readDocContent( + buildDoc({ + paragraphs: [{ runs: [{ text: "text" }] }], + sectionGrpprl: SECTION_GEOMETRY, + }), + ); + if (document.kind !== "wordprocessing") { + throw new Error("a .doc always reads as a wordprocessing document"); + } + const section = document.sections[0]; + if (section === undefined) throw new Error("a section must be present"); + expect(section.pageSize).toEqual({ widthPt: 600, heightPt: 800 }); + expect(section.margins).toEqual({ + leftPt: 90, + rightPt: 54, + topPt: 45, + bottomPt: 36, + }); + }); + + it("falls back to Word's own new-document default when the file carries no PlcfSed at all", () => { + const document = readDocContent( + buildDoc({ paragraphs: [{ runs: [{ text: "text" }] }] }), + ); + if (document.kind !== "wordprocessing") { + throw new Error("a .doc always reads as a wordprocessing document"); + } + const section = document.sections[0]; + if (section === undefined) throw new Error("a section must be present"); + expect(section.pageSize).toEqual({ widthPt: 612, heightPt: 792 }); + expect(section.margins).toEqual({ + leftPt: 72, + rightPt: 72, + topPt: 72, + bottomPt: 72, + }); + }); + it("derives a heading level from the paragraph style index, as sprmPIstd's own rule states", () => { const document = readDocContent( buildDoc({ diff --git a/packages/doc-codec/src/read.ts b/packages/doc-codec/src/read.ts index 5ce9634ec..5fcdd5973 100644 --- a/packages/doc-codec/src/read.ts +++ b/packages/doc-codec/src/read.ts @@ -21,6 +21,7 @@ import { import { applyCharacterSprms, type CharacterProperties } from "./prop/chp"; import { PropertyBinTable } from "./prop/fkp"; import { applyParagraphSprms, type ParagraphProperties } from "./prop/pap"; +import { readSectionProperties } from "./prop/sep"; import { readGrpprl, type Prl } from "./prop/sprm"; import { parseFontTable } from "./style/fonts"; import { headingLevelFromIstd, parseStsh, type StyleSheet } from "./style/stsh"; @@ -39,9 +40,9 @@ import { // The top-level read: a .doc's bytes to a ContentDocument. Every step below is one of [MS-DOC]'s own algorithms, in the order the specification chains them -- the compound-file container gives the WordDocument and Table streams, the FIB gives the offsets, the piece table turns character positions into bytes, and the two bin tables turn byte offsets into formatting. readParagraphs itself only ever produces flat ParagraphEntry values (one per paragraph/cell/row mark, whatever its own table depth); table/read.ts's assembleBlocks is what folds a contiguous run of table-depth paragraphs into a real ContentTable, so this module carries no table-specific logic of its own. // -// What this does NOT do is as important as what it does, and is stated in full in the README's scope section rather than only here: no images, no footnotes/headers/endnotes, no section geometry, no style-inherited formatting, and no decryption. Each of those is a genuine layer of the format, and each is absent rather than approximated. Tables are read, but only at depth 1 -- a table nested inside a table cell is refused (table/read.ts) rather than mis-read. Numbering definitions (list/numbering.ts's readNumberingDefinitions) resolve what a paragraph's own listId/listLevel membership looks like -- see DocContent's own comment below for why that rides outside ContentDocument's shared shape. +// What this does NOT do is as important as what it does, and is stated in full in the README's scope section rather than only here: no images, no footnotes/headers/endnotes, no section boundaries beyond the whole document's own page size and margins, no style-inherited formatting, and no decryption. Each of those is a genuine layer of the format, and each is absent rather than approximated. Tables are read, but only at depth 1 -- a table nested inside a table cell is refused (table/read.ts) rather than mis-read. Numbering definitions (list/numbering.ts's readNumberingDefinitions) resolve what a paragraph's own listId/listLevel membership looks like -- see DocContent's own comment below for why that rides outside ContentDocument's shared shape. -/** The page geometry every section is given, because this reader does not yet read a document's own. US Letter with one-inch margins is Word's own default for a new document; a document that states otherwise is not yet consulted, so this is a placeholder the schema requires rather than a fact read from the file. */ +/** Word's own default for a new document (US Letter, one-inch margins) -- what a field this reader resolves from PlcfSed/Sepx (prop/sep.ts's readSectionProperties) falls back to when the file states nothing for it, exactly as it would fall back to Word's own implementation-dependent default for that one unstated sprm. */ const DEFAULT_PAGE_SIZE: PageSize = { widthPt: 612, heightPt: 792 }; const DEFAULT_MARGINS: Margins = { topPt: 72, @@ -149,6 +150,7 @@ export function readDocContent(bytes: Uint8Array): DocContent { }); const blocks = assembleBlocks(entries); const numbering = readNumberingDefinitions(table, fib); + const sectionProperties = readSectionProperties(wordDocument, table, fib); return { kind: "wordprocessing", @@ -159,8 +161,18 @@ export function readDocContent(bytes: Uint8Array): DocContent { : summaryInformationToLayoutMetadata(readSummaryInformation(metadata)), sections: [ { - pageSize: DEFAULT_PAGE_SIZE, - margins: DEFAULT_MARGINS, + pageSize: { + widthPt: sectionProperties.pageWidthPt ?? DEFAULT_PAGE_SIZE.widthPt, + heightPt: + sectionProperties.pageHeightPt ?? DEFAULT_PAGE_SIZE.heightPt, + }, + margins: { + leftPt: sectionProperties.marginLeftPt ?? DEFAULT_MARGINS.leftPt, + rightPt: sectionProperties.marginRightPt ?? DEFAULT_MARGINS.rightPt, + topPt: sectionProperties.marginTopPt ?? DEFAULT_MARGINS.topPt, + bottomPt: + sectionProperties.marginBottomPt ?? DEFAULT_MARGINS.bottomPt, + }, blocks, }, ], diff --git a/packages/doc-codec/src/test-support/doc.ts b/packages/doc-codec/src/test-support/doc.ts index 8b7262b02..724bfa14e 100644 --- a/packages/doc-codec/src/test-support/doc.ts +++ b/packages/doc-codec/src/test-support/doc.ts @@ -39,6 +39,8 @@ export interface DocSpec { /** Splits the text across this many pieces rather than one, exercising a logical stream assembled from discontiguous byte ranges. */ readonly pieces?: number; readonly styles?: readonly DocStyleSpec[]; + /** The one section's own Sepx grpprl -- absent produces no PlcfSed at all, exercising the reader's own fallback to its page-geometry defaults exactly as a real file with no section properties would. */ + readonly sectionGrpprl?: readonly number[]; } // Two grpprls are the same exception when both are absent or their bytes match, which is what decides whether adjacent stretches merge into one ChpxFkp run. @@ -110,7 +112,13 @@ export function buildDoc(spec: DocSpec): Uint8Array { const firstFreePage = Math.ceil((TEXT_FC + textByteLength) / FKP_PAGE_SIZE); const papxPage = firstFreePage + 1; - const wordDocument = new Uint8Array((papxPage + 1) * FKP_PAGE_SIZE); + // The Sepx, when the spec wants one, sits right after the Papx page -- not itself an FKP-paged structure, so it needs no page alignment of its own. + const sepx = + spec.sectionGrpprl === undefined + ? undefined + : buildSepxBytes(spec.sectionGrpprl); + const fcSepx = (papxPage + 1) * FKP_PAGE_SIZE; + const wordDocument = new Uint8Array(fcSepx + (sepx?.length ?? 0)); const wordView = new DataView(wordDocument.buffer); for (let index = 0; index < text.length; index += 1) { const code = text.charCodeAt(index); @@ -148,8 +156,11 @@ export function buildDoc(spec: DocSpec): Uint8Array { ), papxPage * FKP_PAGE_SIZE, ); + if (sepx !== undefined) { + wordDocument.set(sepx, fcSepx); + } - // 3. The Table stream: the Clx, the two bin tables, and the style sheet, each at an offset the FIB then names. + // 3. The Table stream: the Clx, the two bin tables, the style sheet, and (when the spec wants a section) the PlcfSed, each at an offset the FIB then names. const clx = buildClx(text.length, spec.pieces ?? 1, compressed, characterFc); const plcBteChpx = buildBinTable( [TEXT_FC, characterFc(text.length)], @@ -160,8 +171,16 @@ export function buildDoc(spec: DocSpec): Uint8Array { [papxPage], ); const stsh = buildStsh(spec.styles ?? []); + const plcfSed = + sepx === undefined ? undefined : buildPlcfSedBytes(text.length, fcSepx); - const tableParts = [clx, plcBteChpx, plcBtePapx, stsh]; + const tableParts = [ + clx, + plcBteChpx, + plcBtePapx, + stsh, + ...(plcfSed === undefined ? [] : [plcfSed]), + ]; const tableOffsets: number[] = []; let tableLength = 0; for (const part of tableParts) { @@ -192,6 +211,9 @@ export function buildDoc(spec: DocSpec): Uint8Array { lcbPlcfBtePapx: plcBtePapx.length, fcStshf: offsetOf(3), lcbStshf: stsh.length, + ...(plcfSed === undefined + ? {} + : { fcPlcfSed: offsetOf(4), lcbPlcfSed: plcfSed.length }), }); wordDocument.set(fib, 0); @@ -201,6 +223,28 @@ export function buildDoc(spec: DocSpec): Uint8Array { ]); } +// Sepx, [MS-DOC] 2.9.279: a 2-byte cb (grpprl's own length) followed by the grpprl itself. Built independently of prop/sep-write.ts's own buildSepx, for the same reason the sprm byte sequences at the top of read.test.ts are hand-encoded rather than built through prop/sep-write.ts's encodeSectionGrpprl: a fixture sharing code with the module under test would let a bug in one hide behind the same bug in the other. +function buildSepxBytes(grpprl: readonly number[]): Uint8Array { + return new Uint8Array([ + grpprl.length & 0xff, + (grpprl.length >> 8) & 0xff, + ...grpprl, + ]); +} + +// A one-section PlcfSed: two CPs (0 and ccpText) bracketing a single 12-byte Sed ([MS-DOC] 2.9.269) whose fcSepx names where buildSepxBytes' own bytes were placed in the WordDocument stream. +function buildPlcfSedBytes(ccpText: number, fcSepx: number): Uint8Array { + const bytes = new Uint8Array(4 + 4 + 12); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0, true); // cp[0] + view.setUint32(4, ccpText, true); // cp[1] + view.setUint16(8, 0, true); // sed.fn -- ignored. + view.setUint32(10, fcSepx, true); // sed.fcSepx. + view.setUint16(14, 0, true); // sed.fnMpr -- ignored. + view.setUint32(16, 0xffffffff, true); // sed.fcMpr -- ignored. + return bytes; +} + // A Clx with no Prc array (so its first byte is the Pcdt's own 0x02) and a PlcPcd splitting the text into `pieceCount` pieces of as-equal length as divides. function buildClx( characterCount: number, diff --git a/packages/doc-codec/src/test-support/fib.ts b/packages/doc-codec/src/test-support/fib.ts index 1f59036a7..2c737b6eb 100644 --- a/packages/doc-codec/src/test-support/fib.ts +++ b/packages/doc-codec/src/test-support/fib.ts @@ -26,6 +26,8 @@ export interface FibSpec { readonly lcbPlcfBtePapx?: number; readonly fcClx?: number; readonly lcbClx?: number; + readonly fcPlcfSed?: number; + readonly lcbPlcfSed?: number; readonly fcPlfLst?: number; readonly lcbPlfLst?: number; readonly fcPlfLfo?: number; @@ -54,6 +56,7 @@ const FC_LCB_INDEX = { fcPlcfBteChpx: 24, fcPlcfBtePapx: 26, fcClx: 66, + fcPlcfSed: 12, fcPlfLst: 146, fcPlfLfo: 148, } as const; @@ -111,6 +114,7 @@ export function buildFib(spec: FibSpec = {}): Uint8Array { spec.lcbPlcfBtePapx ?? 0, ); pair(FC_LCB_INDEX.fcClx, spec.fcClx ?? 0, spec.lcbClx ?? 0); + pair(FC_LCB_INDEX.fcPlcfSed, spec.fcPlcfSed ?? 0, spec.lcbPlcfSed ?? 0); pair(FC_LCB_INDEX.fcPlfLst, spec.fcPlfLst ?? 0, spec.lcbPlfLst ?? 0); pair(FC_LCB_INDEX.fcPlfLfo, spec.fcPlfLfo ?? 0, spec.lcbPlfLfo ?? 0); diff --git a/packages/doc-codec/src/write.test.ts b/packages/doc-codec/src/write.test.ts index e9a17547e..ee419daef 100644 --- a/packages/doc-codec/src/write.test.ts +++ b/packages/doc-codec/src/write.test.ts @@ -244,6 +244,33 @@ describe("writeDocContent", () => { expect(paragraphAt(result, 4).pageBreakBefore).toBe(true); }); + it("round-trips a section's own page size and margins", () => { + const input: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 600, heightPt: 800 }, + margins: { leftPt: 90, rightPt: 54, topPt: 45, bottomPt: 36 }, + blocks: [paragraph([{ text: "text" }])], + }, + ], + }; + const result = roundTrip(input); + 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"); + expect(section.pageSize).toEqual({ widthPt: 600, heightPt: 800 }); + expect(section.margins).toEqual({ + leftPt: 90, + rightPt: 54, + topPt: 45, + bottomPt: 36, + }); + }); + it("round-trips every ST_Jc alignment value this package converts", () => { const input = document([ paragraph([{ text: "l" }], { alignment: "left" }), diff --git a/packages/doc-codec/src/write.ts b/packages/doc-codec/src/write.ts index 57e878eb2..214c65157 100644 --- a/packages/doc-codec/src/write.ts +++ b/packages/doc-codec/src/write.ts @@ -19,6 +19,7 @@ import { type PapxParagraphToWrite, } from "./prop/fkp-write"; import { encodeParagraphGrpprl } from "./prop/pap-write"; +import { buildPlcfSed, buildSepx, encodeSectionGrpprl } from "./prop/sep-write"; import { buildFontTable } from "./style/fonts"; import { buildEmptyStsh } from "./style/stsh"; import { flattenSectionBlocks } from "./table/write"; @@ -27,7 +28,7 @@ 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, table/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. A ContentTable block is expanded by table/write.ts's flattenSectionBlocks into the same flat paragraph sequence every other block already is, each with its own terminator (a cell/row mark's own cell-mark character rather than the ordinary paragraph mark) and extra grpprl bytes (sprmPFInTable, and on a row's own mark, sprmPFTtp plus its whole TAP) -- so table paragraphs flow through the identical Chpx/Papx paging logic below as every other paragraph, not a separate table-only path. // -// What this writer does NOT do is stated in full in the README's own scope section, not only here: 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. Tables are written, but only at depth 1 (see table/write.ts) and without cell shading/borders or any other TAP layer document-schema.js's own ContentTable/ContentTableCell has no field for. +// What this writer does NOT do is stated in full in the README's own scope section, not only here: no images, no footnotes/headers/endnotes, no section boundaries beyond refusing more than one section (the one section's own page size and margins are written for real -- see below), 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. Tables are written, but only at depth 1 (see table/write.ts) and without cell shading/borders or any other TAP layer document-schema.js's own ContentTable/ContentTableCell has no field for. /** 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; @@ -173,9 +174,11 @@ export function writeDocContent( ); const papxPages = buildPapxPages(papxParagraphSpecs, textFcLim); - const wordDocument = new Uint8Array( - (papxPageStart + papxPages.length) * FKP_PAGE_SIZE, - ); + // The one section's own Sepx, [MS-DOC] 2.9.279 -- not an FKP-paged structure like the Chpx/Papx pages above, so it needs no page alignment and is simply appended after them. + const sepx = buildSepx(encodeSectionGrpprl(section)); + const fcSepx = (papxPageStart + papxPages.length) * FKP_PAGE_SIZE; + + const wordDocument = new Uint8Array(fcSepx + sepx.length); const wordView = new DataView(wordDocument.buffer); for (let index = 0; index < text.length; index += 1) { wordView.setUint16(characterFc(index), text.charCodeAt(index), true); @@ -186,6 +189,7 @@ export function writeDocContent( papxPages.forEach((page, index) => { wordDocument.set(page, (papxPageStart + index) * FKP_PAGE_SIZE); }); + wordDocument.set(sepx, fcSepx); // 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); @@ -200,6 +204,7 @@ export function writeDocContent( const stsh = buildEmptyStsh(); const fontTable = fontNames.length > 0 ? buildFontTable(fontNames) : undefined; + const plcfSed = buildPlcfSed(text.length, fcSepx); let cursor = 0; const place = (bytes: Uint8Array): number => { @@ -211,12 +216,14 @@ export function writeDocContent( const fcPlcfBteChpx = place(chpxBinTable); const fcPlcfBtePapx = place(papxBinTable); const fcStshf = place(stsh); + const fcPlcfSed = place(plcfSed); 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); + table.set(plcfSed, fcPlcfSed); if (fontTable !== undefined) table.set(fontTable, fcSttbfFfn); const fib = buildFib({ @@ -224,6 +231,8 @@ export function writeDocContent( cbMac: wordDocument.length, fcClx, lcbClx: clx.length, + fcPlcfSed, + lcbPlcfSed: plcfSed.length, fcPlcfBteChpx, lcbPlcfBteChpx: chpxBinTable.length, fcPlcfBtePapx, diff --git a/packages/doc-codec/test/smoke.test.mjs b/packages/doc-codec/test/smoke.test.mjs index eaed0ff4a..e851fcccd 100644 --- a/packages/doc-codec/test/smoke.test.mjs +++ b/packages/doc-codec/test/smoke.test.mjs @@ -86,6 +86,7 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/prop/fkp.js', exports: ['parseChpxFkp', 'parsePapxFkp', 'FKP_PAGE_SIZE'] }, { path: '../dist/prop/chp.js', exports: ['applyCharacterSprms'] }, { path: '../dist/prop/pap.js', exports: ['applyParagraphSprms'] }, + { path: '../dist/prop/sep.js', exports: ['readSectionProperties'] }, { path: '../dist/style/stsh.js', exports: ['parseStsh', 'headingLevelFromIstd', 'STK'] }, { path: '../dist/list/numbering.js', exports: ['readNumberingDefinitions'] }, { path: '../dist/read.js', exports: ['readDocContent', 'readDocStreams'] },