From 1a3847394b7b8b1b04d28f7c5c02920bd3c4fab0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 17:27:56 +0100 Subject: [PATCH 01/12] feat(archive-codec): add MS-OLEPS Property Set Stream read/write support Adds a generic reader and writer for the MS-OLEPS Property Set Stream format (stream header, PropertySet dictionary, and VT_I2/VT_I4/VT_LPSTR/ VT_LPWSTR/VT_FILETIME typed values), plus a SummaryInformation-specific layer mapping the seven fields a legacy binary Office document's title, author, and dates live in (title/subject/author/keywords/comments/ created/last-saved/last-printed) onto named PIDs. The generic reader accepts VT_LPSTR under either CP_WINUNICODE or windows-1252 (the two codepages real producers actually use), decoding each string up to its first null character per MS-OLEPS's own tolerance for embedded/trailing nulls. The writer only emits VT_LPWSTR strings, sidestepping the ANSI codepage-table question entirely since Unicode strings are codepage-independent; VT_LPSTR write is refused with a named error. NumPropertySets is restricted to 1 and PID 0 (the Dictionary property) is rejected, since neither ever appears in a real SummaryInformation stream and DocumentSummaryInformation's two-property- set, named-property spelling is an explicit, separately-scoped remainder. The primary reader test transcribes MS-OLEPS's own worked SummaryInformation Property Set example byte for byte, independently re-deriving the three FILETIME timestamps from the documented 100-nanosecond-since-1601 formula rather than trusting this package's own conversion. --- packages/archive-codec/README.md | 54 +++- packages/archive-codec/package.json | 2 +- packages/archive-codec/src/index.test.ts | 21 ++ packages/archive-codec/src/index.ts | 6 +- packages/archive-codec/src/oleps/read.test.ts | 242 ++++++++++++++++ packages/archive-codec/src/oleps/read.ts | 267 ++++++++++++++++++ .../src/oleps/summary-information.test.ts | 86 ++++++ .../src/oleps/summary-information.ts | 156 ++++++++++ packages/archive-codec/src/oleps/wire.ts | 101 +++++++ .../archive-codec/src/oleps/write.test.ts | 90 ++++++ packages/archive-codec/src/oleps/write.ts | 147 ++++++++++ .../archive-codec/src/test-support/oleps.ts | 166 +++++++++++ packages/archive-codec/test/smoke.test.mjs | 33 ++- .../test/workers/archive-codec.test.ts | 24 ++ 14 files changed, 1380 insertions(+), 15 deletions(-) create mode 100644 packages/archive-codec/src/oleps/read.test.ts create mode 100644 packages/archive-codec/src/oleps/read.ts create mode 100644 packages/archive-codec/src/oleps/summary-information.test.ts create mode 100644 packages/archive-codec/src/oleps/summary-information.ts create mode 100644 packages/archive-codec/src/oleps/wire.ts create mode 100644 packages/archive-codec/src/oleps/write.test.ts create mode 100644 packages/archive-codec/src/oleps/write.ts create mode 100644 packages/archive-codec/src/test-support/oleps.ts diff --git a/packages/archive-codec/README.md b/packages/archive-codec/README.md index c666d956c..8df433a0b 100644 --- a/packages/archive-codec/README.md +++ b/packages/archive-codec/README.md @@ -2,7 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/archive-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/archive-codec) [![npm version](https://img.shields.io/npm/v/archive-codec)](https://www.npmjs.com/package/archive-codec) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions) -> ZIP-in-ZIP recursive walking under depth and cumulative decompressed-size guards, and classic OLE compound-file ([MS-CFB]) reading and writing — zero document-format knowledge, the archive and container utility package for the [documents.js family](../../README.md). Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate. +> ZIP-in-ZIP recursive walking under depth and cumulative decompressed-size guards, classic OLE compound-file ([MS-CFB]) reading and writing, and [MS-OLEPS] Property Set Stream reading and writing — zero document-format knowledge, the archive and container utility package for the [documents.js family](../../README.md). Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate. Created for [documents.js#564](https://github.com/ExaDev/documents.js/issues/564): nothing in the ecosystem recursed into a nested archive. Most concretely, OOXML's embedded-object model — a docx/pptx carrying a genuinely separate ZIP blob at `word/embeddings/oleObject1.xlsx` — had no safe handling anywhere, and no package guarded against recursive-archive inputs at all (`byte-codec`'s 512 MiB per-stream inflate cap does not compose across recursion). A new sibling was chosen over extending `byte-codec` (whose charter is byte/image primitives, zero container-format knowledge) or doing it inline in `documents.js` (which would repeat the duplication `byte-codec`'s own extraction was meant to avoid). Its first family consumer is `ooxml.js`'s OLE embedded-object recovery — [documents.js#733](https://github.com/ExaDev/documents.js/issues/733) (pptx, `p:oleObj`) and [documents.js#734](https://github.com/ExaDev/documents.js/issues/734) (docx, `o:OLEObject`): an OLE payload part's bytes are checked through `isZipArchive` and, when they are a ZIP, decoded as a nested OOXML package behind this package's guarded walk — the bounded inflate that populates `document-schema.js`'s `ContentEmbeddedObject`/`ContentEmbeddedObjectBlock` (the same vocabulary odf.js embeds formula sub-documents through) with a genuinely recovered sub-document. @@ -10,7 +10,9 @@ Created for [documents.js#564](https://github.com/ExaDev/documents.js/issues/564 [documents.js#815](https://github.com/ExaDev/documents.js/issues/815), [#816](https://github.com/ExaDev/documents.js/issues/816), and [#817](https://github.com/ExaDev/documents.js/issues/817) then needed the other direction. `xls-codec`, `doc-codec`, `ppt-codec`, and `wpd-codec` each read a legacy Office binary format out of an [MS-CFB] container, and none of them can write one back, because there was no container to put their streams into: a `.xls` writer producing a `Workbook` stream, or a `.doc` writer producing `WordDocument` and `1Table`, needs a conformant compound file to hold them. That container is structural knowledge exactly as the reader's is, so `writeCompoundFile` is the mirror of `readCompoundFile` here rather than four hand-rolled emitters in four codecs. -Scope: **ZIP containers** (read and write over [`fflate`](https://github.com/101arrowz/fflate), recursive walking of ZIP-in-ZIP entries) and **classic OLE compound files** (bounded [MS-CFB] reading and conformant [MS-CFB] writing, plus the OLE Package stream unwrapping). **tar and gzip are explicitly out of scope.** +[documents.js#815](https://github.com/ExaDev/documents.js/issues/815), [#816](https://github.com/ExaDev/documents.js/issues/816), and [#817](https://github.com/ExaDev/documents.js/issues/817) also each named the same remaining gap: `doc-codec`, `xls-codec`, and `ppt-codec` all hard-coded document metadata (title, author, dates) to an empty object, because that metadata lives in a genuinely different structure from the one each format's own reader already parses -- a [MS-OLEPS] Property Set Stream, conventionally stored as a "\x05SummaryInformation" stream beside `WordDocument`/`Workbook`/`PowerPoint Document` in the identical [MS-CFB] container all three already read through this package. `oleps/read` and `oleps/write` are the generic property-set codec (the stream header, the PropertySet packet's dictionary, and VT_I2/VT_I4/VT_LPSTR/VT_LPWSTR/VT_FILETIME typed values), and `oleps/summary-information` is the SummaryInformation-specific mapping on top of it -- the same two-layer split `cfb/read.ts` and `cfb/ole-package.ts` already establish for the OLE Package stream, container structure below, one named stream's own field layout above. + +Scope: **ZIP containers** (read and write over [`fflate`](https://github.com/101arrowz/fflate), recursive walking of ZIP-in-ZIP entries), **classic OLE compound files** (bounded [MS-CFB] reading and conformant [MS-CFB] writing, plus the OLE Package stream unwrapping), and **[MS-OLEPS] Property Set Streams** (generic read/write of a single-property-set stream, plus SummaryInformation's own title/subject/author/keywords/comments/created/last-saved/last-printed fields). **tar and gzip, DocumentSummaryInformation's extended and user-defined property sets, and writing VT_LPSTR (ANSI-codepage) string properties are explicitly out of scope.** ## Getting started @@ -40,15 +42,18 @@ import { walkArchive } from "archive-codec/zip/walk"; The smoke suite (`test/smoke.test.mjs`) is the guard on that advertisement: it loads each module below from the built `dist/` in both module systems, so a build config that stops serving an advertised subpath fails the suite — neither publint nor `attw` catches a wildcard whose targets are missing. -| Module | Exports | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` | -| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` | -| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` | -| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) | -| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` | -| `cfb/write` | `writeCompoundFile` ([MS-CFB] container generation), `CompoundFileWriteError`, `WriteCompoundFileOptions` — takes the `CompoundFileStream` array `cfb/read` returns | -| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` | +| Module | Exports | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` | +| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` | +| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` | +| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) | +| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` | +| `cfb/write` | `writeCompoundFile` ([MS-CFB] container generation), `CompoundFileWriteError`, `WriteCompoundFileOptions` — takes the `CompoundFileStream` array `cfb/read` returns | +| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` | +| `oleps/read` | `readPropertySetStream` (generic [MS-OLEPS] property-set decoding), `PropertySetFormatError` | +| `oleps/write` | `writePropertySetStream` (generic [MS-OLEPS] property-set encoding), `PropertySetWriteError` — takes the `PropertySet` shape `oleps/read` returns | +| `oleps/summary-information` | `readSummaryInformation`, `writeSummaryInformationStream`, `SummaryInformationProperties`, `FMTID_SUMMARY_INFORMATION` | ### Recursive walking @@ -113,6 +118,31 @@ Two details are deliberate rather than incidental. The directory's sibling trees Correctness is checked against independent parsers, not only against this package's own reader: the written files are accepted by [`olefile`](https://github.com/decalage2/olefile) in its strict `DEFECT_INCORRECT` mode and by 7-Zip's Compound handler, both of which return byte-identical stream content, and a real LibreOffice-authored `.doc` read through `readCompoundFile` and re-emitted through `writeCompoundFile` still opens in LibreOffice Writer. +### Property sets + +```ts +import { + readCompoundFile, + readSummaryInformation, + writeSummaryInformationStream, +} from "archive-codec"; + +const stream = readCompoundFile(docBytes).find( + (s) => s.path === "\x05SummaryInformation", +); +if (stream !== undefined) { + const metadata = readSummaryInformation(stream.bytes); + metadata.title; // string | undefined + metadata.createdIso; // string | undefined, ISO-8601 +} + +// The mirror image: builds a "\x05SummaryInformation" stream's bytes from the +// same shape, to hand to writeCompoundFile alongside the format's own streams. +const summaryStream = writeSummaryInformationStream({ title: "Q3 report" }); +``` + +`readSummaryInformation`/`writeSummaryInformationStream` cover the seven SummaryInformation fields a caller actually needs (title, subject, author, keywords, comments, and the created/last-saved/last-printed FILETIME timestamps, as ISO-8601 strings); everything else the property set can carry (template, last author, revision number, application name, edit time, page/word/character counts, document security) is read into the stream but not projected into `SummaryInformationProperties`, and the separate `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. `readPropertySetStream`/`writePropertySetStream` are the generic layer beneath it — a `PropertySet`'s `formatId` and its `properties` map, keyed by `PropertyIdentifier`, valued by a `{ type, value }` pair over `VT_I2`/`VT_I4`/`VT_LPSTR`/`VT_LPWSTR`/`VT_FILETIME` — for a caller working with a different, non-SummaryInformation property set built on the identical [MS-OLEPS] wire format. The writer only emits `VT_LPWSTR` (Unicode) strings, never `VT_LPSTR`: a `CodePageString`'s ANSI encoding depends on the property set's own CodePage property, and writing an arbitrary codepage's bytes would need a full codepage table this package does not carry, so `VT_LPWSTR`'s codepage-independent UTF-16LE sidesteps the question entirely. The reader still decodes `VT_LPSTR` on the way in — `CP_WINUNICODE` (1200) and windows-1252 (1252, the value the [MS-OLEPS] SummaryInformation worked example itself declares, and the same ANSI convention `cfb/ole-package.ts` already uses) — since a real Office-authored file almost always writes ANSI strings, not Unicode ones. + ### ZIP container `zipPackage` takes an _ordered_ array of `[path, entry]` tuples, not a `Record`, so the caller controls the exact emission order deterministically (the property formats with a fixed-offset first entry — ODF's `mimetype` — depend on), and any entry can be written stored-uncompressed via `stored: true`. `unzipPackage` is the read side; the returned `Record` makes no ordering promise and collapses duplicate paths. @@ -121,7 +151,7 @@ Correctness is checked against independent parsers, not only against this packag - Worker-isomorphic (see the [family-wide convention](../../README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running the test suite inside an actual `workerd` isolate (`pnpm test:workers`). Test files under `src/**/*.test.ts` and `src/test-support/` are exempt and may use Node APIs for fixtures. - Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise. -- Zero document-format knowledge: this package knows bytes and container structure — ZIP entries, compound-file sectors and directory entries, the OLE packaging wrapper — never that any entry or stream is a document. It depends only on `fflate` — not on `byte-codec`, `ooxml.js`, or `odf.js` (whose ZIP wrappers it deliberately mirrors rather than imports, keeping their branding and release cadences decoupled). +- Zero document-format knowledge: this package knows bytes and container structure — ZIP entries, compound-file sectors and directory entries, the OLE packaging wrapper, [MS-OLEPS] property identifiers and typed values — never that any entry or stream is a document, or that PID 2 means a title. It depends only on `fflate` — not on `byte-codec`, `ooxml.js`, or `odf.js` (whose ZIP wrappers it deliberately mirrors rather than imports, keeping their branding and release cadences decoupled). ## Install diff --git a/packages/archive-codec/package.json b/packages/archive-codec/package.json index 6bf68760d..69bbdba38 100644 --- a/packages/archive-codec/package.json +++ b/packages/archive-codec/package.json @@ -1,7 +1,7 @@ { "name": "archive-codec", "version": "1.3.0", - "description": "ZIP-in-ZIP recursive walking with depth and cumulative decompressed-size guards, plus bounded classic OLE compound-file ([MS-CFB]) reading - zero document-format knowledge, the archive and container utility package for the documents.js family.", + "description": "ZIP-in-ZIP recursive walking with depth and cumulative decompressed-size guards, bounded classic OLE compound-file ([MS-CFB]) reading and writing, and [MS-OLEPS] Property Set Stream reading and writing - zero document-format knowledge, the archive and container utility package for the documents.js family.", "type": "module", "repository": { "type": "git", diff --git a/packages/archive-codec/src/index.test.ts b/packages/archive-codec/src/index.test.ts index 9d1df417f..a28b2a931 100644 --- a/packages/archive-codec/src/index.test.ts +++ b/packages/archive-codec/src/index.test.ts @@ -5,8 +5,10 @@ import { isCompoundFile, isZipArchive, readCompoundFile, + readSummaryInformation, unzipPackage, writeCompoundFile, + writeSummaryInformationStream, zipPackage, walkArchive, } from "./index"; @@ -49,4 +51,23 @@ describe("archive-codec barrel smoke", () => { ArchiveWalkLimitError, ); }); + + it("exposes the SummaryInformation property-set surface, composed with the compound-file surface", () => { + const summaryStream = writeSummaryInformationStream({ + title: "Barrel smoke", + author: "archive-codec", + }); + const bytes = writeCompoundFile([ + { path: "\x05SummaryInformation", bytes: summaryStream }, + ]); + const stream = readCompoundFile(bytes).find( + (s) => s.path === "\x05SummaryInformation", + ); + if (stream === undefined) { + throw new Error("expected a \\x05SummaryInformation stream"); + } + const metadata = readSummaryInformation(stream.bytes); + expect(metadata.title).toBe("Barrel smoke"); + expect(metadata.author).toBe("archive-codec"); + }); }); diff --git a/packages/archive-codec/src/index.ts b/packages/archive-codec/src/index.ts index 6892d0035..d1df4e6f9 100644 --- a/packages/archive-codec/src/index.ts +++ b/packages/archive-codec/src/index.ts @@ -1,8 +1,12 @@ -// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, and classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this. +// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in, and [MS-OLEPS] Property Set Stream reading and writing (generic, plus the SummaryInformation-specific mapping every legacy binary Office format's metadata lives in) -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this. export * from "./cfb/detect"; export * from "./cfb/ole-package"; export * from "./cfb/read"; export * from "./cfb/write"; +export * from "./oleps/read"; +export * from "./oleps/summary-information"; +export type { PropertySet, PropertyValue } from "./oleps/wire"; +export * from "./oleps/write"; export * from "./zip/container"; export * from "./zip/detect"; export * from "./zip/walk"; diff --git a/packages/archive-codec/src/oleps/read.test.ts b/packages/archive-codec/src/oleps/read.test.ts new file mode 100644 index 000000000..1cea5c519 --- /dev/null +++ b/packages/archive-codec/src/oleps/read.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { propertySetStream } from "../test-support/oleps"; +import { PropertySetFormatError, readPropertySetStream } from "./read"; + +// Coverage for the generic [MS-OLEPS] Property Set Stream reader (src/oleps/read.ts). The primary fixture below is transcribed byte-for-byte from [MS-OLEPS]'s own worked "SummaryInformation Property Set" example (the stream contents table in the spec's SummaryInformation Property Set section) -- the strongest possible validation, since it proves this reader parses a real, complete, unmodified 444-byte stream a genuine implementation produced, not merely bytes this reader's own writer happens to agree with itself about. It exercises every property type this reader supports (VT_I2 for CodePage, VT_LPSTR for every string property, VT_FILETIME for every timestamp, VT_I4 for every count) in one pass. Additional fixtures below it, built via ../test-support/oleps.ts (independent of ./write.ts's own construction), cover round-trip correctness for values this reader's own numbers can be hand-verified against, and the structural error paths. + +const FMTID_SUMMARY_INFORMATION = "{F29F85E0-4FF9-1068-AB91-08002B27B3D9}"; + +// prettier-ignore +const SUMMARY_INFORMATION_WORKED_EXAMPLE = new Uint8Array([ + // 00x + 0xfe, 0xff, 0x00, 0x00, 0x06, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // 01x + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x85, 0x9f, 0xf2, + // 02x + 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9, 0x30, 0x00, 0x00, 0x00, + // 03x + 0x8c, 0x01, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x98, 0x00, 0x00, 0x00, + // 04x + 0x02, 0x00, 0x00, 0x00, 0xa0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0xb8, 0x00, 0x00, 0x00, + // 05x + 0x04, 0x00, 0x00, 0x00, 0xc4, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0xd0, 0x00, 0x00, 0x00, + // 06x + 0x06, 0x00, 0x00, 0x00, 0xdc, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0xe8, 0x00, 0x00, 0x00, + // 07x + 0x08, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, + // 08x + 0x12, 0x00, 0x00, 0x00, 0x1c, 0x01, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x3c, 0x01, 0x00, 0x00, + // 09x + 0x0b, 0x00, 0x00, 0x00, 0x48, 0x01, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00, + // 0Ax + 0x0d, 0x00, 0x00, 0x00, 0x60, 0x01, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x6c, 0x01, 0x00, 0x00, + // 0Bx + 0x0f, 0x00, 0x00, 0x00, 0x74, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x7c, 0x01, 0x00, 0x00, + // 0Cx + 0x13, 0x00, 0x00, 0x00, 0x84, 0x01, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0xe4, 0x04, 0x00, 0x00, + // 0Dx + 0x1e, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x4a, 0x6f, 0x65, 0x27, 0x73, 0x20, 0x64, 0x6f, + // 0Ex + 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, + // 0Fx + 0x4a, 0x6f, 0x62, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x4a, 0x6f, 0x65, 0x00, + // 10x + 0x1e, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, + // 11x + 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, + // 12x + 0x4e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x2e, 0x64, 0x6f, 0x74, 0x6d, 0x00, 0x1e, 0x00, 0x00, 0x00, + // 13x + 0x0a, 0x00, 0x00, 0x00, 0x43, 0x6f, 0x72, 0x6e, 0x65, 0x6c, 0x69, 0x75, 0x73, 0x00, 0x00, 0x00, + // 14x + 0x1e, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x36, 0x36, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, + // 15x + 0x18, 0x00, 0x00, 0x00, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x20, 0x4f, 0x66, + // 16x + 0x66, 0x69, 0x63, 0x65, 0x20, 0x57, 0x6f, 0x72, 0x64, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, + // 17x + 0x00, 0x6e, 0xd9, 0xa2, 0x42, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x16, 0xd0, 0xa1, + // 18x + 0x4e, 0x8e, 0xc6, 0x01, 0x40, 0x00, 0x00, 0x00, 0x00, 0x1c, 0xf2, 0xd5, 0x2a, 0xce, 0xc6, 0x01, + // 19x + 0x40, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xdc, 0x73, 0xdd, 0x80, 0xc8, 0x01, 0x03, 0x00, 0x00, 0x00, + // 1Ax + 0x0e, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0xe5, 0x0d, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + // 1Bx (only 12 bytes: the stream ends at offset 0x1BC) + 0x38, 0x4f, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +// 100-nanosecond intervals since 1601-01-01T00:00:00Z to 1970-01-01T00:00:00Z -- reimplemented independently here (rather than imported from ./wire.ts) so this expected-value derivation genuinely checks the reader's output against the documented [MS-OLEPS]/[MS-DTYP] FILETIME formula, not against this package's own conversion. +function expectedFiletimeIso(low: bigint, high: bigint): string { + const ticks = (high << 32n) | low; + const ms = (ticks - 116444736000000000n) / 10000n; + return new Date(Number(ms)).toISOString(); +} + +describe("readPropertySetStream", () => { + it("parses [MS-OLEPS]'s own worked SummaryInformation Property Set example in full", () => { + const propertySet = readPropertySetStream( + SUMMARY_INFORMATION_WORKED_EXAMPLE, + ); + expect(propertySet.formatId).toBe(FMTID_SUMMARY_INFORMATION); + expect(propertySet.properties.size).toBe(18); + + expect(propertySet.properties.get(1)).toEqual({ + type: "VT_I2", + value: 1252, + }); // PID_CODEPAGE + expect(propertySet.properties.get(2)).toEqual({ + type: "VT_LPSTR", + value: "Joe's document", + }); // PIDSI_TITLE + expect(propertySet.properties.get(3)).toEqual({ + type: "VT_LPSTR", + value: "Job", + }); // PIDSI_SUBJECT + expect(propertySet.properties.get(4)).toEqual({ + type: "VT_LPSTR", + value: "Joe", + }); // PIDSI_AUTHOR + expect(propertySet.properties.get(5)).toEqual({ + type: "VT_LPSTR", + value: "", + }); // PIDSI_KEYWORDS + expect(propertySet.properties.get(6)).toEqual({ + type: "VT_LPSTR", + value: "", + }); // PIDSI_COMMENTS + expect(propertySet.properties.get(7)).toEqual({ + type: "VT_LPSTR", + value: "Normal.dotm", + }); // PIDSI_TEMPLATE + expect(propertySet.properties.get(8)).toEqual({ + type: "VT_LPSTR", + value: "Cornelius", + }); // PIDSI_LASTAUTHOR + expect(propertySet.properties.get(9)).toEqual({ + type: "VT_LPSTR", + value: "66", + }); // PIDSI_REVNUMBER + expect(propertySet.properties.get(0x12)).toEqual({ + type: "VT_LPSTR", + value: "Microsoft Office Word", + }); // PIDSI_APPNAME + + expect(propertySet.properties.get(14)).toEqual({ + type: "VT_I4", + value: 14, + }); // PIDSI_PAGECOUNT + expect(propertySet.properties.get(15)).toEqual({ + type: "VT_I4", + value: 3557, + }); // PIDSI_WORDCOUNT + expect(propertySet.properties.get(16)).toEqual({ + type: "VT_I4", + value: 20280, + }); // PIDSI_CHARCOUNT + expect(propertySet.properties.get(0x13)).toEqual({ + type: "VT_I4", + value: 0, + }); // PIDSI_DOC_SECURITY + + const lastPrinted = propertySet.properties.get(11); // PIDSI_LASTPRINTED + expect(lastPrinted?.type).toBe("VT_FILETIME"); + expect((lastPrinted?.value as Date).toISOString()).toBe( + expectedFiletimeIso(0xa1d01600n, 0x01c68e4en), + ); + + const created = propertySet.properties.get(12); // PIDSI_CREATE_DTM + expect(created?.type).toBe("VT_FILETIME"); + expect((created?.value as Date).toISOString()).toBe( + expectedFiletimeIso(0xd5f21c00n, 0x01c6ce2an), + ); + + const lastSaved = propertySet.properties.get(13); // PIDSI_LASTSAVE_DTM + expect(lastSaved?.type).toBe("VT_FILETIME"); + expect((lastSaved?.value as Date).toISOString()).toBe( + expectedFiletimeIso(0x73dc3c00n, 0x01c880ddn), + ); + }); + + it("round-trips a hand-built VT_LPWSTR (Unicode) string property with CP_WINUNICODE declared", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 1, value: { type: "VT_I2", value: 1200 } }, + { pid: 2, value: { type: "VT_LPWSTR", value: "Café Über" } }, + ]); + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.get(2)).toEqual({ + type: "VT_LPWSTR", + value: "Café Über", + }); + }); + + it("decodes a VT_LPSTR (CodePageString) as UTF-16LE when CodePage declares CP_WINUNICODE", () => { + // A CodePageString's own encoding follows the CodePage property, not its own type tag: with CP_WINUNICODE declared, VT_LPSTR becomes a UTF-16LE array too ([MS-OLEPS] 2.19). + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 1, value: { type: "VT_I2", value: 1200 } }, + { pid: 2, value: { type: "VT_LPSTR_UTF16", value: "Café Über" } }, + ]); + expect(readPropertySetStream(bytes).properties.get(2)).toEqual({ + type: "VT_LPSTR", + value: "Café Über", + }); + }); + + it("decodes a VT_LPSTR (CodePageString) as windows-1252 when CodePage is absent", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 2, value: { type: "VT_LPSTR", value: "plain title" } }, + ]); + expect(readPropertySetStream(bytes).properties.get(2)).toEqual({ + type: "VT_LPSTR", + value: "plain title", + }); + }); + + it("throws PropertySetFormatError for a VT_LPSTR under a CodePage this reader does not decode", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 1, value: { type: "VT_I2", value: 932 } }, // Shift-JIS -- neither CP_WINUNICODE nor windows-1252 + { pid: 2, value: { type: "VT_LPSTR", value: "x" } }, + ]); + expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError for a ByteOrder field other than 0xFFFE", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 2, value: { type: "VT_LPWSTR", value: "x" } }, + ]); + bytes.set([0x00, 0x00], 0); + expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError for NumPropertySets other than 1", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 2, value: { type: "VT_LPWSTR", value: "x" } }, + ]); + const view = new DataView(bytes.buffer); + view.setUint32(24, 2, true); + expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError for a Dictionary property (PID 0)", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 0, value: { type: "VT_LPWSTR", value: "x" } }, + ]); + expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError for a property type this reader does not decode", () => { + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 2, value: { type: "VT_I4", value: 1 } }, + ]); + const view = new DataView(bytes.buffer); + // The dictionary/value pair are already well-formed for VT_I4; corrupt the Type field alone to an unsupported code (VT_BOOL, 0x000B) without touching the value bytes. + view.setUint16(48 + 8 + 8, 0x000b, true); + expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError when a stream is shorter than the fixed header", () => { + expect(() => readPropertySetStream(new Uint8Array(10))).toThrow( + PropertySetFormatError, + ); + }); +}); diff --git a/packages/archive-codec/src/oleps/read.ts b/packages/archive-codec/src/oleps/read.ts new file mode 100644 index 000000000..6113a66f3 --- /dev/null +++ b/packages/archive-codec/src/oleps/read.ts @@ -0,0 +1,267 @@ +import { + BYTE_ORDER_MARK, + CP_WINUNICODE, + HEADER_SIZE, + IDENTIFIER_AND_OFFSET_SIZE, + PID_CODEPAGE, + PID_DICTIONARY, + PROPERTY_SET_HEADER_SIZE, + TYPED_VALUE_HEADER_SIZE, + VT_FILETIME, + VT_I2, + VT_I4, + VT_LPSTR, + VT_LPWSTR, + WINDOWS_1252_CODEPAGE, + filetimeToDate, + readGuid, + type PropertySet, + type PropertyValue, +} from "./wire"; + +// A generic reader for the [MS-OLEPS] Property Set Stream format: the stream header, the single PropertySet packet it names (Size, NumProperties, the PropertyIdentifierAndOffset dictionary, and the typed property values themselves), for VT_I2, VT_I4, VT_LPSTR, VT_LPWSTR, and VT_FILETIME -- the five PropertyType values that cover every property a real [MS-OSHARED] SummaryInformation stream carries (title/subject/author/keywords/comments/template/lastAuthor/appName as strings, created/lastSaved/lastPrinted/editTime as FILETIMEs, pageCount/wordCount/charCount/docSecurity as VT_I4, codePage as VT_I2), so this reader parses a whole real-world stream even though ./summary-information.ts only projects a subset of it into named fields. Zero document-format knowledge: it knows property identifiers and typed values, never that PID 2 means a title or that this stream is conventionally named "\x05SummaryInformation" -- that mapping lives one level up, in ./summary-information.ts, the same layering cfb/ole-package.ts gives the OLE Package stream on top of the generic CFB reader in ../cfb/read.ts. +// +// Two genuine [MS-OLEPS] features are out of scope, deliberately, rather than by oversight: a PropertySetStream can carry two property sets in one physical stream (2.21 -- how DocumentSummaryInformation and its UserDefinedProperties share a stream), and a property set can carry named, dictionary-keyed properties (via PID 0, the Dictionary property) rather than purely numeric ones. Neither ever appears in a "\x05SummaryInformation" stream -- SummaryInformation is always exactly one property set, and its properties are always identified numerically -- so a reader that rejects both stays honest about not reading DocumentSummaryInformation while still parsing every real SummaryInformation stream in full. + +export class PropertySetFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "PropertySetFormatError"; + } +} + +function requireBytes( + byteLength: number, + offset: number, + length: number, + what: string, +): void { + if (offset < 0 || length < 0 || offset + length > byteLength) { + throw new PropertySetFormatError( + `property set stream ends before ${what} (needs ${length} bytes at offset ${offset}, stream is ${byteLength} bytes)`, + ); + } +} + +const ANSI_DECODER = new TextDecoder("windows-1252"); +const UTF16_DECODER = new TextDecoder("utf-16le"); + +function decodeAnsi(bytes: Uint8Array, codepage: number): string { + if (codepage !== WINDOWS_1252_CODEPAGE) { + throw new PropertySetFormatError( + `property set declares CodePage ${codepage}, which this reader does not decode (only CP_WINUNICODE/1200 and windows-1252/1252 are supported)`, + ); + } + return ANSI_DECODER.decode(bytes); +} + +// [MS-OLEPS] 2.19/2.20: both string packets MAY carry embedded or additional trailing null characters beyond the first terminator, and how a reader "presents" such a string to its application is implementation-specific. This one truncates at the first null code unit -- what every string ./write.ts and ./summary-information.ts actually produce needs (a plain string, no embedded nulls), and what the spec's own worked SummaryInformation example requires to read an empty property back as "" rather than as embedded NUL characters (its KEYWORDS property is four zero bytes: Size 4, not the minimal Size 1 a null-terminator-only empty string would use). +function truncateAtNull(value: string): string { + const index = value.indexOf("\u0000"); + return index === -1 ? value : value.slice(0, index); +} + +// [MS-OLEPS] 2.19 CodePageString: Size(4) is the byte length of Characters including its null terminator but excluding padding; Characters is that many bytes, ANSI- or UTF-16LE-encoded depending on the property set's own CodePage property, padded to a 4-byte boundary. +function readCodePageString( + bytes: Uint8Array, + view: DataView, + offset: number, + codepage: number, +): string { + requireBytes(bytes.length, offset, 4, "a CodePageString's Size field"); + const size = view.getUint32(offset, true); + requireBytes( + bytes.length, + offset + 4, + size, + "a CodePageString's Characters field", + ); + const raw = bytes.subarray(offset + 4, offset + 4 + size); + return codepage === CP_WINUNICODE + ? truncateAtNull(UTF16_DECODER.decode(raw)) + : truncateAtNull(decodeAnsi(raw, codepage)); +} + +// [MS-OLEPS] 2.20 UnicodeString: Length(4) is the UTF-16 code-unit count of Characters including its null terminator but excluding padding; Characters is that many 16-bit units, always UTF-16LE regardless of the property set's CodePage property, padded to a 4-byte boundary. +function readUnicodeString( + bytes: Uint8Array, + view: DataView, + offset: number, +): string { + requireBytes(bytes.length, offset, 4, "a UnicodeString's Length field"); + const units = view.getUint32(offset, true); + const byteLength = units * 2; + requireBytes( + bytes.length, + offset + 4, + byteLength, + "a UnicodeString's Characters field", + ); + const raw = bytes.subarray(offset + 4, offset + 4 + byteLength); + return truncateAtNull(UTF16_DECODER.decode(raw)); +} + +interface DictionaryEntry { + readonly pid: number; + readonly relativeOffset: number; +} + +// Parses a [MS-OLEPS] Property Set Stream: the header (validating ByteOrder and the single-property-set requirement above), the PropertySet packet's dictionary, and every property's typed value. Throws PropertySetFormatError on any structural nonconformance or on a property type this reader does not decode -- loud failure, never a partial property map that looks complete. +export function readPropertySetStream( + bytes: Uint8Array, +): PropertySet { + requireBytes(bytes.length, 0, HEADER_SIZE, "the PropertySetStream header"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const byteOrder = view.getUint16(0, true); + if (byteOrder !== BYTE_ORDER_MARK) { + throw new PropertySetFormatError( + `property set stream's ByteOrder field is 0x${byteOrder.toString(16)}, not the mandated 0xFFFE`, + ); + } + const numPropertySets = view.getUint32(24, true); + if (numPropertySets !== 1) { + throw new PropertySetFormatError( + `property set stream declares ${numPropertySets} property sets; this reader only handles the single-property-set form every "\\x05SummaryInformation" stream uses (the two-property-set DocumentSummaryInformation/UserDefinedProperties spelling is out of scope, see the package README)`, + ); + } + const formatId = readGuid(view, 28); + const offset0 = view.getUint32(44, true); + + requireBytes( + bytes.length, + offset0, + PROPERTY_SET_HEADER_SIZE, + "the PropertySet packet header", + ); + const size = view.getUint32(offset0, true); + requireBytes( + bytes.length, + offset0, + size, + "the PropertySet packet's own declared Size", + ); + const numProperties = view.getUint32(offset0 + 4, true); + + const tableStart = offset0 + PROPERTY_SET_HEADER_SIZE; + requireBytes( + bytes.length, + tableStart, + numProperties * IDENTIFIER_AND_OFFSET_SIZE, + "the PropertyIdentifierAndOffset dictionary", + ); + const entries: DictionaryEntry[] = []; + for (let i = 0; i < numProperties; i++) { + const entryOffset = tableStart + i * IDENTIFIER_AND_OFFSET_SIZE; + const pid = view.getUint32(entryOffset, true); + if (pid === PID_DICTIONARY) { + throw new PropertySetFormatError( + 'property set carries a Dictionary property (PID 0), which names string-keyed properties this reader does not support -- no "\\x05SummaryInformation" stream should carry one', + ); + } + entries.push({ + pid, + relativeOffset: view.getUint32(entryOffset + 4, true), + }); + } + + // Two-pass: the CodePage property governs how every VT_LPSTR value in the SAME property set decodes, so it must be resolved before any string is read, regardless of where the dictionary lists PID 1 relative to the properties that need it. Absent CodePage is treated as windows-1252, the overwhelmingly common real-world default, rather than refused outright -- a stream a real producer wrote without one should still read. + let codepage = WINDOWS_1252_CODEPAGE; + for (const entry of entries) { + if (entry.pid !== PID_CODEPAGE) continue; + const abs = offset0 + entry.relativeOffset; + requireBytes( + bytes.length, + abs, + TYPED_VALUE_HEADER_SIZE + 4, + "the CodePage property's TypedPropertyValue", + ); + const type = view.getUint16(abs, true); + if (type !== VT_I2) { + throw new PropertySetFormatError( + `CodePage property (PID 1) has type 0x${type.toString(16)}, not VT_I2 as [MS-OLEPS] requires`, + ); + } + const raw = view.getInt16(abs + TYPED_VALUE_HEADER_SIZE, true); + // Codepages above 32767 are conventionally stored as their negative 16-bit twos-complement equivalent, since VT_I2's own Value is a signed integer. + codepage = raw < 0 ? raw + 0x10000 : raw; + } + + const properties = new Map(); + for (const entry of entries) { + const abs = offset0 + entry.relativeOffset; + requireBytes( + bytes.length, + abs, + TYPED_VALUE_HEADER_SIZE, + "a property's TypedPropertyValue header", + ); + const type = view.getUint16(abs, true); + const padding = view.getUint16(abs + 2, true); + if (padding !== 0) { + throw new PropertySetFormatError( + `property ${entry.pid}'s TypedPropertyValue padding is 0x${padding.toString(16)}, not zero as [MS-OLEPS] requires`, + ); + } + const valueOffset = abs + TYPED_VALUE_HEADER_SIZE; + switch (type) { + case VT_I2: { + requireBytes( + bytes.length, + valueOffset, + 4, + `property ${entry.pid}'s VT_I2 value`, + ); + properties.set(entry.pid, { + type: "VT_I2", + value: view.getInt16(valueOffset, true), + }); + break; + } + case VT_I4: { + requireBytes( + bytes.length, + valueOffset, + 4, + `property ${entry.pid}'s VT_I4 value`, + ); + properties.set(entry.pid, { + type: "VT_I4", + value: view.getInt32(valueOffset, true), + }); + break; + } + case VT_LPSTR: { + const value = readCodePageString(bytes, view, valueOffset, codepage); + properties.set(entry.pid, { type: "VT_LPSTR", value }); + break; + } + case VT_LPWSTR: { + const value = readUnicodeString(bytes, view, valueOffset); + properties.set(entry.pid, { type: "VT_LPWSTR", value }); + break; + } + case VT_FILETIME: { + requireBytes( + bytes.length, + valueOffset, + 8, + `property ${entry.pid}'s VT_FILETIME value`, + ); + const low = view.getUint32(valueOffset, true); + const high = view.getUint32(valueOffset + 4, true); + properties.set(entry.pid, { + type: "VT_FILETIME", + value: filetimeToDate(low, high), + }); + break; + } + default: + throw new PropertySetFormatError( + `property ${entry.pid} has type 0x${type.toString(16)}, which this reader does not decode (supported: VT_I2, VT_I4, VT_LPSTR, VT_LPWSTR, VT_FILETIME)`, + ); + } + } + + return { formatId, properties }; +} diff --git a/packages/archive-codec/src/oleps/summary-information.test.ts b/packages/archive-codec/src/oleps/summary-information.test.ts new file mode 100644 index 000000000..971c2821b --- /dev/null +++ b/packages/archive-codec/src/oleps/summary-information.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { PropertySetFormatError, readPropertySetStream } from "./read"; +import { + FMTID_SUMMARY_INFORMATION, + readSummaryInformation, + writeSummaryInformationStream, +} from "./summary-information"; +import { writePropertySetStream } from "./write"; + +describe("readSummaryInformation / writeSummaryInformationStream", () => { + it("round-trips every field this module covers", () => { + const metadata = { + title: "Q3 report", + subject: "Finance", + author: "Joe", + keywords: ["quarterly", "finance", "report"], + comments: "Draft for review", + createdIso: "2024-01-15T09:00:00.000Z", + lastSavedIso: "2024-03-20T14:30:00.000Z", + lastPrintedIso: "2024-03-21T08:00:00.000Z", + }; + const bytes = writeSummaryInformationStream(metadata); + expect(readSummaryInformation(bytes)).toEqual(metadata); + }); + + it("writes and reads back an honestly-empty stream for {}", () => { + const bytes = writeSummaryInformationStream({}); + expect(readSummaryInformation(bytes)).toEqual({}); + }); + + it("omits a field entirely rather than writing it as an empty/zero placeholder", () => { + const bytes = writeSummaryInformationStream({ title: "Only a title" }); + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.has(3)).toBe(false); // PIDSI_SUBJECT + expect(propertySet.properties.has(4)).toBe(false); // PIDSI_AUTHOR + expect(propertySet.properties.has(12)).toBe(false); // PIDSI_CREATE_DTM + }); + + it("joins keywords with ', ' and splits them back apart, dropping empty entries", () => { + const bytes = writeSummaryInformationStream({ keywords: ["a", "b", "c"] }); + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.get(5)).toEqual({ + type: "VT_LPWSTR", + value: "a, b, c", + }); + expect(readSummaryInformation(bytes).keywords).toEqual(["a", "b", "c"]); + }); + + it("declares CP_WINUNICODE as its CodePage property", () => { + const bytes = writeSummaryInformationStream({ title: "x" }); + expect(readPropertySetStream(bytes).properties.get(1)).toEqual({ + type: "VT_I2", + value: 1200, + }); + }); + + it("reads a zero FILETIME (the conventional 'never printed' spelling) back as absent", () => { + // [MS-OLEPS] 2.15: an all-zero FILETIME (low=0, high=0) decodes to the FILETIME epoch itself, 1601-01-01T00:00:00Z -- the value a real producer writes for "never printed" rather than omitting PIDSI_LASTPRINTED outright. + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties: new Map([ + [ + 11, + { type: "VT_FILETIME", value: new Date("1601-01-01T00:00:00.000Z") }, + ], + ]), // PIDSI_LASTPRINTED + }); + expect(readSummaryInformation(bytes).lastPrintedIso).toBeUndefined(); + }); + + it("throws PropertySetFormatError for a stream whose FMTID is not FMTID_SummaryInformation", () => { + const bytes = writePropertySetStream({ + formatId: "{D5CDD502-2E9C-101B-9397-08002B2CF9AE}", // FMTID_DocSummaryInformation + properties: new Map([[2, { type: "VT_LPWSTR", value: "x" }]]), + }); + expect(() => readSummaryInformation(bytes)).toThrow(PropertySetFormatError); + }); + + it("throws PropertySetFormatError when a known field's property has the wrong type", () => { + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties: new Map([[2, { type: "VT_I4", value: 1 }]]), // PIDSI_TITLE, wrong type + }); + expect(() => readSummaryInformation(bytes)).toThrow(PropertySetFormatError); + }); +}); diff --git a/packages/archive-codec/src/oleps/summary-information.ts b/packages/archive-codec/src/oleps/summary-information.ts new file mode 100644 index 000000000..70dd026bf --- /dev/null +++ b/packages/archive-codec/src/oleps/summary-information.ts @@ -0,0 +1,156 @@ +import { PropertySetFormatError, readPropertySetStream } from "./read"; +import { CP_WINUNICODE, PID_CODEPAGE, type PropertyValue } from "./wire"; +import { writePropertySetStream } from "./write"; + +// The one named [MS-OLEPS] property set every legacy binary Office document ([MS-OSHARED] 2.3.3.2.2) carries its title/author/dates in: the fixed SummaryInformation property set, conventionally stored as a "\x05SummaryInformation" stream in the document's own [MS-CFB] compound file. This is the layer that knows PID 2 means a title -- ../cfb/read.ts and ./read.ts/./write.ts below it know nothing about SummaryInformation specifically, exactly as ../cfb/ole-package.ts knows the OLE Package stream's own field layout while ../cfb/read.ts knows only generic compound-file structure. +// +// Deliberately narrower than the full SummaryInformation property set [MS-OLEPS] 3.1 documents: only the seven fields a caller (doc-codec, xls-codec, ppt-codec) actually needs are read and written -- title, subject, author, keywords, comments, and the three FILETIME timestamps (created, last saved, last printed). PIDSI_TEMPLATE, PIDSI_LASTAUTHOR, PIDSI_REVNUMBER, PIDSI_APPNAME, PIDSI_EDITTIME, PIDSI_PAGECOUNT, PIDSI_WORDCOUNT, PIDSI_CHARCOUNT, and PIDSI_DOC_SECURITY are not read or written -- an honest, explicitly out-of-scope remainder, alongside DocumentSummaryInformation's own extended and user-defined property sets (a different stream, "\x05DocumentSummaryInformation", carrying company/manager/custom properties -- not attempted at all). +// +// KEYWORDS is [MS-OLEPS] 2.19/2.20's own single free-text string, not a vector -- there is no delimiter [MS-OLEPS] mandates, so this joins/splits on ", ", the convention already established for the identical shape in ooxml.js's cp:keywords and pdf-codec's /Keywords (see packages/ooxml.js/src/typed/shared/metadata.ts and packages/pdf-codec/src/read.ts). + +// [MS-OLEPS] 3.1: the FMTID every SummaryInformation property set declares as its FMTID0. +export const FMTID_SUMMARY_INFORMATION = + "{F29F85E0-4FF9-1068-AB91-08002B27B3D9}"; + +// [MS-OSHARED] 2.3.3.2.2 / [MS-OLEPS] 3.1: the property identifiers this module reads and writes, verified against the spec's own worked SummaryInformation Property Set example. +const PID_TITLE = 2; +const PID_SUBJECT = 3; +const PID_AUTHOR = 4; +const PID_KEYWORDS = 5; +const PID_COMMENTS = 6; +const PID_LASTPRINTED = 11; +const PID_CREATE_DTM = 12; +const PID_LASTSAVE_DTM = 13; + +const KEYWORDS_DELIMITER = ", "; + +export interface SummaryInformationProperties { + readonly title?: string; + readonly subject?: string; + readonly author?: string; + readonly keywords?: readonly string[]; + readonly comments?: string; + readonly createdIso?: string; + readonly lastSavedIso?: string; + readonly lastPrintedIso?: string; +} + +function splitKeywords(value: string): string[] | undefined { + const parts = value + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + return parts.length > 0 ? parts : undefined; +} + +function stringValue( + value: PropertyValue | undefined, + pid: number, +): string | undefined { + if (value === undefined) return undefined; + if (value.type !== "VT_LPSTR" && value.type !== "VT_LPWSTR") { + throw new PropertySetFormatError( + `SummaryInformation property ${pid} has type ${value.type}, not a string type as [MS-OLEPS]'s SummaryInformation Property Set defines`, + ); + } + return value.value.length > 0 ? value.value : undefined; +} + +// [MS-OLEPS] 2.15: a producer that has never printed/saved/created-tracked a document conventionally writes an all-zero FILETIME (decoding to the FILETIME epoch itself, 1601-01-01) for that PID rather than omitting the property. Reading that back as absent, not as the year 1601, matches how a caller should only ever see a genuinely-set timestamp. +const FILETIME_UNSET_ISO = "1601-01-01T00:00:00.000Z"; + +function dateIsoValue( + value: PropertyValue | undefined, + pid: number, +): string | undefined { + if (value === undefined) return undefined; + if (value.type !== "VT_FILETIME") { + throw new PropertySetFormatError( + `SummaryInformation property ${pid} has type ${value.type}, not VT_FILETIME as [MS-OLEPS]'s SummaryInformation Property Set defines`, + ); + } + const iso = value.value.toISOString(); + return iso === FILETIME_UNSET_ISO ? undefined : iso; +} + +// Parses a "\x05SummaryInformation" stream's bytes into the seven fields this module covers. Throws PropertySetFormatError if the stream is not a SummaryInformation property set (wrong FMTID) or structurally malformed; a property this module does not project (see the scope note above) is present in the stream but simply not read. +export function readSummaryInformation( + bytes: Uint8Array, +): SummaryInformationProperties { + const propertySet = readPropertySetStream(bytes); + if (propertySet.formatId !== FMTID_SUMMARY_INFORMATION) { + throw new PropertySetFormatError( + `property set stream declares FMTID ${propertySet.formatId}, not FMTID_SummaryInformation (${FMTID_SUMMARY_INFORMATION}); this is not a "\\x05SummaryInformation" stream`, + ); + } + const { properties } = propertySet; + const keywords = stringValue(properties.get(PID_KEYWORDS), PID_KEYWORDS); + return { + title: stringValue(properties.get(PID_TITLE), PID_TITLE), + subject: stringValue(properties.get(PID_SUBJECT), PID_SUBJECT), + author: stringValue(properties.get(PID_AUTHOR), PID_AUTHOR), + keywords: keywords === undefined ? undefined : splitKeywords(keywords), + comments: stringValue(properties.get(PID_COMMENTS), PID_COMMENTS), + createdIso: dateIsoValue(properties.get(PID_CREATE_DTM), PID_CREATE_DTM), + lastSavedIso: dateIsoValue( + properties.get(PID_LASTSAVE_DTM), + PID_LASTSAVE_DTM, + ), + lastPrintedIso: dateIsoValue( + properties.get(PID_LASTPRINTED), + PID_LASTPRINTED, + ), + }; +} + +// Builds a well-formed "\x05SummaryInformation" stream's bytes from the same shape readSummaryInformation returns. Only the fields actually present are written -- a real SummaryInformation stream need not carry every property, and a caller wanting an honestly-empty stream (just the CodePage property every real producer includes) can pass {}. +export function writeSummaryInformationStream( + properties: SummaryInformationProperties, +): Uint8Array { + const entries = new Map(); + // CP_WINUNICODE, since every string this module writes is VT_LPWSTR (see ./write.ts's own scope note on why it never writes VT_LPSTR). + entries.set(PID_CODEPAGE, { type: "VT_I2", value: CP_WINUNICODE }); + if (properties.title !== undefined) { + entries.set(PID_TITLE, { type: "VT_LPWSTR", value: properties.title }); + } + if (properties.subject !== undefined) { + entries.set(PID_SUBJECT, { type: "VT_LPWSTR", value: properties.subject }); + } + if (properties.author !== undefined) { + entries.set(PID_AUTHOR, { type: "VT_LPWSTR", value: properties.author }); + } + if (properties.keywords !== undefined && properties.keywords.length > 0) { + entries.set(PID_KEYWORDS, { + type: "VT_LPWSTR", + value: properties.keywords.join(KEYWORDS_DELIMITER), + }); + } + if (properties.comments !== undefined) { + entries.set(PID_COMMENTS, { + type: "VT_LPWSTR", + value: properties.comments, + }); + } + if (properties.createdIso !== undefined) { + entries.set(PID_CREATE_DTM, { + type: "VT_FILETIME", + value: new Date(properties.createdIso), + }); + } + if (properties.lastSavedIso !== undefined) { + entries.set(PID_LASTSAVE_DTM, { + type: "VT_FILETIME", + value: new Date(properties.lastSavedIso), + }); + } + if (properties.lastPrintedIso !== undefined) { + entries.set(PID_LASTPRINTED, { + type: "VT_FILETIME", + value: new Date(properties.lastPrintedIso), + }); + } + return writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties: entries, + }); +} diff --git a/packages/archive-codec/src/oleps/wire.ts b/packages/archive-codec/src/oleps/wire.ts new file mode 100644 index 000000000..bb6b04721 --- /dev/null +++ b/packages/archive-codec/src/oleps/wire.ts @@ -0,0 +1,101 @@ +// The [MS-OLEPS] Property Set Stream wire format's shared vocabulary: the PropertyType codes, the two reserved property identifiers, the codepage constants, the fixed structural sizes, and the two symmetric codecs (GUID and FILETIME) both ./read.ts and ./write.ts need identically. A true single source of truth rather than each direction restating its own copy: unlike cfb/write.ts's own FAT special values (a handful of independent constants where duplication risks no real drift), a GUID or FILETIME transcribed slightly differently in each direction would silently break every round trip, so the one correct engineering choice here is one definition both sides import. + +// [MS-OLEPS] 2.21 PropertySetStream: mandated at the start of every property set stream. +export const BYTE_ORDER_MARK = 0xfffe; +// ByteOrder(2) + Version(2) + SystemIdentifier(4) + CLSID(16) + NumPropertySets(4) + FMTID0(16) + Offset0(4): the fixed header this package always writes and only ever reads the single-property-set form of (see read.ts's own scope note on NumPropertySets). +export const HEADER_SIZE = 48; +// [MS-OLEPS] 2.16 PropertySet: Size(4) + NumProperties(4), before the PropertyIdentifierAndOffset dictionary begins. +export const PROPERTY_SET_HEADER_SIZE = 8; +// [MS-OLEPS] 2.17 PropertyIdentifierAndOffset: PropertyIdentifier(4) + Offset(4). +export const IDENTIFIER_AND_OFFSET_SIZE = 8; +// [MS-OLEPS] 2.15 TypedPropertyValue: Type(2) + Padding(2), before the Value field. +export const TYPED_VALUE_HEADER_SIZE = 4; + +// [MS-OLEPS] 2.15 PropertyType enumeration -- only the five values this package reads and/or writes (see each module's own scope note for which of read/write covers which). +export const VT_I2 = 0x0002; +export const VT_I4 = 0x0003; +export const VT_LPSTR = 0x001e; +export const VT_LPWSTR = 0x001f; +export const VT_FILETIME = 0x0040; + +// [MS-OLEPS] 2.17: PID 0 is reserved for the Dictionary property (a Dictionary packet, not a TypedPropertyValue, naming string-keyed properties) -- a structure this package does not parse, and one no "\x05SummaryInformation" stream carries (only DocumentSummaryInformation's user-defined section uses named properties, which is out of scope; see the package README). +export const PID_DICTIONARY = 0; +// [MS-OLEPS] 2.18: the CodePage property, MUST be VT_I2, governing how every VT_LPSTR (CodePageString) value in the same property set decodes its bytes. +export const PID_CODEPAGE = 1; + +// [MS-OLEPS] 2.19 CodePageString: when the property set's CodePage property has this value, a CodePageString is itself a UTF-16LE array rather than an ANSI one -- the codepage ./write.ts always declares, since it writes Unicode strings only. +export const CP_WINUNICODE = 1200; +// Windows Western European -- the only single-byte ANSI codepage this package's reader decodes (matching the windows-1252 convention archive-codec's own OLE Package reader, ./cfb/ole-package.ts, already uses for ANSI text), and the value the [MS-OLEPS] SummaryInformation worked example itself declares. +export const WINDOWS_1252_CODEPAGE = 1252; + +// [MS-OLEPS] 2.21: "If no CLSID is provided by the application, it SHOULD be set to GUID_NULL by default" -- this package has no notion of a property set's own associated CLSID, so it always writes this and never inspects it on read. +export const GUID_NULL = "{00000000-0000-0000-0000-000000000000}"; + +function hex(n: number, width: number): string { + return n.toString(16).padStart(width, "0"); +} + +// [MS-OLEPS] 2.7 GUID (Packet Version), reused from [MS-DTYP] 2.3.4: Data1 (4 bytes) and Data2/Data3 (2 bytes each) are little-endian; Data4 (8 bytes) is written byte-for-byte in the order the GUID's braced string form gives it, with no byte-swapping. The braced-hyphenated-uppercase-hex form is this package's own in-memory representation of a formatId (FMTID) or CLSID -- not part of the wire format itself, just how ./read.ts hands one back and ./write.ts expects one in. +export function readGuid(view: DataView, offset: number): string { + const data1 = hex(view.getUint32(offset, true), 8); + const data2 = hex(view.getUint16(offset + 4, true), 4); + const data3 = hex(view.getUint16(offset + 6, true), 4); + let data4a = ""; + for (let i = 0; i < 2; i++) { + data4a += hex(view.getUint8(offset + 8 + i), 2); + } + let data4b = ""; + for (let i = 2; i < 8; i++) { + data4b += hex(view.getUint8(offset + 8 + i), 2); + } + return `{${data1}-${data2}-${data3}-${data4a}-${data4b}}`.toUpperCase(); +} + +export function writeGuid(view: DataView, offset: number, guid: string): void { + const digits = guid.replace(/[{}-]/g, ""); + view.setUint32(offset, Number.parseInt(digits.slice(0, 8), 16), true); + view.setUint16(offset + 4, Number.parseInt(digits.slice(8, 12), 16), true); + view.setUint16(offset + 6, Number.parseInt(digits.slice(12, 16), 16), true); + for (let i = 0; i < 8; i++) { + view.setUint8( + offset + 8 + i, + Number.parseInt(digits.slice(16 + i * 2, 18 + i * 2), 16), + ); + } +} + +// [MS-OLEPS] 2.15 (VT_FILETIME) / [MS-DTYP] 2.3.3: a FILETIME counts 100-nanosecond intervals since 1601-01-01T00:00:00Z, a JS Date counts milliseconds since 1970-01-01T00:00:00Z. The gap between those two epochs, in 100-nanosecond units -- BigInt throughout, because the raw tick count for any modern date already exceeds Number.MAX_SAFE_INTEGER. +const FILETIME_EPOCH_OFFSET_100NS = 116444736000000000n; +const HUNDRED_NS_PER_MS = 10000n; + +export function filetimeToDate(low: number, high: number): Date { + const ticks = (BigInt(high) << 32n) | BigInt(low); + const ms = (ticks - FILETIME_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MS; + return new Date(Number(ms)); +} + +export function dateToFiletime(date: Date): { + readonly low: number; + readonly high: number; +} { + const ticks = + BigInt(date.getTime()) * HUNDRED_NS_PER_MS + FILETIME_EPOCH_OFFSET_100NS; + return { + low: Number(ticks & 0xffffffffn), + high: Number((ticks >> 32n) & 0xffffffffn), + }; +} + +// The typed value of one property, decoded from -- or destined for -- a TypedPropertyValue packet. Tagged by the PropertyType name rather than a synthetic kind, so a caller matching on `type` reads the same vocabulary [MS-OLEPS] itself uses. +export type PropertyValue = + | { readonly type: "VT_I2"; readonly value: number } + | { readonly type: "VT_I4"; readonly value: number } + | { readonly type: "VT_LPSTR"; readonly value: string } + | { readonly type: "VT_LPWSTR"; readonly value: string } + | { readonly type: "VT_FILETIME"; readonly value: Date }; + +// One property set: its FMTID (as a formatId string) and its properties keyed by PropertyIdentifier. The vocabulary ./read.ts and ./write.ts share both directions -- reading a stream and writing one back take and return the identical shape, so a round trip is well-typed rather than a translation between two. +export interface PropertySet { + readonly formatId: string; + readonly properties: ReadonlyMap; +} diff --git a/packages/archive-codec/src/oleps/write.test.ts b/packages/archive-codec/src/oleps/write.test.ts new file mode 100644 index 000000000..9e11d0c6f --- /dev/null +++ b/packages/archive-codec/src/oleps/write.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { readPropertySetStream } from "./read"; +import type { PropertyValue } from "./wire"; +import { PropertySetWriteError, writePropertySetStream } from "./write"; + +// Coverage for the generic [MS-OLEPS] Property Set Stream writer (src/oleps/write.ts): round trips through this package's own reader (matching how every other write-side feature in this session is verified -- against the package's own reader, proving genuine conformance rather than internal self-consistency alone) for every type the writer supports, plus the deliberate VT_LPSTR refusal. + +const FMTID_SUMMARY_INFORMATION = "{F29F85E0-4FF9-1068-AB91-08002B27B3D9}"; + +describe("writePropertySetStream", () => { + it("round-trips VT_I2, VT_I4, VT_FILETIME, and VT_LPWSTR properties through readPropertySetStream", () => { + const createdIso = "2024-06-15T10:30:00.000Z"; + const properties = new Map([ + [1, { type: "VT_I2", value: 1200 }], + [2, { type: "VT_LPWSTR", value: "Café Über — em dash" }], + [12, { type: "VT_FILETIME", value: new Date(createdIso) }], + [14, { type: "VT_I4", value: -42 }], + ]); + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties, + }); + const read = readPropertySetStream(bytes); + expect(read.formatId).toBe(FMTID_SUMMARY_INFORMATION); + expect(read.properties.get(1)).toEqual({ type: "VT_I2", value: 1200 }); + expect(read.properties.get(2)).toEqual({ + type: "VT_LPWSTR", + value: "Café Über — em dash", + }); + expect((read.properties.get(12)?.value as Date).toISOString()).toBe( + createdIso, + ); + expect(read.properties.get(14)).toEqual({ type: "VT_I4", value: -42 }); + }); + + it("emits the PropertyIdentifierAndOffset dictionary in increasing PID order regardless of Map insertion order", () => { + const properties = new Map([ + [12, { type: "VT_I4", value: 3 }], + [2, { type: "VT_I4", value: 1 }], + [5, { type: "VT_I4", value: 2 }], + ]); + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties, + }); + const read = readPropertySetStream(bytes); + expect([...read.properties.keys()].sort((a, b) => a - b)).toEqual([ + 2, 5, 12, + ]); + expect(read.properties.get(2)).toEqual({ type: "VT_I4", value: 1 }); + expect(read.properties.get(5)).toEqual({ type: "VT_I4", value: 2 }); + expect(read.properties.get(12)).toEqual({ type: "VT_I4", value: 3 }); + }); + + it("writes a well-formed stream with no properties at all", () => { + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties: new Map(), + }); + const read = readPropertySetStream(bytes); + expect(read.formatId).toBe(FMTID_SUMMARY_INFORMATION); + expect(read.properties.size).toBe(0); + }); + + it("round-trips a title long enough to need mini-FAT-scale, multi-sector-scale content and characters needing surrogate pairs", () => { + const value = `${"x".repeat(2000)}\u{1F600}`; // an emoji is a UTF-16 surrogate pair -- charCodeAt-based encoding must carry both units through unchanged + const bytes = writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties: new Map([ + [2, { type: "VT_LPWSTR", value }], + ]), + }); + expect(readPropertySetStream(bytes).properties.get(2)).toEqual({ + type: "VT_LPWSTR", + value, + }); + }); + + it("throws PropertySetWriteError for a VT_LPSTR property", () => { + const properties = new Map([ + [2, { type: "VT_LPSTR", value: "ansi" }], + ]); + expect(() => + writePropertySetStream({ + formatId: FMTID_SUMMARY_INFORMATION, + properties, + }), + ).toThrow(PropertySetWriteError); + }); +}); diff --git a/packages/archive-codec/src/oleps/write.ts b/packages/archive-codec/src/oleps/write.ts new file mode 100644 index 000000000..e814857ad --- /dev/null +++ b/packages/archive-codec/src/oleps/write.ts @@ -0,0 +1,147 @@ +import { + BYTE_ORDER_MARK, + GUID_NULL, + HEADER_SIZE, + IDENTIFIER_AND_OFFSET_SIZE, + PROPERTY_SET_HEADER_SIZE, + TYPED_VALUE_HEADER_SIZE, + VT_FILETIME, + VT_I2, + VT_I4, + VT_LPWSTR, + dateToFiletime, + writeGuid, + type PropertySet, + type PropertyValue, +} from "./wire"; + +// The write half of the generic [MS-OLEPS] Property Set Stream reader in ./read.ts: given the same {formatId, properties} vocabulary that reads, it emits a conformant single-property-set stream -- header, PropertySet packet (Size, NumProperties, the PropertyIdentifierAndOffset dictionary, and the typed values themselves). Deliberately the mirror of readPropertySetStream: writePropertySetStream(readPropertySetStream(bytes)) is a well-typed round trip rather than a translation between two vocabularies, exactly as cfb/write.ts is to cfb/read.ts. +// +// Purely mechanical: this writer emits exactly the properties it is given, in PID order, and injects nothing of its own (no default CodePage, no synthesized property) -- the same "output depends only on what was asked for, never a guess about what a well-formed stream should also contain" discipline cfb/write.ts holds for stream paths. Constructing a properties map that is actually a well-formed "\x05SummaryInformation" stream (title/author/dates mapped onto the right PIDs, a CodePage property included) is ./summary-information.ts's job, one level up. +// +// Narrower than the reader in one respect, deliberately: VT_LPSTR (CodePageString) is not written, only VT_LPWSTR (UnicodeString). A CodePageString's ANSI encoding depends on the property set's own CodePage property, and writing an arbitrary codepage's byte encoding would need a full codepage table this package does not have (the reader only ever decodes windows-1252 or CP_WINUNICODE for the same reason -- see ./read.ts). Writing Unicode strings unconditionally sidesteps the whole question: VT_LPWSTR is always UTF-16LE regardless of CodePage, so every string this package's callers actually need to write (arbitrary document titles/authors, not constrained to Latin-1) round-trips losslessly without a codepage table. + +export class PropertySetWriteError extends Error { + constructor(message: string) { + super(message); + this.name = "PropertySetWriteError"; + } +} + +// [MS-OLEPS] 2.20 UnicodeString's own Characters field: a null-terminated array of 16-bit code units. JS strings are already sequences of UTF-16 code units, so this copies charCodeAt directly rather than re-encoding -- a surrogate pair round-trips as its own two code units with no special-casing needed, since nothing here interprets code-point boundaries. +function encodeUnicodeStringValue(value: string): Uint8Array { + const characterBytes = new Uint8Array((value.length + 1) * 2); + const charView = new DataView(characterBytes.buffer); + for (let i = 0; i < value.length; i++) { + charView.setUint16(i * 2, value.charCodeAt(i), true); + } + charView.setUint16(value.length * 2, 0, true); // the null terminator [MS-OLEPS] 2.20 requires + return characterBytes; +} + +function padTo4(length: number): number { + return Math.ceil(length / 4) * 4; +} + +// Encodes one property's TypedPropertyValue: Type(2) + Padding(2), then the Value field per [MS-OLEPS] 2.15. Every branch's total length is already a multiple of 4 bytes (VT_I2/VT_I4 pad their 2-/4-byte value out to 4; VT_FILETIME's 8-byte value needs none; VT_LPWSTR's own padding rule ensures it), so packing successive properties back-to-back keeps every later property's own offset naturally 4-byte aligned without extra bookkeeping. +function encodeTypedPropertyValue( + value: PropertyValue, +): Uint8Array { + switch (value.type) { + case "VT_I2": { + const bytes = new Uint8Array(TYPED_VALUE_HEADER_SIZE + 4); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_I2, true); + view.setUint16(2, 0, true); + view.setInt16(4, value.value, true); + view.setUint16(6, 0, true); + return bytes; + } + case "VT_I4": { + const bytes = new Uint8Array(TYPED_VALUE_HEADER_SIZE + 4); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_I4, true); + view.setUint16(2, 0, true); + view.setInt32(4, value.value, true); + return bytes; + } + case "VT_FILETIME": { + const { low, high } = dateToFiletime(value.value); + const bytes = new Uint8Array(TYPED_VALUE_HEADER_SIZE + 8); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_FILETIME, true); + view.setUint16(2, 0, true); + view.setUint32(4, low, true); + view.setUint32(8, high, true); + return bytes; + } + case "VT_LPWSTR": { + const characters = encodeUnicodeStringValue(value.value); + const paddedLength = padTo4(characters.length); + const bytes = new Uint8Array(TYPED_VALUE_HEADER_SIZE + 4 + paddedLength); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_LPWSTR, true); + view.setUint16(2, 0, true); + view.setUint32(4, characters.length / 2, true); // Length is in 16-bit units, not bytes + bytes.set(characters, TYPED_VALUE_HEADER_SIZE + 4); + return bytes; + } + case "VT_LPSTR": + throw new PropertySetWriteError( + "writePropertySetStream cannot write a VT_LPSTR property: this writer emits Unicode (VT_LPWSTR) strings only, since encoding to an arbitrary ANSI codepage is out of scope -- see the package README's OLEPS scope note", + ); + } +} + +// Writes a conformant [MS-OLEPS] Property Set Stream carrying exactly one property set, in the shape readPropertySetStream returns. +export function writePropertySetStream( + propertySet: PropertySet, +): Uint8Array { + const entries = [...propertySet.properties.entries()].sort( + ([a], [b]) => a - b, + ); + + const dictionaryBytes = new Uint8Array( + entries.length * IDENTIFIER_AND_OFFSET_SIZE, + ); + const dictionaryView = new DataView(dictionaryBytes.buffer); + const valueChunks: Uint8Array[] = []; + let valueOffset = PROPERTY_SET_HEADER_SIZE + dictionaryBytes.length; + let index = 0; + for (const [pid, value] of entries) { + const encoded = encodeTypedPropertyValue(value); + dictionaryView.setUint32(index * IDENTIFIER_AND_OFFSET_SIZE, pid, true); + dictionaryView.setUint32( + index * IDENTIFIER_AND_OFFSET_SIZE + 4, + valueOffset, + true, + ); + valueChunks.push(encoded); + valueOffset += encoded.length; + index += 1; + } + const propertySetSize = valueOffset; + + const propertySetBytes = new Uint8Array(propertySetSize); + const propertySetView = new DataView(propertySetBytes.buffer); + propertySetView.setUint32(0, propertySetSize, true); + propertySetView.setUint32(4, entries.length, true); + propertySetBytes.set(dictionaryBytes, PROPERTY_SET_HEADER_SIZE); + let cursor = PROPERTY_SET_HEADER_SIZE + dictionaryBytes.length; + for (const chunk of valueChunks) { + propertySetBytes.set(chunk, cursor); + cursor += chunk.length; + } + + const streamBytes = new Uint8Array(HEADER_SIZE + propertySetBytes.length); + const view = new DataView(streamBytes.buffer); + view.setUint16(0, BYTE_ORDER_MARK, true); + view.setUint16(2, 0, true); // Version 0: none of the types this writer emits need version 1's extra features + view.setUint32(4, 0, true); // SystemIdentifier is implementation-specific and MUST be ignored by readers ([MS-OLEPS] 2.21); zero rather than impersonating a real OS identifier + writeGuid(view, 8, GUID_NULL); // CLSID: this package has no notion of a property set's own associated CLSID to record + view.setUint32(24, 1, true); // NumPropertySets + writeGuid(view, 28, propertySet.formatId); + view.setUint32(44, HEADER_SIZE, true); // Offset0 + streamBytes.set(propertySetBytes, HEADER_SIZE); + return streamBytes; +} diff --git a/packages/archive-codec/src/test-support/oleps.ts b/packages/archive-codec/src/test-support/oleps.ts new file mode 100644 index 000000000..d1517d3d2 --- /dev/null +++ b/packages/archive-codec/src/test-support/oleps.ts @@ -0,0 +1,166 @@ +// A hand-built [MS-OLEPS] Property Set Stream encoder for the oleps reader's tests, independent of oleps/write.ts's own construction (the same "test-support hand-rolls its own bytes rather than reusing the module under test" discipline test-support/cfb.ts already follows for the CFB reader): given a formatId and a list of typed field specs, it emits a genuine single-property-set stream -- header, then the PropertySet packet's Size/NumProperties/PropertyIdentifierAndOffset dictionary/typed values -- whose bytes the reader under test must parse back into the same fields. +// +// Test-support only: excluded from the published dist per the family convention. + +export type FieldValue = + | { readonly type: "VT_I2"; readonly value: number } + | { readonly type: "VT_I4"; readonly value: number } + | { readonly type: "VT_LPSTR"; readonly value: string } // ASCII only -- windows-1252 and ASCII agree below 0x80, which is all these tests ever need to encode + | { readonly type: "VT_LPSTR_UTF16"; readonly value: string } // a VT_LPSTR (0x001E) CodePageString whose Characters are UTF-16LE -- what a real producer writes when the property set's own CodePage declares CP_WINUNICODE ([MS-OLEPS] 2.19) + | { readonly type: "VT_LPWSTR"; readonly value: string } + | { + readonly type: "VT_FILETIME"; + readonly low: number; + readonly high: number; + }; + +export interface FieldSpec { + readonly pid: number; + readonly value: FieldValue; +} + +const VT_I2 = 0x0002; +const VT_I4 = 0x0003; +const VT_LPSTR = 0x001e; +const VT_LPWSTR = 0x001f; +const VT_FILETIME = 0x0040; + +function padTo4(length: number): number { + return Math.ceil(length / 4) * 4; +} + +function writeGuid(view: DataView, offset: number, guid: string): void { + const digits = guid.replace(/[{}-]/g, ""); + view.setUint32(offset, Number.parseInt(digits.slice(0, 8), 16), true); + view.setUint16(offset + 4, Number.parseInt(digits.slice(8, 12), 16), true); + view.setUint16(offset + 6, Number.parseInt(digits.slice(12, 16), 16), true); + for (let i = 0; i < 8; i++) { + view.setUint8( + offset + 8 + i, + Number.parseInt(digits.slice(16 + i * 2, 18 + i * 2), 16), + ); + } +} + +function encodeAsciiCodePageString(value: string): Uint8Array { + const size = value.length + 1; // + null terminator + const bytes = new Uint8Array(4 + padTo4(size)); + const view = new DataView(bytes.buffer); + view.setUint32(0, size, true); + for (let i = 0; i < value.length; i++) { + bytes[4 + i] = value.charCodeAt(i); + } + return bytes; +} + +function encodeUnicodeString(value: string): Uint8Array { + const units = value.length + 1; // + null terminator + const charBytes = units * 2; + const bytes = new Uint8Array(4 + padTo4(charBytes)); + const view = new DataView(bytes.buffer); + view.setUint32(0, units, true); + for (let i = 0; i < value.length; i++) { + view.setUint16(4 + i * 2, value.charCodeAt(i), true); + } + return bytes; +} + +function encodeValue(value: FieldValue): Uint8Array { + switch (value.type) { + case "VT_I2": { + const bytes = new Uint8Array(8); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_I2, true); + view.setInt16(4, value.value, true); + return bytes; + } + case "VT_I4": { + const bytes = new Uint8Array(8); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_I4, true); + view.setInt32(4, value.value, true); + return bytes; + } + case "VT_FILETIME": { + const bytes = new Uint8Array(12); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_FILETIME, true); + view.setUint32(4, value.low, true); + view.setUint32(8, value.high, true); + return bytes; + } + case "VT_LPSTR": { + const characters = encodeAsciiCodePageString(value.value); + const bytes = new Uint8Array(4 + characters.length); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_LPSTR, true); + bytes.set(characters, 4); + return bytes; + } + case "VT_LPSTR_UTF16": { + // The identical CodePageString shape VT_LPSTR uses (Size, then null-terminated Characters padded to 4 bytes), but Characters is UTF-16LE, not ASCII -- Size therefore counts bytes, not code units, and is twice encodeUnicodeString's own Length. + const wide = encodeUnicodeString(value.value); + const wideView = new DataView(wide.buffer); + const units = wideView.getUint32(0, true); + wideView.setUint32(0, units * 2, true); + const bytes = new Uint8Array(4 + wide.length); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_LPSTR, true); + bytes.set(wide, 4); + return bytes; + } + case "VT_LPWSTR": { + const characters = encodeUnicodeString(value.value); + const bytes = new Uint8Array(4 + characters.length); + const view = new DataView(bytes.buffer); + view.setUint16(0, VT_LPWSTR, true); + bytes.set(characters, 4); + return bytes; + } + } +} + +/** Builds a single-property-set [MS-OLEPS] PropertySetStream: header (ByteOrder/Version/SystemIdentifier/CLSID=GUID_NULL/NumPropertySets=1/FMTID0/Offset0), then the PropertySet packet -- fields emitted in the given order, each entry's PropertyIdentifierAndOffset pointing at its own value, immediately after the dictionary. */ +export function propertySetStream( + formatId: string, + fields: readonly FieldSpec[], +): Uint8Array { + const HEADER_SIZE = 48; + const dictionarySize = fields.length * 8; + + interface PlacedField { + readonly pid: number; + readonly offset: number; + readonly bytes: Uint8Array; + } + const placed: PlacedField[] = []; + let valueOffset = 8 + dictionarySize; + for (const field of fields) { + const bytes = encodeValue(field.value); + placed.push({ pid: field.pid, offset: valueOffset, bytes }); + valueOffset += bytes.length; + } + const propertySetSize = valueOffset; + + const propertySetBytes = new Uint8Array(propertySetSize); + const psView = new DataView(propertySetBytes.buffer); + psView.setUint32(0, propertySetSize, true); + psView.setUint32(4, placed.length, true); + placed.forEach((field, index) => { + psView.setUint32(8 + index * 8, field.pid, true); + psView.setUint32(8 + index * 8 + 4, field.offset, true); + propertySetBytes.set(field.bytes, field.offset); + }); + + const streamBytes = new Uint8Array(HEADER_SIZE + propertySetBytes.length); + const view = new DataView(streamBytes.buffer); + view.setUint16(0, 0xfffe, true); // ByteOrder + view.setUint16(2, 0, true); // Version + view.setUint32(4, 0, true); // SystemIdentifier + writeGuid(view, 8, "{00000000-0000-0000-0000-000000000000}"); // CLSID = GUID_NULL + view.setUint32(24, 1, true); // NumPropertySets + writeGuid(view, 28, formatId); // FMTID0 + view.setUint32(44, HEADER_SIZE, true); // Offset0 + streamBytes.set(propertySetBytes, HEADER_SIZE); + return streamBytes; +} diff --git a/packages/archive-codec/test/smoke.test.mjs b/packages/archive-codec/test/smoke.test.mjs index a12cf124a..8db4ca84a 100644 --- a/packages/archive-codec/test/smoke.test.mjs +++ b/packages/archive-codec/test/smoke.test.mjs @@ -17,13 +17,24 @@ const BARREL_FUNCTIONS = [ 'readCompoundFile', 'writeCompoundFile', 'readOlePackage', + 'readPropertySetStream', + 'writePropertySetStream', + 'readSummaryInformation', + 'writeSummaryInformationStream', +]; +const BARREL_CONSTANTS = [ + 'MAX_WALK_DEPTH', + 'MAX_WALK_TOTAL_BYTES', + 'MAX_CFB_TOTAL_STREAM_BYTES', + 'FMTID_SUMMARY_INFORMATION', ]; -const BARREL_CONSTANTS = ['MAX_WALK_DEPTH', 'MAX_WALK_TOTAL_BYTES', 'MAX_CFB_TOTAL_STREAM_BYTES']; const BARREL_CLASSES = [ 'ArchiveWalkLimitError', 'CompoundFileFormatError', 'CompoundFileWriteError', 'OlePackageFormatError', + 'PropertySetFormatError', + 'PropertySetWriteError', ]; describe('dist/ barrel exports are present in both builds', () => { @@ -59,6 +70,12 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/cfb/read.js', exports: ['readCompoundFile', 'MAX_CFB_TOTAL_STREAM_BYTES'] }, { path: '../dist/cfb/write.js', exports: ['writeCompoundFile', 'CompoundFileWriteError'] }, { path: '../dist/cfb/ole-package.js', exports: ['readOlePackage'] }, + { path: '../dist/oleps/read.js', exports: ['readPropertySetStream', 'PropertySetFormatError'] }, + { path: '../dist/oleps/write.js', exports: ['writePropertySetStream', 'PropertySetWriteError'] }, + { + path: '../dist/oleps/summary-information.js', + exports: ['readSummaryInformation', 'writeSummaryInformationStream', 'FMTID_SUMMARY_INFORMATION'], + }, { path: '../dist/magic.js', exports: [] }, ]; @@ -114,4 +131,18 @@ describe('dist/ end-to-end: both builds round-trip a real archive', () => { expect(cjsStreams.map((entry) => entry.path)).toEqual(['Large']); expect(cjsStreams[0]?.bytes).toEqual(large); }); + + it('writeSummaryInformationStream -> readSummaryInformation agrees across ESM and CJS', () => { + // The property-set half of the same end-to-end check: a writer whose stream only its own build can parse back would pass every deep-import check above and still be broken. + const metadata = { title: 'Smoke title', author: 'archive-codec', keywords: ['a', 'b'] }; + const esmRead = esm.readSummaryInformation(esm.writeSummaryInformationStream(metadata)); + expect(esmRead.title).toBe(metadata.title); + expect(esmRead.author).toBe(metadata.author); + expect(esmRead.keywords).toEqual(metadata.keywords); + + const cjsRead = cjs.readSummaryInformation(cjs.writeSummaryInformationStream(metadata)); + expect(cjsRead.title).toBe(metadata.title); + expect(cjsRead.author).toBe(metadata.author); + expect(cjsRead.keywords).toEqual(metadata.keywords); + }); }); diff --git a/packages/archive-codec/test/workers/archive-codec.test.ts b/packages/archive-codec/test/workers/archive-codec.test.ts index b13686249..dd4b07592 100644 --- a/packages/archive-codec/test/workers/archive-codec.test.ts +++ b/packages/archive-codec/test/workers/archive-codec.test.ts @@ -8,8 +8,10 @@ import { isZipArchive, readCompoundFile, readOlePackage, + readSummaryInformation, unzipPackage, writeCompoundFile, + writeSummaryInformationStream, zipPackage, walkArchive, } from '../../src'; @@ -110,4 +112,26 @@ describe('archive-codec under the Cloudflare Workers runtime', () => { view.setUint32(512 + 2 * 4, 2, true); // the stream's first data sector points at itself: a cyclic FAT chain expect(() => readCompoundFile(bytes)).toThrow(CompoundFileFormatError); }); + + it('writes and reads back a SummaryInformation property set inside the isolate', () => { + // The [MS-OLEPS] path leans on TextDecoder('windows-1252'), TextDecoder('utf-16le'), and BigInt FILETIME arithmetic -- none of them Node-only, but genuinely worth proving under workerd rather than assumed, the same way the CFB and OLE Package paths above are. + const metadata = { + title: 'Workers isolate title', + author: 'archive-codec', + keywords: ['a', 'b'], + createdIso: '2024-06-15T10:30:00.000Z', + }; + const streamBytes = writeSummaryInformationStream(metadata); + const cfb = writeCompoundFile([{ path: '\x05SummaryInformation', bytes: streamBytes }]); + const streams = readCompoundFile(cfb); + const stream = streams.find((s) => s.path === '\x05SummaryInformation'); + if (stream === undefined) { + throw new Error('expected a \\x05SummaryInformation stream'); + } + const read = readSummaryInformation(stream.bytes); + expect(read.title).toBe(metadata.title); + expect(read.author).toBe(metadata.author); + expect(read.keywords).toEqual(metadata.keywords); + expect(read.createdIso).toBe(metadata.createdIso); + }); }); From cdbafb9c9787e7a0e113f90cb97cc4b33f508160 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 17:28:12 +0100 Subject: [PATCH 02/12] feat(doc-codec): read and write document metadata via SummaryInformation readDocContent now maps a "\x05SummaryInformation" stream, when the compound file carries one, onto LayoutMetadata's title/subject/author/ keywords/createdIso/modifiedIso through archive-codec's oleps support; a document with no such stream still reads back {}, exactly as before. writeDocContent writes the stream back whenever the input's metadata carries anything SummaryInformation can hold, leaving it out entirely for metadata that is empty or carries only fields (creator/producer/ language) the stream has no room for, so a caller reading either back sees the identical {}. The field mapping lives in a new src/metadata.ts: comments and last-printed have no LayoutMetadata destination, and creator/producer/ language have no SummaryInformation source, so each direction only carries the six fields both sides can actually represent. --- packages/doc-codec/README.md | 19 +++++++- packages/doc-codec/src/detect.ts | 3 ++ packages/doc-codec/src/index.ts | 1 + packages/doc-codec/src/metadata.ts | 47 +++++++++++++++++++ packages/doc-codec/src/read.test.ts | 43 +++++++++++++++++ packages/doc-codec/src/read.ts | 28 ++++++++--- packages/doc-codec/src/write.test.ts | 46 ++++++++++++++++++ packages/doc-codec/src/write.ts | 27 +++++++++-- packages/doc-codec/test/smoke.test.mjs | 10 +++- .../doc-codec/test/workers/doc-codec.test.ts | 17 +++++++ 10 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 packages/doc-codec/src/metadata.ts diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index f8ff7f1c4..cb483f529 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -20,10 +20,11 @@ Built and shipped, on the read side: - **Tables** — `table/read.ts`'s `assembleBlocks` folds a contiguous run of table-depth-1 paragraphs into a real `ContentTable`: cell boundaries at each cell-mark (`0x07`) character, a cell holding more than one paragraph where only its last ends in a cell mark, and each row's own trailing mark (`sprmPFTtp`) resolved through `table/tap.ts`'s `applyTableSprms` for its TAP — column boundaries and every physical cell's own horizontal/vertical merge state, from `sprmTDefTable`'s `TDefTableOperand` (and a `sprmTMerge` range or `sprmTVertMerge` per-cell flag where a real producer states a merge that way instead — see [Tables](#tables) below for why both are read). A table nested inside a table cell (table depth greater than 1, detected via `sprmPItap`/`sprmPFInnerTableCell`/`sprmPFInnerTtp`) is refused with `DocUnsupportedError` rather than mis-read; a row whose own TAP this reader cannot resolve at all — no direct `sprmTDefTable` anywhere in its grpprl, or a cell-mark count that disagrees with it — degrades the whole run back to flat paragraphs instead, since that is a legal producer choice this reader does not yet follow rather than corruption (see [Tables](#tables)). - **`readDocContent`** — the whole chain, producing a `'wordprocessing'` `ContentDocument` of paragraphs, runs and tables. - **`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. 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, and a font table when a run names one — 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. +- **`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. - 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` (not flattened text), its row/column/cell structure and vertical merge intact, with the one confirmed caveat [Tables](#tables) states plainly. **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: @@ -37,7 +38,7 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc | **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. | +| **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 — a real Word producer's own row mark typically states its TAP this way (`sprmPTableProps` pointing at a `PrcData` of incremental `sprmT*` operations, per [MS-DOC] 2.4.3's own worked example) rather than the direct `sprmTDefTable` this package's reader and writer both use instead. `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. | @@ -94,6 +95,19 @@ A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` an **What is not resolved.** Cell shading and borders (`ContentTableCell.background`/`.borders`) are neither read from nor written to `TC80`'s own `brcTop`/`brcLeft`/`brcBottom`/`brcRight`/shading fields — every border this writer emits is `Brc80MayBeNil`'s "no border" sentinel (all bits set). `sprmTVertMerge` is read (folded onto `sprmTDefTable`'s own layout, the vertical analogue of `sprmTMerge`) but never written — this writer states a vertical merge only through `TC80.tcgrf`. Every table-level TAP sprm beyond `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge`/`sprmTVertMerge` — absolute position, table style, cell padding, and the rest of [MS-DOC] 2.6.4's roughly seventy table sprms — is unread and unwritten, exactly as the read-side scope note already states for ordinary paragraph sprms this package does not convert. +## Metadata + +A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writeDocContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. + +The mapping is not 1:1, and each gap is permanent rather than a remaining TODO: + +| Direction | Fields covered | Gap | +| ----------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `ContentDocument`. | +| LayoutMetadata → SummaryInformation | the same six fields, in reverse | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers. | + +Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares. + ## 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`). @@ -144,6 +158,7 @@ The modules layer in the order [MS-DOC]'s own algorithms chain: | `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/metadata.ts` | Maps `archive-codec`'s `SummaryInformationProperties` to and from `document-schema.js`'s `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). | | `src/table/tap.ts` | Folding a table row's own sgc-5 grpprl into its TAP — column boundaries and every physical cell's merge state from `sprmTDefTable`, folded with a `sprmTMerge` range or `sprmTVertMerge` flag where one is present, regardless of which order they appear in. | | `src/table/read.ts` | Grouping a contiguous run of table-depth paragraphs (from `read.ts`'s own flat sequence) into a real `ContentTable`, refusing a nested table. | | `src/read.ts` | The whole read chain, to a `ContentDocument`. | diff --git a/packages/doc-codec/src/detect.ts b/packages/doc-codec/src/detect.ts index 36479c079..03eb6b097 100644 --- a/packages/doc-codec/src/detect.ts +++ b/packages/doc-codec/src/detect.ts @@ -5,6 +5,9 @@ import { FIB_W_IDENT } from "./fib/offsets"; /** The stream a Word Binary File's FIB and text live in, [MS-DOC] 2.1.1. */ export const WORD_DOCUMENT_STREAM = "WordDocument"; +/** The [MS-OLEPS] Property Set Stream a .doc's title/author/dates live in when present -- a genuinely optional stream, unlike WordDocument, since a valid Word Binary File need not carry document properties at all. */ +export const SUMMARY_INFORMATION_STREAM = "\x05SummaryInformation"; + // Whether these bytes are a Word Binary File. The compound-file signature alone is not enough to answer that: .xls and .ppt of the same era are compound files too, and so is any OLE embedding, so a detector that stopped at the magic bytes would claim every one of them. The distinguishing facts are the presence of a stream named "WordDocument" and the 0xA5EC signature at its own offset zero -- the two things [MS-DOC] requires of every conforming file and no sibling format has. // // This reads the whole container to answer, which is the honest cost of a correct answer: a compound file's directory is not at a fixed offset, so there is no cheaper place to look for a named stream. A caller with a path or a MIME type already in hand should use that instead of paying for this. diff --git a/packages/doc-codec/src/index.ts b/packages/doc-codec/src/index.ts index 6751bce46..7fa470717 100644 --- a/packages/doc-codec/src/index.ts +++ b/packages/doc-codec/src/index.ts @@ -6,6 +6,7 @@ export * from "./detect"; export * from "./fib/offsets"; export * from "./fib/fib"; export * from "./fib/write"; +export * from "./metadata"; export * from "./text/piece-table"; export * from "./text/piece-table-write"; export * from "./text/characters"; diff --git a/packages/doc-codec/src/metadata.ts b/packages/doc-codec/src/metadata.ts new file mode 100644 index 000000000..b3402468a --- /dev/null +++ b/packages/doc-codec/src/metadata.ts @@ -0,0 +1,47 @@ +import type { SummaryInformationProperties } from "archive-codec"; +import type { LayoutMetadata } from "document-schema.js"; + +// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a wordprocessing document. +// +// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: +// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a ContentDocument. +// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). +// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. + +export function summaryInformationToLayoutMetadata( + info: SummaryInformationProperties, +): LayoutMetadata { + return { + title: info.title, + subject: info.subject, + author: info.author, + keywords: info.keywords === undefined ? undefined : [...info.keywords], + createdIso: info.createdIso, + modifiedIso: info.lastSavedIso, + }; +} + +export function layoutMetadataToSummaryInformation( + metadata: LayoutMetadata, +): SummaryInformationProperties { + return { + title: metadata.title, + subject: metadata.subject, + author: metadata.author, + keywords: metadata.keywords, + createdIso: metadata.createdIso, + lastSavedIso: metadata.modifiedIso, + }; +} + +/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writeDocContent's own call site). */ +export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { + return ( + metadata.title !== undefined || + metadata.subject !== undefined || + metadata.author !== undefined || + (metadata.keywords !== undefined && metadata.keywords.length > 0) || + metadata.createdIso !== undefined || + metadata.modifiedIso !== undefined + ); +} diff --git a/packages/doc-codec/src/read.test.ts b/packages/doc-codec/src/read.test.ts index 86ea5ede1..91645247e 100644 --- a/packages/doc-codec/src/read.test.ts +++ b/packages/doc-codec/src/read.test.ts @@ -1,3 +1,8 @@ +import { + readCompoundFile, + writeCompoundFile, + writeSummaryInformationStream, +} from "archive-codec"; import { ContentDocumentSchema } from "document-schema.js"; import type { ContentBlock, ContentParagraph } from "document-schema.js"; import { describe, expect, it } from "vitest"; @@ -358,3 +363,41 @@ describe("isDocBytes", () => { ).toBe(false); }); }); + +describe("metadata", () => { + // A real "\x05SummaryInformation" stream added beside the WordDocument/1Table streams a real producer would already have written -- composed here with archive-codec's own writeSummaryInformationStream/writeCompoundFile rather than by extending test-support/doc.ts's buildDoc, which stays a pure [MS-DOC]-only fixture builder. + function withSummaryInformation( + doc: Uint8Array, + metadata: Parameters[0], + ): Uint8Array { + return writeCompoundFile([ + ...readCompoundFile(doc), + { + path: "\x05SummaryInformation", + bytes: writeSummaryInformationStream(metadata), + }, + ]); + } + + it('reads title/author/dates from a real "\\x05SummaryInformation" stream', () => { + const doc = withSummaryInformation( + buildDoc({ paragraphs: [{ runs: [{ text: "Hello." }] }] }), + { + title: "Meeting notes", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }, + ); + const result = readDocContent(doc); + expect(result.metadata).toEqual({ + title: "Meeting notes", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }); + }); + + it('reads {} when the container carries no "\\x05SummaryInformation" stream', () => { + const doc = buildDoc({ paragraphs: [{ runs: [{ text: "Hello." }] }] }); + expect(readDocContent(doc).metadata).toEqual({}); + }); +}); diff --git a/packages/doc-codec/src/read.ts b/packages/doc-codec/src/read.ts index 2d43eca9d..2bd7abab2 100644 --- a/packages/doc-codec/src/read.ts +++ b/packages/doc-codec/src/read.ts @@ -1,4 +1,4 @@ -import { readCompoundFile } from "archive-codec"; +import { readCompoundFile, readSummaryInformation } from "archive-codec"; import type { ContentDocument, ContentParagraph, @@ -7,9 +7,10 @@ import type { PageSize, } from "document-schema.js"; import { slice } from "./bytes"; -import { WORD_DOCUMENT_STREAM } from "./detect"; +import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError } from "./errors"; import { parseFib, tableStreamName, type Fib } from "./fib/fib"; +import { summaryInformationToLayoutMetadata } from "./metadata"; import { applyCharacterSprms, type CharacterProperties } from "./prop/chp"; import { PropertyBinTable } from "./prop/fkp"; import { applyParagraphSprms, type ParagraphProperties } from "./prop/pap"; @@ -46,9 +47,11 @@ export interface DocStreams { readonly wordDocument: Uint8Array; readonly table: Uint8Array; readonly fib: Fib; + /** The raw "\x05SummaryInformation" stream bytes, or undefined when the container carries none -- a valid, spec-conformant Word Binary File need not carry document properties at all. */ + readonly metadata: Uint8Array | undefined; } -// Pulls the two streams every later step reads from, and the FIB that says which of "1Table" and "0Table" is the one in play. Both names always exist as candidates in the container; only the one FibBase.fWhichTblStm selects holds the structures the FIB's offsets address, and reading the other yields offsets into unrelated bytes. +// Pulls the two streams every later step reads from, the FIB that says which of "1Table" and "0Table" is the one in play, and the optional metadata stream. Both WordDocument and Table names always exist as candidates in the container; only the one FibBase.fWhichTblStm selects holds the structures the FIB's offsets address, and reading the other yields offsets into unrelated bytes. export function readDocStreams(bytes: Uint8Array): DocStreams { const streams = readCompoundFile(bytes); const wordDocument = streams.find( @@ -67,13 +70,21 @@ export function readDocStreams(bytes: Uint8Array): DocStreams { `FibBase.fWhichTblStm selects the "${wanted}" stream, which this compound file does not contain`, ); } - return { wordDocument: wordDocument.bytes, table: table.bytes, fib }; + const metadata = streams.find( + (stream) => stream.path === SUMMARY_INFORMATION_STREAM, + ); + return { + wordDocument: wordDocument.bytes, + table: table.bytes, + fib, + metadata: metadata?.bytes, + }; } export function readDocContent( bytes: Uint8Array, ): ContentDocument { - const { wordDocument, table, fib } = readDocStreams(bytes); + const { wordDocument, table, fib, metadata } = readDocStreams(bytes); const pieceTable = parseClx( slice(table, fib.fcClx, fib.lcbClx, "Clx in the Table stream"), @@ -130,8 +141,11 @@ export function readDocContent( return { kind: "wordprocessing", - // Empty rather than populated from the SummaryInformation property-set streams: those are [MS-OLEPS] property sets rather than [MS-DOC] structures, and this package does not read them yet. An absent title is honest; a fabricated one is not. - metadata: {}, + // Absent when the container carries no "\x05SummaryInformation" stream at all -- a valid, spec-conformant Word Binary File need not have one -- and mapped from it through summaryInformationToLayoutMetadata (see src/metadata.ts) otherwise. + metadata: + metadata === undefined + ? {} + : summaryInformationToLayoutMetadata(readSummaryInformation(metadata)), sections: [ { pageSize: DEFAULT_PAGE_SIZE, diff --git a/packages/doc-codec/src/write.test.ts b/packages/doc-codec/src/write.test.ts index f38b7cc5c..1a8c04254 100644 --- a/packages/doc-codec/src/write.test.ts +++ b/packages/doc-codec/src/write.test.ts @@ -1,3 +1,4 @@ +import { readCompoundFile } from "archive-codec"; import { ContentDocumentSchema, type ContentBlock, @@ -562,4 +563,49 @@ describe("writeDocContent tables", () => { ]); expect(() => writeDocContent(input)).toThrow(DocUnsupportedError); }); + + describe("metadata", () => { + it('round-trips title/subject/author/keywords/dates through a real "\\x05SummaryInformation" stream', () => { + const input: ContentDocument = { + ...document([paragraph([{ text: "Hello." }])]), + metadata: { + title: "Quarterly report", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + modifiedIso: "2024-03-20T14:30:00.000Z", + }, + }; + const result = roundTrip(input); + expect(result.metadata).toEqual(input.metadata); + }); + + it('writes no "\\x05SummaryInformation" stream at all when metadata carries nothing that stream can hold', () => { + const input = document([paragraph([{ text: "Hello." }])]); + const bytes = writeDocContent(input); + const streams = readCompoundFile(bytes); + expect( + streams.some((stream) => stream.path === "\x05SummaryInformation"), + ).toBe(false); + expect(readDocContent(bytes).metadata).toEqual({}); + }); + + it("drops creator/producer/language, which SummaryInformation cannot hold, without writing an empty stream for them alone", () => { + const input: ContentDocument = { + ...document([paragraph([{ text: "Hello." }])]), + metadata: { + creator: "Some Tool", + producer: "Some Producer", + language: "en-GB", + }, + }; + const bytes = writeDocContent(input); + const streams = readCompoundFile(bytes); + expect( + streams.some((stream) => stream.path === "\x05SummaryInformation"), + ).toBe(false); + expect(readDocContent(bytes).metadata).toEqual({}); + }); + }); }); diff --git a/packages/doc-codec/src/write.ts b/packages/doc-codec/src/write.ts index ce6efaf96..f4f4dc5f1 100644 --- a/packages/doc-codec/src/write.ts +++ b/packages/doc-codec/src/write.ts @@ -1,8 +1,15 @@ -import { writeCompoundFile } from "archive-codec"; -import type { ContentDocument } from "document-schema.js"; -import { WORD_DOCUMENT_STREAM } from "./detect"; +import { + writeCompoundFile, + writeSummaryInformationStream, +} from "archive-codec"; +import type { ContentDocument, ContentParagraph } from "document-schema.js"; +import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError, DocUnsupportedError } from "./errors"; import { buildFib } from "./fib/write"; +import { + hasSummaryInformationFields, + layoutMetadataToSummaryInformation, +} from "./metadata"; import { encodeCharacterGrpprl } from "./prop/chp-write"; import { FKP_PAGE_SIZE } from "./prop/fkp"; import { @@ -228,10 +235,20 @@ export function writeDocContent( }); wordDocument.set(fib, 0); - return writeCompoundFile([ + const streams = [ { path: WORD_DOCUMENT_STREAM, bytes: wordDocument }, { path: "1Table", bytes: table }, - ]); + ]; + // Only when there is something SummaryInformation can actually hold: an input whose metadata carries nothing beyond creator/producer/language (or nothing at all) should read back exactly as it would with no stream present, not force an empty-but-present one into existence. + if (hasSummaryInformationFields(document.metadata)) { + streams.push({ + path: SUMMARY_INFORMATION_STREAM, + bytes: writeSummaryInformationStream( + layoutMetadataToSummaryInformation(document.metadata), + ), + }); + } + return writeCompoundFile(streams); } function sameGrpprl( diff --git a/packages/doc-codec/test/smoke.test.mjs b/packages/doc-codec/test/smoke.test.mjs index 149f27000..2a4895439 100644 --- a/packages/doc-codec/test/smoke.test.mjs +++ b/packages/doc-codec/test/smoke.test.mjs @@ -28,6 +28,9 @@ const BARREL_FUNCTIONS = [ 'parsePlc', 'findLargestAtMost', 'endsParagraph', + 'summaryInformationToLayoutMetadata', + 'layoutMetadataToSummaryInformation', + 'hasSummaryInformationFields', ]; const BARREL_CONSTANTS = [ 'FIB_W_IDENT', @@ -36,6 +39,7 @@ const BARREL_CONSTANTS = [ 'COMPRESSED_CHARACTER_MAP', 'PARAGRAPH_MARK', 'WORD_DOCUMENT_STREAM', + 'SUMMARY_INFORMATION_STREAM', 'SGC', 'STK', ]; @@ -70,7 +74,11 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/errors.js', exports: ['DocFormatError', 'DocUnsupportedError'] }, { path: '../dist/bytes.js', exports: ['readUint16LE', 'slice'] }, { path: '../dist/plc.js', exports: ['parsePlc', 'findLargestAtMost'] }, - { path: '../dist/detect.js', exports: ['isDocBytes', 'WORD_DOCUMENT_STREAM'] }, + { path: '../dist/detect.js', exports: ['isDocBytes', 'WORD_DOCUMENT_STREAM', 'SUMMARY_INFORMATION_STREAM'] }, + { + path: '../dist/metadata.js', + exports: ['summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', 'hasSummaryInformationFields'], + }, { path: '../dist/fib/offsets.js', exports: ['FIB_W_IDENT', 'FIB_FC_LCB_BLOB_OFFSET'] }, { path: '../dist/fib/fib.js', exports: ['parseFib', 'tableStreamName'] }, { path: '../dist/text/piece-table.js', exports: ['parseClx', 'characterOffset'] }, diff --git a/packages/doc-codec/test/workers/doc-codec.test.ts b/packages/doc-codec/test/workers/doc-codec.test.ts index dc638a78c..a68109711 100644 --- a/packages/doc-codec/test/workers/doc-codec.test.ts +++ b/packages/doc-codec/test/workers/doc-codec.test.ts @@ -104,4 +104,21 @@ describe("doc-codec under the Cloudflare Workers runtime", () => { ]); expect(table.lastCp).toBe(14); }); + + it("round-trips document metadata through a real \"\\x05SummaryInformation\" stream, with no Node-only API", () => { + const input: ContentDocument = { + kind: "wordprocessing", + metadata: { title: "Workers isolate title", author: "doc-codec" }, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "Hello." }] }], + }, + ], + }; + const bytes = writeDocContent(input); + const result = readDocContent(bytes); + expect(result.metadata).toEqual(input.metadata); + }); }); From 535ff90ca1cadf381b5c21bb19ec124770734d9e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 17:37:50 +0100 Subject: [PATCH 03/12] feat(xls-codec)!: read and write document metadata via SummaryInformation readXlsContent now maps a "\x05SummaryInformation" stream, when the compound file carries one, onto LayoutMetadata's title/subject/author/ keywords/createdIso/modifiedIso through archive-codec's oleps support; a workbook with no such stream still reads back {}, exactly as before. writeXlsContent writes the stream back whenever the input's metadata carries anything SummaryInformation can hold, leaving it out entirely for metadata that is empty or carries only fields (creator/producer/ language) the stream has no room for. readWorkbookStream is replaced by readWorkbookStreams, returning both the Workbook stream and the optional metadata stream from one compound- file parse rather than two; its only caller (content.ts) is updated accordingly. The field mapping lives in a new src/metadata.ts, identical in shape to doc-codec's: comments and last-printed have no LayoutMetadata destination, and creator/producer/language have no SummaryInformation source, so each direction only carries the six fields both sides can actually represent. BREAKING CHANGE: readWorkbookStream is renamed to readWorkbookStreams and now returns { workbook, metadata } instead of a bare workbook byte array. A caller importing readWorkbookStream must switch to readWorkbookStreams and destructure the workbook field. --- packages/xls-codec/README.md | 27 +++++++--- packages/xls-codec/src/container.ts | 22 ++++++-- packages/xls-codec/src/content.test.ts | 53 +++++++++++++++++++ packages/xls-codec/src/content.ts | 19 +++++-- packages/xls-codec/src/index.ts | 1 + packages/xls-codec/src/metadata.ts | 47 ++++++++++++++++ packages/xls-codec/src/write.test.ts | 33 +++++++++++- packages/xls-codec/src/write.ts | 22 +++++++- packages/xls-codec/test/smoke.test.mjs | 12 ++++- packages/xls-codec/test/workers/write.test.ts | 10 ++++ 10 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 packages/xls-codec/src/metadata.ts diff --git a/packages/xls-codec/README.md b/packages/xls-codec/README.md index 2c77707aa..5c7a95687 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -17,6 +17,7 @@ Under active development, with real, tested **read and write** support. Built an - **Number-format classification and date serials** ([`excel-number-format`](../excel-number-format/README.md), `src/serial.ts`) — what turns a bare number into the schema's own `percentage`/`currency`/`date`/`time`/`dateTime` value kinds and back, honouring the workbook's own epoch flag (the writer always emits the 1900 system) and refusing the 1900 system's phantom leap day in both directions. The classification itself is a dependency, not local code: this package shares it with `ooxml.js`'s xlsx support, since it is the identical mini-language in both formats (ExaDev/documents.js#848). A cell's own `numberFormatCode` is preserved verbatim on write when present; absent, it resolves to a representative built-in code for its value kind (`General` for a plain number/string/boolean/error, `0%` for a percentage, a bare `$` format for a currency with no code, `mm-dd-yy`/`h:mm:ss`/`m/d/yy h:mm` for date/time/dateTime), and the workbook-wide `Format`/`XF` table is deduplicated across every sheet so two cells sharing one code share one entry. - **Formula text recovery** (`src/biff/ptg.ts`, `src/biff/ptg-functions.ts`) — a Formula record's compiled `rgce` token stream ([MS-XLS] 2.5.198's `Ptg` vocabulary) read and rebuilt into the infix text a spreadsheet application would show: literal operands (`PtgInt`/`PtgNum`/`PtgStr`/`PtgBool`/`PtgErr`/`PtgMissArg`), cell and range references including their 3D (cross-sheet) forms (`PtgRef`/`PtgArea`/`PtgRef3d`/`PtgArea3d`, `$`-qualified per their own relative/absolute flags, a 3D reference's sheet name resolved through `EXTERNSHEET` and a self-referencing `SupBook` — `src/workbook/globals.ts`'s own `sheetRanges`), every arithmetic/comparison/unary/percent operator and explicit parentheses, and function calls through both `PtgFunc` (fixed arity, resolved from a curated table of [MS-XLS]'s own Ftab grammar) and `PtgFuncVar` (variable arity, its own on-disk `cparams`) — see "Formula expressions" under Read-side gaps below for the exact boundary of what this does not resolve. - **Schema mapping** — `readXlsContent`/`readXls` (`src/content.ts`) as before, now also populating `ContentSheetCell.formula` wherever the Ptg reader above resolves it; `writeXlsContent`/`writeXls` (`src/write.ts`) the counterpart, taking a `ContentDocument`/`DocumentTree` of `kind: 'spreadsheet'` and producing genuine `.xls` bytes: a real BIFF8 `Workbook` stream (globals substream, one worksheet substream per sheet, `BoundSheet8.lbPlyPos` patched to each sheet's real byte offset once every substream's length is known) wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`. +- **Document metadata** — `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` read from a `"\x05SummaryInformation"` stream when one is present, and written back to one whenever the input's metadata carries anything that stream can hold (see [Metadata](#metadata)). Verified primarily by round trip (`src/write.test.ts`, plus a dedicated `test/workers/write.test.ts` proving the whole write path inside a real `workerd` isolate, not just Node): build a `ContentDocument`, write it, read it back through this package's own independently-pinned reader, and check the result. Every record's own byte layout is additionally cited to its [MS-XLS] section in the writer's source, matching the reader's own convention. @@ -30,10 +31,9 @@ What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `. | Cell decoration (fill, borders, alignment, per-cell font) | The reader does not read a `CellXF`'s decoration payload back (see below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits carries the same undecorated defaults (general alignment, bottom vertical alignment, no border, no fill) a genuinely undecorated Excel-written cell also carries. | | `Blank`/`MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Number`/`LabelSst`/`BoolErr` record already carries losslessly. An `empty`-kind cell is never written at all — `content.ts`'s own reader drops every blank cell it reads regardless, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone, so writing nothing for one is what round-trips correctly rather than a gap. | | Images, embedded objects, comments (`Note`/`Txo`), data validation, conditional formatting, defined names (`Lbl`) | Not read either (see below); there is no round trip to verify a writer for them against. | -| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) and workbook metadata (`\x05SummaryInformation`) | Same reason — the reader always returns its own fixed "Normal" preset and empty metadata regardless of what a file states, so writing the real values would be unverifiable. | +| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) | Same reason — the reader always returns its own fixed "Normal" preset regardless of what a file states, so writing the real values would be unverifiable. Workbook metadata (`\x05SummaryInformation`) is a separate story: see [Metadata](#metadata). | | `RECALC`/calc-state records (`CalcMode`, `CalcCount`, …), `Window1`/`Window2`, `CodePage`, `Index`/`DBCell`, the legacy interface records (`InterfaceHdr`, `WriteAccess`, …) | UI and interoperability bookkeeping [MS-XLS]'s own grammar names in the globals/worksheet substreams alongside the content-carrying records above, not data. `Index`/`DBCell` specifically is a pure cell-lookup performance optimisation (see [MS-XLS]'s own "Retrieval of Last-Calculated Cell Values Without Loading Cell Table") that this reader — and Excel's own reader — does not require to find a cell; real, well-established minimal BIFF8 writers (e.g. Python's `xlwt`) omit the same set and produce files Excel opens correctly. | | `Continue`-chain splitting | A record whose data would exceed the 8224-byte single-record ceiling ([MS-XLS] 2.1.4) — an extremely long shared string, an enormous shared string table, or thousands of merged ranges in one sheet — is refused with a thrown `BiffWriteError` rather than silently split across `Continue` records. | - Column widths round-trip to the nearest pixel Excel's own integer-pixel-grid quantization allows (matching the read direction's own "honestly approximate" contract, `units.ts`), never narrower than requested. A `.xls` cell outside BIFF8's own grid (65536 rows, 256 columns) is refused rather than silently wrapped or truncated. ### Read-side gaps @@ -43,11 +43,23 @@ Each deliberate rather than overlooked: - **Formula expressions, mostly recovered.** A `Formula` record's compiled `Ptg` token stream (`src/biff/ptg.ts`) is walked and rebuilt into real formula text — literal operands, cell/range references (`$`-qualified, including 3D cross-sheet references resolved through `EXTERNSHEET` and a self-referencing `SupBook`), every arithmetic/comparison/unary/percent operator, explicit parentheses, and both fixed- and variable-arity function calls, resolved by name against [MS-XLS]'s own built-in function table (`src/biff/ptg-functions.ts`, covering the whole published table — [MS-XLS] 2.5.198.17 — cited to that table's own `iftab` index; PtgFunc's fixed argument count is a curated subset of it, since PtgFunc's own token carries no count and only a function [MS-XLS]'s grammar states a fixed, non-optional arity for is resolved through it, empirically confirmed against real LibreOffice-written BIFF8 rather than assumed from the grammar alone). Three constructs remain genuinely unresolved, each leaving `ContentSheetCell.formula` absent for that cell specifically rather than fabricating text: a **shared formula** (`PtgExp`, whose real expression lives in a separate `ShrFmla` record this reader does not yet join), an **array formula** (`PtgArray`, whose literal values live in a separate `PtgExtraArray` trailer this reader does not yet parse), and a **genuinely external workbook** reference (a `SupBook` naming another file, a DDE/OLE data source, or an add-in, rather than this same workbook) — each is real, meaningfully separate work, not an oversight. A defined name (`PtgName`/`PtgNameX`) and a natural-language "Elf" reference are likewise not resolved, for the same reason `Lbl` (defined names) is not read at all yet (see below). - **Cell decoration** — fill, borders, and alignment from `XF`'s trailing `CellXF` payload, whose colours are palette indices needing the `Palette` record and the default colour table to resolve. `Font` records are not read either: `ContentSheetCell` has no cell-level font field, and `ooxml.js`'s xlsx reader likewise maps only the number format and decoration from a cell format. - **Print settings** are emitted as Excel's documented "Normal" preset rather than read from the file. The real values need `Setup` (including its paper-size code table), the four margin records, `PrintGrid`, and `PrintRowCol`. -- **Workbook metadata** — `metadata` is empty. Title, author, and dates live in the `\x05SummaryInformation` property-set stream ([MS-OSHARED]), a different format from BIFF8 sitting beside it in the same container. - **Not read at all:** charts, drawings and images, cell comments (`Note`/`Txo`), data validation, conditional formatting, and defined names (`Lbl`). - **Encrypted workbooks** are refused rather than mis-read: a `FilePass` record means every record after it is ciphertext. -This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdfToXls`, `convertDocument("xls", ...)`, and every same-variant spreadsheet bridge) — see that package's own README Fidelity table for exactly which pairs route and which don't. Remaining read+write scope gaps (formula writing, shared/array/external-reference formulas, cell decoration, print settings, metadata) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). +This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdfToXls`, `convertDocument("xls", ...)`, and every same-variant spreadsheet bridge) — see that package's own README Fidelity table for exactly which pairs route and which don't. Remaining read+write scope gaps (formula writing, shared/array/external-reference formulas, cell decoration, print settings) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). + +## Metadata + +A `.xls`'s title, author, and dates do not live in any BIFF8 record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Workbook` in the same [MS-CFB] compound file. `readXlsContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writeXlsContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. + +The mapping is not 1:1, and each gap is permanent rather than a remaining TODO: + +| Direction | Fields covered | Gap | +| ----------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `ContentDocument`. | +| LayoutMetadata → SummaryInformation | the same six fields, in reverse | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers. | + +Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares. ## Getting started @@ -82,10 +94,10 @@ if (isXlsFile(bytes)) { The record layer is exported in its own right, for a caller inspecting a workbook rather than converting it: ```ts -import { readRecords, readWorkbookStream } from "xls-codec"; +import { readRecords, readWorkbookStreams } from "xls-codec"; -const stream = readWorkbookStream(bytes); // the raw BIFF8 record stream out of the compound file -for (const rec of readRecords(stream)) { +const { workbook } = readWorkbookStreams(bytes); // the raw BIFF8 record stream out of the compound file, plus the optional "\x05SummaryInformation" stream beside it +for (const rec of readRecords(workbook)) { console.log(rec.type.toString(16), rec.data.length); } ``` @@ -106,6 +118,7 @@ Layered bottom-up, each layer testable against hand-built byte sequences taken f - **`src/workbook/globals.ts`**, **`src/workbook/sheet.ts`** — the two substream readers, each walking the record sequence its ABNF in [MS-XLS] 2.1.7.20.3 / 2.1.7.20.5 defines; `globals.ts` also resolves a 3D reference's own `ixti` to a sheet range through `EXTERNSHEET` and a self-referencing `SupBook`, which `sheet.ts` threads into `ptg.ts` for a Formula record's own 3D references. - **[`excel-number-format`](../excel-number-format/README.md)**, **`src/serial.ts`** — number-format classification and date-serial conversion, the two pieces of xlsx semantics BIFF8 shares because ECMA-376 inherited them from BIFF. The classifier itself is a dependency shared with `ooxml.js`, not a module in this package (ExaDev/documents.js#848) — `classifyNumberFormat` and `BUILTIN_NUMBER_FORMATS` still ride this package's own barrel (`export * from "excel-number-format"` in `src/index.ts`), so `import { classifyNumberFormat } from "xls-codec"` is unchanged. - **`src/content.ts`** — the mapping onto `document-schema.js`. +- **`src/metadata.ts`** — maps `archive-codec`'s `SummaryInformationProperties` to and from `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). ### Deliberately not depended on diff --git a/packages/xls-codec/src/container.ts b/packages/xls-codec/src/container.ts index d11ecda23..9a9be1a7c 100644 --- a/packages/xls-codec/src/container.ts +++ b/packages/xls-codec/src/container.ts @@ -16,14 +16,23 @@ const WORKBOOK_STREAM = "Workbook"; /** BIFF5 and BIFF7 workbooks name their stream "Book" instead. Recognised only to say so in an error, never read: those are different formats record-for-record, not older spellings of this one. */ const LEGACY_WORKBOOK_STREAM = "Book"; +/** The [MS-OLEPS] Property Set Stream a .xls's title/author/dates live in when present ([MS-OSHARED] 2.3.3.2.2) -- a genuinely optional stream, unlike Workbook, since a valid BIFF8 workbook need not carry document properties at all. */ +export const SUMMARY_INFORMATION_STREAM = "\x05SummaryInformation"; + +export interface WorkbookStreams { + readonly workbook: Uint8Array; + /** The raw "\x05SummaryInformation" stream bytes, or undefined when the container carries none. */ + readonly metadata: Uint8Array | undefined; +} + /** - * Extracts the BIFF8 record stream from a .xls file's compound-file container. + * Extracts the BIFF8 record stream, and the optional metadata stream beside it, from a .xls file's compound-file container. * * Throws rather than returning undefined for anything that is not a readable BIFF8 workbook: a caller wanting a soft answer asks isXlsFile first. */ -export function readWorkbookStream( +export function readWorkbookStreams( bytes: Uint8Array, -): Uint8Array { +): WorkbookStreams { if (!isCompoundFile(bytes)) { throw new BiffFormatError( "not a compound file: a .xls workbook is a [MS-CFB] container holding a 'Workbook' stream", @@ -32,7 +41,10 @@ export function readWorkbookStream( const streams = readWorkbookContainer(bytes); const workbook = streams.find((stream) => stream.path === WORKBOOK_STREAM); if (workbook !== undefined) { - return workbook.bytes; + const metadata = streams.find( + (stream) => stream.path === SUMMARY_INFORMATION_STREAM, + ); + return { workbook: workbook.bytes, metadata: metadata?.bytes }; } if (streams.some((stream) => stream.path === LEGACY_WORKBOOK_STREAM)) { throw new BiffFormatError( @@ -77,7 +89,7 @@ export function isXlsFile(bytes: Uint8Array): boolean { (stream) => stream.path === WORKBOOK_STREAM, ); } catch { - // A container too malformed to enumerate is not a workbook this package can read, which is exactly what this predicate reports. The caller that wants the reason calls readWorkbookStream and catches its error. + // A container too malformed to enumerate is not a workbook this package can read, which is exactly what this predicate reports. The caller that wants the reason calls readWorkbookStreams and catches its error. return false; } } diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index b9645b55d..96bdd8f03 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -1,3 +1,8 @@ +import { + readCompoundFile, + writeCompoundFile, + writeSummaryInformationStream, +} from "archive-codec"; import { ContentDocumentSchema, DocumentTreeSchema } from "document-schema.js"; import { describe, expect, it } from "vitest"; @@ -107,6 +112,20 @@ function xlsFile(stream: Uint8Array): Uint8Array { return compoundFile([{ path: "Workbook", bytes: stream }]); } +/** Adds a real "\x05SummaryInformation" stream beside an .xls file's existing streams -- composed with archive-codec's own writeSummaryInformationStream/writeCompoundFile rather than by extending xlsFile, which stays a pure BIFF8-only fixture builder. */ +function withSummaryInformation( + xls: Uint8Array, + metadata: Parameters[0], +): Uint8Array { + return writeCompoundFile([ + ...readCompoundFile(xls), + { + path: "\x05SummaryInformation", + bytes: writeSummaryInformationStream(metadata), + }, + ]); +} + /** The fifteen style XFs a real file writes before its first cell XF, so a cell's own ixfe of 15 lands on the first cell format -- which is what [MS-XLS] 2.5.168 requires of an ixfe. */ function xfTable(...cellFormats: readonly number[]): Uint8Array[] { const styles = Array.from({ length: 15 }, () => @@ -460,6 +479,40 @@ describe("readXlsContent", () => { readXlsContent(new Uint8Array([0x50, 0x4b, 0x03, 0x04])), ).toThrow(BiffFormatError); }); + + describe("metadata", () => { + it('reads title/author/dates from a real "\\x05SummaryInformation" stream', () => { + const bytes = withSummaryInformation( + xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [{ name: "Sheet1", records: [] }], + }), + ), + { + title: "Budget", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }, + ); + const content = readXlsContent(bytes); + expect(content.metadata).toEqual({ + title: "Budget", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }); + }); + + it('reads {} when the container carries no "\\x05SummaryInformation" stream', () => { + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [{ name: "Sheet1", records: [] }], + }), + ); + expect(readXlsContent(bytes).metadata).toEqual({}); + }); + }); }); describe("readXlsContent formula recovery", () => { diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 221d4f1c7..1607bec37 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -1,3 +1,4 @@ +import { readSummaryInformation } from "archive-codec"; import type { ContentCellValue, ContentDocument, @@ -17,7 +18,8 @@ import { splitSubstreams, type Substream, } from "./biff/substreams"; -import { readWorkbookStream } from "./container"; +import { readWorkbookStreams } from "./container"; +import { summaryInformationToLayoutMetadata } from "./metadata"; import { classifyNumberFormat } from "excel-number-format"; import { serialToIsoDate, @@ -82,9 +84,8 @@ const DEFAULT_PRINT_SETTINGS: ContentSheetPrintSettings = { export function readXlsContent( bytes: Uint8Array, ): XlsContentDocument { - const substreams = splitSubstreams( - groupRecords(readRecords(readWorkbookStream(bytes))), - ); + const { workbook, metadata } = readWorkbookStreams(bytes); + const substreams = splitSubstreams(groupRecords(readRecords(workbook))); const globalsSubstream = substreams[0]; if (globalsSubstream === undefined) { throw new BiffFormatError( @@ -101,7 +102,15 @@ export function readXlsContent( const sheets = globals.sheets .filter((entry) => entry.sheetType === SHEET_TYPE_WORKSHEET) .map((entry) => readSheet(entry, substreams, globals)); - return { kind: "spreadsheet", metadata: {}, sheets }; + // Absent when the container carries no "\x05SummaryInformation" stream at all -- a valid BIFF8 workbook need not have one -- and mapped from it through summaryInformationToLayoutMetadata (see src/metadata.ts) otherwise. + return { + kind: "spreadsheet", + metadata: + metadata === undefined + ? {} + : summaryInformationToLayoutMetadata(readSummaryInformation(metadata)), + sheets, + }; } /** The tree-form read: readXlsContent composed with the schema's own structural transform, exactly as ooxml.js's readXlsx wraps readXlsxContent. */ diff --git a/packages/xls-codec/src/index.ts b/packages/xls-codec/src/index.ts index a337686f4..22d602d8b 100644 --- a/packages/xls-codec/src/index.ts +++ b/packages/xls-codec/src/index.ts @@ -13,6 +13,7 @@ export * from "./biff/write-errors"; export * from "./biff/xf-writer"; export * from "./container"; export * from "./content"; +export * from "./metadata"; export * from "excel-number-format"; export * from "./serial"; export * from "./units"; diff --git a/packages/xls-codec/src/metadata.ts b/packages/xls-codec/src/metadata.ts new file mode 100644 index 000000000..e80e1c31c --- /dev/null +++ b/packages/xls-codec/src/metadata.ts @@ -0,0 +1,47 @@ +import type { SummaryInformationProperties } from "archive-codec"; +import type { LayoutMetadata } from "document-schema.js"; + +// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a spreadsheet document. +// +// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: +// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a ContentDocument. +// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). +// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. + +export function summaryInformationToLayoutMetadata( + info: SummaryInformationProperties, +): LayoutMetadata { + return { + title: info.title, + subject: info.subject, + author: info.author, + keywords: info.keywords === undefined ? undefined : [...info.keywords], + createdIso: info.createdIso, + modifiedIso: info.lastSavedIso, + }; +} + +export function layoutMetadataToSummaryInformation( + metadata: LayoutMetadata, +): SummaryInformationProperties { + return { + title: metadata.title, + subject: metadata.subject, + author: metadata.author, + keywords: metadata.keywords, + createdIso: metadata.createdIso, + lastSavedIso: metadata.modifiedIso, + }; +} + +/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writeXlsContent's own call site). */ +export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { + return ( + metadata.title !== undefined || + metadata.subject !== undefined || + metadata.author !== undefined || + (metadata.keywords !== undefined && metadata.keywords.length > 0) || + metadata.createdIso !== undefined || + metadata.modifiedIso !== undefined + ); +} diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 35a3ce842..d27c24719 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -10,7 +10,7 @@ import { DocumentTreeSchema, PAGE_SIZE_LETTER, } from "document-schema.js"; -import { isCompoundFile } from "archive-codec"; +import { isCompoundFile, readCompoundFile } from "archive-codec"; import { describe, expect, it } from "vitest"; import { BiffWriteError } from "./biff/write-errors"; @@ -468,6 +468,37 @@ describe("writeXlsContent", () => { ), ).toThrow(BiffWriteError); }); + + describe("metadata", () => { + it('round-trips title/subject/author/keywords/dates through a real "\\x05SummaryInformation" stream', () => { + const input: XlsContentDocument = { + ...document([ + sheet("Sheet1", [cell(0, 0, { kind: "number", value: 1 })]), + ]), + metadata: { + title: "Budget", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + modifiedIso: "2024-03-20T14:30:00.000Z", + }, + }; + const bytes = writeXlsContent(input); + expect(readXlsContent(bytes).metadata).toEqual(input.metadata); + }); + + it('writes no "\\x05SummaryInformation" stream at all when metadata carries nothing that stream can hold', () => { + const bytes = writeXlsContent( + document([sheet("Sheet1", [cell(0, 0, { kind: "number", value: 1 })])]), + ); + const streams = readCompoundFile(bytes); + expect( + streams.some((stream) => stream.path === "\x05SummaryInformation"), + ).toBe(false); + expect(readXlsContent(bytes).metadata).toEqual({}); + }); + }); }); describe("writeXls", () => { diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index 0f2bec973..939367262 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -1,4 +1,7 @@ -import { writeCompoundFile } from "archive-codec"; +import { + writeCompoundFile, + writeSummaryInformationStream, +} from "archive-codec"; import type { ContentDocument, ContentSheet, @@ -11,6 +14,11 @@ import { BUILTIN_NUMBER_FORMATS } from "excel-number-format"; import { BiffWriteError } from "./biff/write-errors"; import type { XlsContentDocument } from "./content"; +import { SUMMARY_INFORMATION_STREAM } from "./container"; +import { + hasSummaryInformationFields, + layoutMetadataToSummaryInformation, +} from "./metadata"; import { buildWorkbookGlobals, GENERAL_CELL_XF_INDEX, @@ -288,7 +296,17 @@ export function writeXlsContent( content: XlsContentDocument, ): Uint8Array { const stream = buildWorkbookStream(content); - return writeCompoundFile([{ path: WORKBOOK_STREAM_NAME, bytes: stream }]); + const streams = [{ path: WORKBOOK_STREAM_NAME, bytes: stream }]; + // Only when there is something SummaryInformation can actually hold: an input whose metadata carries nothing beyond creator/producer/language (or nothing at all) should read back exactly as it would with no stream present, not force an empty-but-present one into existence. + if (hasSummaryInformationFields(content.metadata)) { + streams.push({ + path: SUMMARY_INFORMATION_STREAM, + bytes: writeSummaryInformationStream( + layoutMetadataToSummaryInformation(content.metadata), + ), + }); + } + return writeCompoundFile(streams); } /** Writes a DocumentTree of kind 'spreadsheet' to real .xls bytes, flattening it to a ContentDocument first -- the counterpart of content.ts's readXls. */ diff --git a/packages/xls-codec/test/smoke.test.mjs b/packages/xls-codec/test/smoke.test.mjs index 679cb2fa8..d5cacf213 100644 --- a/packages/xls-codec/test/smoke.test.mjs +++ b/packages/xls-codec/test/smoke.test.mjs @@ -25,7 +25,10 @@ const BARREL_FUNCTIONS = [ 'serialToIsoDateTime', 'twipsToPoints', 'columnWidthToPoints', - 'readWorkbookStream', + 'readWorkbookStreams', + 'summaryInformationToLayoutMetadata', + 'layoutMetadataToSummaryInformation', + 'hasSummaryInformationFields', 'isXlsFile', 'readXlsContent', 'readXls', @@ -37,6 +40,7 @@ const BARREL_CONSTANTS = [ 'MAX_RECORD_DATA_SIZE', 'BIFF8_VERSION', 'BUILTIN_NUMBER_FORMATS', + 'SUMMARY_INFORMATION_STREAM', ]; const BARREL_CLASSES = ['BiffFormatError', 'BlockCursor']; @@ -77,8 +81,12 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/workbook/sheet.js', exports: ['readSheetRecords'] }, { path: '../dist/serial.js', exports: ['serialToIsoDate'] }, { path: '../dist/units.js', exports: ['twipsToPoints'] }, - { path: '../dist/container.js', exports: ['readWorkbookStream', 'isXlsFile'] }, + { path: '../dist/container.js', exports: ['readWorkbookStreams', 'isXlsFile', 'SUMMARY_INFORMATION_STREAM'] }, { path: '../dist/content.js', exports: ['readXlsContent', 'readXls'] }, + { + path: '../dist/metadata.js', + exports: ['summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', 'hasSummaryInformationFields'], + }, ]; for (const module of DEEP_MODULES) { diff --git a/packages/xls-codec/test/workers/write.test.ts b/packages/xls-codec/test/workers/write.test.ts index 167da45e1..2addc34ad 100644 --- a/packages/xls-codec/test/workers/write.test.ts +++ b/packages/xls-codec/test/workers/write.test.ts @@ -78,4 +78,14 @@ describe("xls-codec write path inside workerd", () => { value: "2026-09-03", }); }); + + it('round-trips document metadata through a real "\\x05SummaryInformation" stream, with no Node-only API', () => { + const input: XlsContentDocument = { + ...document(), + metadata: { title: "Workers isolate title", author: "xls-codec" }, + }; + const metadataBytes = writeXlsContent(input); + const content = readXlsContent(metadataBytes); + expect(content.metadata).toEqual(input.metadata); + }); }); From 4fba3a90c7cea626c598aff453195403457aaf65 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 17:44:54 +0100 Subject: [PATCH 04/12] feat(ppt-codec): read and write document metadata via SummaryInformation readPptContent now maps a "\x05SummaryInformation" stream, when the compound file carries one, onto LayoutMetadata's title/subject/author/ keywords/createdIso/modifiedIso through archive-codec's oleps support; a presentation with no such stream still reads back {}, exactly as before. writePptContent writes the stream back whenever the input's metadata carries anything SummaryInformation can hold, leaving it out entirely for metadata that is empty or carries only fields (creator/ producer/language) the stream has no room for. readPptStreams/writePptStreams, the record-level split one layer below readPptContent/writePptContent, are left untouched: they take or return only the two required [MS-PPT] streams and have no compound file to look a third stream up in, so the container-level metadata lookup lives one layer up, where readPptContent already has the full stream list from its own readCompoundFile call. The field mapping lives in a new src/metadata.ts, identical in shape to doc-codec's and xls-codec's: comments and last-printed have no LayoutMetadata destination, and creator/producer/language have no SummaryInformation source, so each direction only carries the six fields both sides can actually represent. --- packages/ppt-codec/README.md | 26 ++++++++-- packages/ppt-codec/src/index.ts | 1 + packages/ppt-codec/src/metadata.ts | 47 +++++++++++++++++++ packages/ppt-codec/src/read.test.ts | 36 +++++++++++++- packages/ppt-codec/src/read.ts | 24 ++++++++-- packages/ppt-codec/src/write.test.ts | 28 +++++++++++ packages/ppt-codec/src/write.ts | 24 ++++++++-- packages/ppt-codec/test/smoke.test.mjs | 8 ++++ .../ppt-codec/test/workers/ppt-codec.test.ts | 9 ++++ 9 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 packages/ppt-codec/src/metadata.ts diff --git a/packages/ppt-codec/README.md b/packages/ppt-codec/README.md index dba82aa3d..451623209 100644 --- a/packages/ppt-codec/README.md +++ b/packages/ppt-codec/README.md @@ -67,9 +67,9 @@ import { writePpt, writePptContent } from "ppt-codec"; // The tree form: a document-schema.js DocumentTree in, real .ppt bytes out. const pptBytes = writePpt(tree); -// The flat form: metadata plus ContentSlide[] in -- metadata is accepted for -// symmetry with readPptContent's own return shape but is not written anywhere -// (see What it does not write yet). +// The flat form: metadata plus ContentSlide[] in -- title/author/dates are +// written to a real "\x05SummaryInformation" stream when metadata carries +// any of them (see Metadata). const bytes = writePptContent({ metadata: {}, slides }); ``` @@ -97,8 +97,8 @@ Geometry is converted from master units (1/576 inch) to points on the way out, s Each of these is a real construct of the format that this package currently ignores or cannot represent — not a claim that it does not exist: - **Encrypted documents.** Recognised and refused by name (`PptEncryptedError`) rather than misparsed, but not decrypted. +- **`DocumentSummaryInformation`'s extended and user-defined properties** (company, manager, custom properties) — a genuinely different stream from the one [Metadata](#metadata) covers, not attempted at all. - **Speaker notes.** Every slide's `notes` is `""`. Notes live in their own `NotesContainer` persist objects reached through the document's notes list, which is not yet walked. -- **Document metadata.** `metadata` is always `{}`. Document properties live in the compound file's own `SummaryInformation` stream ([MS-OSHARED]), not in any [MS-PPT] record. - **Master and layout inheritance.** A run that states no size, typeface, or weight inherits it from the master's `TextMasterStyleAtom`; this reader reports such a property as absent rather than resolving the cascade, so a run's formatting is what the slide itself states and no more. - **Scheme colours.** A `ColorIndexStruct` naming a colour-scheme slot (rather than a literal sRGB value) yields no colour, because resolving it needs the slide's `SlideSchemeColorSchemeAtom`. - **Per-shape text insets.** Every shape reports PowerPoint's own defaults (0.1 inch left and right, 0.05 inch top and bottom); a per-shape override lives in the shape's `OfficeArtFOPT` property table, which is not read. @@ -137,7 +137,7 @@ Each of these is either a real construct this writer deliberately does not attem - **Grouped shapes, rotation, and any coordinate system beyond a plain `OfficeArtClientAnchor`.** Every shape this writer emits is an ungrouped, unrotated rectangle in slide coordinates; `ContentShape.rotationDeg` is not written, and there is no `OfficeArtChildAnchor`/`OfficeArtFSPGR` group nesting. - **Per-shape text insets, autofit, and paint order.** `ContentShape.insetLeftPt`/`insetTopPt`/`insetRightPt`/`insetBottomPt`, `fontScale`, `lineSpacingReduction`, and `paintOrder` have no `OfficeArtFOPT` property table to land in, since this writer does not build one. - **Masters, layouts, and scheme colours.** No `MainMaster`, no `MasterListWithTextContainer`, and no `SlideSchemeColorSchemeAtom`; every character run's colour must already be a literal, and every paragraph's formatting is exactly what the paragraph itself states. -- **Speaker notes and document metadata.** `PptDocument.metadata` is accepted (for symmetry with `readPptContent`'s own return shape) but never written anywhere; a slide's `notes` is likewise accepted and dropped, since neither `NotesContainer` persist objects nor the compound file's own `SummaryInformation` stream are built. +- **Speaker notes.** `notes` is accepted on a `ContentSlide` and dropped, since `NotesContainer` persist objects are not built. Document metadata is a different story now — see [Metadata](#metadata). - **Hyperlinks, bullets, spacing, margins, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; only `alignment` and `list.level` (as a `TextPFException` indent level) round-trip. - **`strike`, `sourcePath`, `source`, and `frames`.** `ContentRun.strike` has no `TextCFException` bit this writer sets (the format's own `CFMasks`/`CFStyle` carry no strikethrough bit at all — a real gap in [MS-PPT], not a scope choice); the three fidelity/positioning fields are round-trip-irrelevant to a fresh write and are never populated. - **Construct markers.** A `constructStart`/`constructEnd` pair (or any other non-`paragraph` block kind) is excluded from the written text body exactly like an image or table block, per [Writing a document](#writing-a-document). @@ -145,6 +145,21 @@ Each of these is either a real construct this writer deliberately does not attem - **Fractional character sizes.** `ContentRun.sizePt` is rounded to the nearest whole point, since `TextCFException`'s size field is a plain 16-bit integer. - **Fonts, tables, animations, transitions, comments, and the metacharacter atoms.** Nothing here is written for the same reason none of it is read yet — see the corresponding entries in [What it does not read yet](#what-it-does-not-read-yet). +## Metadata + +A `.ppt`'s title, author, and dates do not live in any [MS-PPT] record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Current User`/`PowerPoint Document` in the same [MS-CFB] compound file. `readPptContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writePptContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. + +`readPptStreams`/`writePptStreams`, the record-level split one layer below, do not touch this at all: they take or return only the two required [MS-PPT] streams, with no compound file to look a third stream up in. `readPptContent`/`writePptContent` are where the container-level fact lives. + +The mapping is not 1:1, and each gap is permanent rather than a remaining TODO: + +| Direction | Fields covered | Gap | +| ----------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `PptDocument`. | +| LayoutMetadata → SummaryInformation | the same six fields, in reverse | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers. | + +Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares. + ## Architecture Every module is importable by package-relative path as well as through the barrel — `tsdown` builds one dist file per src module (`root: 'src'`, the layout every sibling codec ships), and `package.json`'s `./*` exports wildcard maps each subpath onto it: @@ -177,6 +192,7 @@ import { readStyleTextPropAtom } from "ppt-codec/text/style"; | `text/style-write` | Writes a `StyleTextPropAtom` from the same `StyleRun`/`ParagraphProperties`/`CharacterProperties` shapes `text/style` reads into. | | `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. | | `content-write` | The inverse: a shape's `ContentBlock[]` to the flat character-counted text body and `StyleTextProps` `text/style-write` needs. | +| `metadata` | Maps `archive-codec`'s `SummaryInformationProperties` to and from `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). | | `read` | The whole read pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. | | `write` | The whole write pipeline, and the `writePpt`/`writePptContent`/`writePptStreams` surface. | | `units` | Master units to points, and points to master units. | diff --git a/packages/ppt-codec/src/index.ts b/packages/ppt-codec/src/index.ts index fc0f62435..7eab1797a 100644 --- a/packages/ppt-codec/src/index.ts +++ b/packages/ppt-codec/src/index.ts @@ -10,6 +10,7 @@ export * from "./document/slide-list-write"; export * from "./drawing/shapes"; export * from "./drawing/shapes-write"; export * from "./errors"; +export * from "./metadata"; export * from "./read"; export * from "./record/header"; export * from "./record/tree"; diff --git a/packages/ppt-codec/src/metadata.ts b/packages/ppt-codec/src/metadata.ts new file mode 100644 index 000000000..f0095178f --- /dev/null +++ b/packages/ppt-codec/src/metadata.ts @@ -0,0 +1,47 @@ +import type { SummaryInformationProperties } from "archive-codec"; +import type { LayoutMetadata } from "document-schema.js"; + +// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a PptDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a presentation. +// +// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: +// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a PptDocument. +// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). +// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. + +export function summaryInformationToLayoutMetadata( + info: SummaryInformationProperties, +): LayoutMetadata { + return { + title: info.title, + subject: info.subject, + author: info.author, + keywords: info.keywords === undefined ? undefined : [...info.keywords], + createdIso: info.createdIso, + modifiedIso: info.lastSavedIso, + }; +} + +export function layoutMetadataToSummaryInformation( + metadata: LayoutMetadata, +): SummaryInformationProperties { + return { + title: metadata.title, + subject: metadata.subject, + author: metadata.author, + keywords: metadata.keywords, + createdIso: metadata.createdIso, + lastSavedIso: metadata.modifiedIso, + }; +} + +/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writePptContent's own call site). */ +export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { + return ( + metadata.title !== undefined || + metadata.subject !== undefined || + metadata.author !== undefined || + (metadata.keywords !== undefined && metadata.keywords.length > 0) || + metadata.createdIso !== undefined || + metadata.modifiedIso !== undefined + ); +} diff --git a/packages/ppt-codec/src/read.test.ts b/packages/ppt-codec/src/read.test.ts index cbbfee66e..d8ce4ff0e 100644 --- a/packages/ppt-codec/src/read.test.ts +++ b/packages/ppt-codec/src/read.test.ts @@ -1,3 +1,4 @@ +import { writeSummaryInformationStream } from "archive-codec"; import { ContentDocumentSchema, DocumentTreeSchema, @@ -8,6 +9,7 @@ import { PptEncryptedError, PptFormatError } from "./errors"; import { CURRENT_USER_STREAM, POWERPOINT_DOCUMENT_STREAM, + SUMMARY_INFORMATION_STREAM, readPpt, readPptContent, readPptStreams, @@ -26,6 +28,23 @@ function pptFile( ]); } +/** The same synthetic presentation pptFile builds, with a real "\x05SummaryInformation" stream added beside it -- composed with archive-codec's own writeSummaryInformationStream rather than by extending test-support/compound-file.ts, which stays a pure [MS-CFB]-only fixture builder. */ +function pptFileWithMetadata( + metadata: Parameters[0], + options: Parameters[0] = {}, +): Uint8Array { + const { currentUserStream, powerPointDocumentStream } = + syntheticPresentation(options); + return compoundFile([ + { name: CURRENT_USER_STREAM, bytes: currentUserStream }, + { name: POWERPOINT_DOCUMENT_STREAM, bytes: powerPointDocumentStream }, + { + name: SUMMARY_INFORMATION_STREAM, + bytes: writeSummaryInformationStream(metadata), + }, + ]); +} + describe("readPptStreams", () => { it("reads the slide size in points, converted from the document's master units", () => { const { currentUserStream, powerPointDocumentStream } = @@ -107,7 +126,7 @@ describe("readPptContent", () => { ]); }); - it("reports an empty metadata record, since document properties live outside every [MS-PPT] record", () => { + it('reports an empty metadata record when the container carries no "\\x05SummaryInformation" stream', () => { expect(readPptContent(pptFile()).metadata).toEqual({}); }); @@ -118,6 +137,21 @@ describe("readPptContent", () => { ]); expect(() => readPptContent(bytes)).toThrow(PptFormatError); }); + + describe("metadata", () => { + it('reads title/author/dates from a real "\\x05SummaryInformation" stream', () => { + const bytes = pptFileWithMetadata({ + title: "Quarterly review", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }); + expect(readPptContent(bytes).metadata).toEqual({ + title: "Quarterly review", + author: "Cornelius", + createdIso: "2024-05-01T00:00:00.000Z", + }); + }); + }); }); describe("the shared schema accepts what the reader produces", () => { diff --git a/packages/ppt-codec/src/read.ts b/packages/ppt-codec/src/read.ts index 8e4f27fdc..71e5e14f8 100644 --- a/packages/ppt-codec/src/read.ts +++ b/packages/ppt-codec/src/read.ts @@ -1,4 +1,4 @@ -import { readCompoundFile } from "archive-codec"; +import { readCompoundFile, readSummaryInformation } from "archive-codec"; import { type ContentBlock, type ContentDocument, @@ -10,6 +10,7 @@ import { assembleTree, } from "document-schema.js"; import { buildParagraphs } from "./content"; +import { summaryInformationToLayoutMetadata } from "./metadata"; import { readDocumentAtom } from "./document/document-atom"; import { readFontNames } from "./document/fonts"; import { @@ -42,6 +43,9 @@ import { POINTS_PER_INCH, masterUnitsToPoints } from "./units"; export const CURRENT_USER_STREAM = "Current User"; export const POWERPOINT_DOCUMENT_STREAM = "PowerPoint Document"; +/** The [MS-OLEPS] Property Set Stream a .ppt's title/author/dates live in when present ([MS-OSHARED] 2.3.3.2.2) -- a genuinely optional stream, unlike the two above, since a valid PowerPoint binary document need not carry document properties at all. */ +export const SUMMARY_INFORMATION_STREAM = "\x05SummaryInformation"; + // PowerPoint's own default text insets: 0.1 inch left and right, 0.05 inch top and bottom -- the same figures ECMA-376 later wrote into a:bodyPr's defaults, and the ones ooxml.js applies to a pptx shape stating none. A per-shape override lives in the shape's OfficeArtFOPT text properties, which this reader does not yet read; see the README's scope note. const DEFAULT_INSET_LEFT_RIGHT_PT = 0.1 * POINTS_PER_INCH; const DEFAULT_INSET_TOP_BOTTOM_PT = 0.05 * POINTS_PER_INCH; @@ -216,7 +220,7 @@ export function readPptStreams( slideList === undefined ? [] : readSlideListWithText(slideList); return { - // Document properties live in the compound file's own SummaryInformation stream ([MS-OSHARED]), not in any [MS-PPT] record, so nothing read here can populate them yet. See the README's scope note. + // Document properties live in the compound file's own "\x05SummaryInformation" stream ([MS-OSHARED]), not in any [MS-PPT] record -- genuinely outside what a caller holding only these two streams can supply. readPptContent, one level up, is where a container-level caller gets the real value: it looks the stream up itself and overrides this field when one is present. metadata: {}, slides: persists.map((persist) => readSlide(powerPointDocumentStream, directory, persist, size, fontNames), @@ -224,13 +228,25 @@ export function readPptStreams( }; } -// Reads a .ppt file's bytes into the flat metadata + slides form. +// Reads a .ppt file's bytes into the flat metadata + slides form. readPptStreams below is the pure record-level read (metadata always {}, since it has no container to look a SummaryInformation stream up in); this wraps it with the one container-level fact readPptStreams cannot know -- whether the compound file also carries a "\x05SummaryInformation" stream -- mapped onto LayoutMetadata through summaryInformationToLayoutMetadata (see src/metadata.ts) when present. export function readPptContent(bytes: Uint8Array): PptDocument { const streams = readCompoundFile(bytes); - return readPptStreams( + const document = readPptStreams( requireStream(streams, CURRENT_USER_STREAM), requireStream(streams, POWERPOINT_DOCUMENT_STREAM), ); + const metadataStream = streams.find( + (stream) => stream.path === SUMMARY_INFORMATION_STREAM, + ); + if (metadataStream === undefined) { + return document; + } + return { + ...document, + metadata: summaryInformationToLayoutMetadata( + readSummaryInformation(metadataStream.bytes), + ), + }; } // Reads a .ppt file's bytes into the shared tree form, the same DocumentTree ooxml.js's readPptx and odf.js's readOdp produce for their own presentation formats. diff --git a/packages/ppt-codec/src/write.test.ts b/packages/ppt-codec/src/write.test.ts index 1f464ee09..9dba22e11 100644 --- a/packages/ppt-codec/src/write.test.ts +++ b/packages/ppt-codec/src/write.test.ts @@ -1,3 +1,4 @@ +import { readCompoundFile } from "archive-codec"; import { ContentDocumentSchema, DocumentTreeSchema, @@ -513,6 +514,33 @@ describe("writePptContent / readPptContent round trip", () => { }; expect(() => writePptContent(document)).toThrow(PptUnsupportedContentError); }); + + describe("metadata", () => { + it('round-trips title/subject/author/keywords/dates through a real "\\x05SummaryInformation" stream', () => { + const document = { + metadata: { + title: "Quarterly review", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + modifiedIso: "2024-03-20T14:30:00.000Z", + }, + slides: [slide()], + }; + const bytes = writePptContent(document); + expect(readPptContent(bytes).metadata).toEqual(document.metadata); + }); + + it('writes no "\\x05SummaryInformation" stream at all when metadata carries nothing that stream can hold', () => { + const bytes = writePptContent({ metadata: {}, slides: [slide()] }); + const streams = readCompoundFile(bytes); + expect( + streams.some((stream) => stream.path === "\x05SummaryInformation"), + ).toBe(false); + expect(readPptContent(bytes).metadata).toEqual({}); + }); + }); }); describe("writePptStreams", () => { diff --git a/packages/ppt-codec/src/write.ts b/packages/ppt-codec/src/write.ts index 94441c8df..9e13be796 100644 --- a/packages/ppt-codec/src/write.ts +++ b/packages/ppt-codec/src/write.ts @@ -1,4 +1,7 @@ -import { writeCompoundFile } from "archive-codec"; +import { + writeCompoundFile, + writeSummaryInformationStream, +} from "archive-codec"; import { type ContentSlide, type DocumentTree, @@ -14,9 +17,14 @@ import { } from "./document/slide-list-write"; import { writeSlideDrawing } from "./drawing/shapes-write"; import { PptUnsupportedContentError } from "./errors"; +import { + hasSummaryInformationFields, + layoutMetadataToSummaryInformation, +} from "./metadata"; import { CURRENT_USER_STREAM, POWERPOINT_DOCUMENT_STREAM, + SUMMARY_INFORMATION_STREAM, type PptDocument, } from "./read"; import { RT_Document, RT_Slide } from "./record/types"; @@ -138,10 +146,20 @@ export function writePptContent( ): Uint8Array { const { currentUserStream, powerPointDocumentStream } = writePptStreams(document); - return writeCompoundFile([ + const streams = [ { path: CURRENT_USER_STREAM, bytes: currentUserStream }, { path: POWERPOINT_DOCUMENT_STREAM, bytes: powerPointDocumentStream }, - ]); + ]; + // Only when there is something SummaryInformation can actually hold: an input whose metadata carries nothing beyond creator/producer/language (or nothing at all) should read back exactly as it would with no stream present, not force an empty-but-present one into existence. + if (hasSummaryInformationFields(document.metadata)) { + streams.push({ + path: SUMMARY_INFORMATION_STREAM, + bytes: writeSummaryInformationStream( + layoutMetadataToSummaryInformation(document.metadata), + ), + }); + } + return writeCompoundFile(streams); } // Writes a presentation DocumentTree to .ppt bytes, the mirror of readPpt. Throws PptUnsupportedContentError for a tree of any other kind: this writer covers presentations only, the same kind readPpt itself always produces. diff --git a/packages/ppt-codec/test/smoke.test.mjs b/packages/ppt-codec/test/smoke.test.mjs index 8162aecff..1f7802f94 100644 --- a/packages/ppt-codec/test/smoke.test.mjs +++ b/packages/ppt-codec/test/smoke.test.mjs @@ -19,6 +19,9 @@ const BARREL_FUNCTIONS = [ "readTextBody", "buildParagraphs", "masterUnitsToPoints", + "summaryInformationToLayoutMetadata", + "layoutMetadataToSummaryInformation", + "hasSummaryInformationFields", ]; const BARREL_CONSTANTS = [ "RECORD_HEADER_SIZE", @@ -26,6 +29,7 @@ const BARREL_CONSTANTS = [ "MASTER_UNITS_PER_POINT", "CURRENT_USER_STREAM", "POWERPOINT_DOCUMENT_STREAM", + "SUMMARY_INFORMATION_STREAM", ]; const BARREL_CLASSES = ["PptFormatError", "PptEncryptedError"]; @@ -56,6 +60,10 @@ describe("dist/ barrel exports are present in both builds", () => { describe("dist/ deep imports resolve for every advertised module, in both builds", () => { const DEEP_MODULES = [ { path: "../dist/read.js", exports: ["readPpt", "readPptContent"] }, + { + path: "../dist/metadata.js", + exports: ["summaryInformationToLayoutMetadata", "layoutMetadataToSummaryInformation", "hasSummaryInformationFields"], + }, { path: "../dist/content.js", exports: ["buildParagraphs"] }, { path: "../dist/units.js", exports: ["masterUnitsToPoints"] }, { path: "../dist/errors.js", exports: ["PptFormatError"] }, diff --git a/packages/ppt-codec/test/workers/ppt-codec.test.ts b/packages/ppt-codec/test/workers/ppt-codec.test.ts index faec808a7..b7f52e29b 100644 --- a/packages/ppt-codec/test/workers/ppt-codec.test.ts +++ b/packages/ppt-codec/test/workers/ppt-codec.test.ts @@ -68,4 +68,13 @@ describe("ppt-codec under the Cloudflare Workers runtime", () => { { kind: "paragraph", runs: [{ text: "Written in the isolate" }] }, ]); }); + + it('round-trips document metadata through a real "\\x05SummaryInformation" stream, with no Node-only API', () => { + const document = { + metadata: { title: "Workers isolate title", author: "ppt-codec" }, + slides: [{ size: { widthPt: 720, heightPt: 540 }, notes: "", shapes: [] }], + }; + const written = readPptContent(writePptContent(document)); + expect(written.metadata).toEqual(document.metadata); + }); }); From 11989fd14513485d9a0798a679c2514a9b830b2c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:04:44 +0100 Subject: [PATCH 05/12] fix(archive-codec): skip undecodable property-set values instead of aborting the whole read readPropertySetStream threw PropertySetFormatError for any PropertyType outside VT_I2/VT_I4/VT_LPSTR/VT_LPWSTR/VT_FILETIME, and for a VT_LPSTR under any CodePage other than CP_WINUNICODE/windows-1252. Both are routine in real SummaryInformation streams: PIDSI_THUMBNAIL (PID 0x11) is VT_CF, written whenever "save preview picture" is on, and a non-Western document's CodePage is routinely something other than 1200/1252. Since doc-codec/xls-codec/ppt-codec call readSummaryInformation with no guard, either case turned an unrelated metadata field into a total read failure for the whole document. An unsupported PropertyType or CodePage is now skipped -- the property is simply absent from the returned map, matching how a PID this reader doesn't project (PIDSI_TEMPLATE and friends) was already handled. Genuine structural nonconformance (a bad ByteOrder, a truncated stream, a Dictionary property, non-zero TypedPropertyValue padding, a CodePage property of the wrong type) still throws. Also corrects the module comment's claim that the five decoded PropertyType values cover every property a real SummaryInformation stream carries -- PIDSI_THUMBNAIL/VT_CF is a real counterexample. --- packages/archive-codec/src/oleps/read.test.ts | 39 +++++++++++++++++-- packages/archive-codec/src/oleps/read.ts | 36 ++++++++++------- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/archive-codec/src/oleps/read.test.ts b/packages/archive-codec/src/oleps/read.test.ts index 1cea5c519..417375662 100644 --- a/packages/archive-codec/src/oleps/read.test.ts +++ b/packages/archive-codec/src/oleps/read.test.ts @@ -192,12 +192,17 @@ describe("readPropertySetStream", () => { }); }); - it("throws PropertySetFormatError for a VT_LPSTR under a CodePage this reader does not decode", () => { + it("skips a VT_LPSTR under a CodePage this reader does not decode, rather than throwing", () => { const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ { pid: 1, value: { type: "VT_I2", value: 932 } }, // Shift-JIS -- neither CP_WINUNICODE nor windows-1252 { pid: 2, value: { type: "VT_LPSTR", value: "x" } }, ]); - expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.has(2)).toBe(false); + expect(propertySet.properties.get(1)).toEqual({ + type: "VT_I2", + value: 932, + }); }); it("throws PropertySetFormatError for a ByteOrder field other than 0xFFFE", () => { @@ -224,14 +229,40 @@ describe("readPropertySetStream", () => { expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); }); - it("throws PropertySetFormatError for a property type this reader does not decode", () => { + it("skips a property type this reader does not decode, rather than throwing", () => { const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ { pid: 2, value: { type: "VT_I4", value: 1 } }, ]); const view = new DataView(bytes.buffer); // The dictionary/value pair are already well-formed for VT_I4; corrupt the Type field alone to an unsupported code (VT_BOOL, 0x000B) without touching the value bytes. view.setUint16(48 + 8 + 8, 0x000b, true); - expect(() => readPropertySetStream(bytes)).toThrow(PropertySetFormatError); + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.has(2)).toBe(false); + }); + + it("returns every decodable property when an undecodable one (e.g. a VT_CF thumbnail) sits among them, rather than aborting the whole read", () => { + // PIDSI_THUMBNAIL (PID 0x11) is VT_CF in a real SummaryInformation stream, a type this reader does not decode -- Word/Excel/PowerPoint write one whenever "save preview picture" is on. Built as VT_I4 (the test-support encoder has no VT_CF case) then the Type field alone is corrupted to VT_CF's real code, 0x0047, leaving PID 2's own decodable property untouched -- the exact scenario the HIGH-severity review finding names: an unsupported type must not abort a read that also carries properties this reader can decode. + const bytes = propertySetStream(FMTID_SUMMARY_INFORMATION, [ + { pid: 2, value: { type: "VT_LPWSTR", value: "Joe's document" } }, + { pid: 0x11, value: { type: "VT_I4", value: 0 } }, + { pid: 4, value: { type: "VT_LPWSTR", value: "Joe" } }, + ]); + const view = new DataView(bytes.buffer); + // PID 0x11 is the dictionary's second entry (index 1); read its own relativeOffset back out rather than hand-deriving the byte length of PID 2's preceding VT_LPWSTR value. + const HEADER_SIZE = 48; + const dictionaryEntryOffset = HEADER_SIZE + 8 + 1 * 8; + const relativeOffset = view.getUint32(dictionaryEntryOffset + 4, true); + view.setUint16(HEADER_SIZE + relativeOffset, 0x0047, true); // VT_CF + const propertySet = readPropertySetStream(bytes); + expect(propertySet.properties.get(2)).toEqual({ + type: "VT_LPWSTR", + value: "Joe's document", + }); + expect(propertySet.properties.get(4)).toEqual({ + type: "VT_LPWSTR", + value: "Joe", + }); + expect(propertySet.properties.has(0x11)).toBe(false); }); it("throws PropertySetFormatError when a stream is shorter than the fixed header", () => { diff --git a/packages/archive-codec/src/oleps/read.ts b/packages/archive-codec/src/oleps/read.ts index 6113a66f3..eb8368ad2 100644 --- a/packages/archive-codec/src/oleps/read.ts +++ b/packages/archive-codec/src/oleps/read.ts @@ -19,7 +19,7 @@ import { type PropertyValue, } from "./wire"; -// A generic reader for the [MS-OLEPS] Property Set Stream format: the stream header, the single PropertySet packet it names (Size, NumProperties, the PropertyIdentifierAndOffset dictionary, and the typed property values themselves), for VT_I2, VT_I4, VT_LPSTR, VT_LPWSTR, and VT_FILETIME -- the five PropertyType values that cover every property a real [MS-OSHARED] SummaryInformation stream carries (title/subject/author/keywords/comments/template/lastAuthor/appName as strings, created/lastSaved/lastPrinted/editTime as FILETIMEs, pageCount/wordCount/charCount/docSecurity as VT_I4, codePage as VT_I2), so this reader parses a whole real-world stream even though ./summary-information.ts only projects a subset of it into named fields. Zero document-format knowledge: it knows property identifiers and typed values, never that PID 2 means a title or that this stream is conventionally named "\x05SummaryInformation" -- that mapping lives one level up, in ./summary-information.ts, the same layering cfb/ole-package.ts gives the OLE Package stream on top of the generic CFB reader in ../cfb/read.ts. +// A generic reader for the [MS-OLEPS] Property Set Stream format: the stream header, the single PropertySet packet it names (Size, NumProperties, the PropertyIdentifierAndOffset dictionary, and the typed property values themselves), decoding VT_I2, VT_I4, VT_LPSTR, VT_LPWSTR, and VT_FILETIME -- the five PropertyType values ./summary-information.ts's own seven projected fields need. A real [MS-OSHARED] SummaryInformation stream can carry other PropertyType values this reader does not decode (PIDSI_THUMBNAIL/PID 0x11 is VT_CF, a clipboard-format thumbnail Word/Excel/PowerPoint write whenever "save preview picture" is on) and a VT_LPSTR under a CodePage other than CP_WINUNICODE/windows-1252 (a real, common case for non-Western documents): a property this reader cannot decode -- unsupported PropertyType, or an unsupported CodePage for VT_LPSTR -- is skipped rather than aborting the whole read, since an undecodable value is a gap in projection, not a structural nonconformance, and every PID this reader does decode still parses correctly around it. Zero document-format knowledge: it knows property identifiers and typed values, never that PID 2 means a title or that this stream is conventionally named "\x05SummaryInformation" -- that mapping lives one level up, in ./summary-information.ts, the same layering cfb/ole-package.ts gives the OLE Package stream on top of the generic CFB reader in ../cfb/read.ts. // // Two genuine [MS-OLEPS] features are out of scope, deliberately, rather than by oversight: a PropertySetStream can carry two property sets in one physical stream (2.21 -- how DocumentSummaryInformation and its UserDefinedProperties share a stream), and a property set can carry named, dictionary-keyed properties (via PID 0, the Dictionary property) rather than purely numeric ones. Neither ever appears in a "\x05SummaryInformation" stream -- SummaryInformation is always exactly one property set, and its properties are always identified numerically -- so a reader that rejects both stays honest about not reading DocumentSummaryInformation while still parsing every real SummaryInformation stream in full. @@ -46,11 +46,13 @@ function requireBytes( const ANSI_DECODER = new TextDecoder("windows-1252"); const UTF16_DECODER = new TextDecoder("utf-16le"); -function decodeAnsi(bytes: Uint8Array, codepage: number): string { +// Returns undefined, rather than throwing, for a CodePage this reader does not decode -- the property is skipped by its caller (readCodePageString) rather than aborting the whole stream, exactly like an unsupported PropertyType in the main switch below. +function decodeAnsi( + bytes: Uint8Array, + codepage: number, +): string | undefined { if (codepage !== WINDOWS_1252_CODEPAGE) { - throw new PropertySetFormatError( - `property set declares CodePage ${codepage}, which this reader does not decode (only CP_WINUNICODE/1200 and windows-1252/1252 are supported)`, - ); + return undefined; } return ANSI_DECODER.decode(bytes); } @@ -61,13 +63,13 @@ function truncateAtNull(value: string): string { return index === -1 ? value : value.slice(0, index); } -// [MS-OLEPS] 2.19 CodePageString: Size(4) is the byte length of Characters including its null terminator but excluding padding; Characters is that many bytes, ANSI- or UTF-16LE-encoded depending on the property set's own CodePage property, padded to a 4-byte boundary. +// [MS-OLEPS] 2.19 CodePageString: Size(4) is the byte length of Characters including its null terminator but excluding padding; Characters is that many bytes, ANSI- or UTF-16LE-encoded depending on the property set's own CodePage property, padded to a 4-byte boundary. Returns undefined, rather than throwing, when the property set's CodePage is one this reader does not decode -- the property's structural framing (Size, Characters) is still validated, only its content is left undecoded, so the caller can skip just this one property. function readCodePageString( bytes: Uint8Array, view: DataView, offset: number, codepage: number, -): string { +): string | undefined { requireBytes(bytes.length, offset, 4, "a CodePageString's Size field"); const size = view.getUint32(offset, true); requireBytes( @@ -77,9 +79,11 @@ function readCodePageString( "a CodePageString's Characters field", ); const raw = bytes.subarray(offset + 4, offset + 4 + size); - return codepage === CP_WINUNICODE - ? truncateAtNull(UTF16_DECODER.decode(raw)) - : truncateAtNull(decodeAnsi(raw, codepage)); + if (codepage === CP_WINUNICODE) { + return truncateAtNull(UTF16_DECODER.decode(raw)); + } + const decoded = decodeAnsi(raw, codepage); + return decoded === undefined ? undefined : truncateAtNull(decoded); } // [MS-OLEPS] 2.20 UnicodeString: Length(4) is the UTF-16 code-unit count of Characters including its null terminator but excluding padding; Characters is that many 16-bit units, always UTF-16LE regardless of the property set's CodePage property, padded to a 4-byte boundary. @@ -106,7 +110,7 @@ interface DictionaryEntry { readonly relativeOffset: number; } -// Parses a [MS-OLEPS] Property Set Stream: the header (validating ByteOrder and the single-property-set requirement above), the PropertySet packet's dictionary, and every property's typed value. Throws PropertySetFormatError on any structural nonconformance or on a property type this reader does not decode -- loud failure, never a partial property map that looks complete. +// Parses a [MS-OLEPS] Property Set Stream: the header (validating ByteOrder and the single-property-set requirement above), the PropertySet packet's dictionary, and every property's typed value. Throws PropertySetFormatError on any structural nonconformance (a bad ByteOrder, a truncated stream, a Dictionary property, non-zero TypedPropertyValue padding, a CodePage property of the wrong type) -- loud failure, never a partial property map that looks complete. A property this reader cannot decode -- an unsupported PropertyType, or a VT_LPSTR under an unsupported CodePage -- is absent from the returned map rather than thrown on, since that is a projection gap, not nonconformance (see the module comment above). export function readPropertySetStream( bytes: Uint8Array, ): PropertySet { @@ -233,7 +237,10 @@ export function readPropertySetStream( } case VT_LPSTR: { const value = readCodePageString(bytes, view, valueOffset, codepage); - properties.set(entry.pid, { type: "VT_LPSTR", value }); + // undefined means an unsupported CodePage -- skip the property rather than aborting the whole read (see the module comment above). + if (value !== undefined) { + properties.set(entry.pid, { type: "VT_LPSTR", value }); + } break; } case VT_LPWSTR: { @@ -257,9 +264,8 @@ export function readPropertySetStream( break; } default: - throw new PropertySetFormatError( - `property ${entry.pid} has type 0x${type.toString(16)}, which this reader does not decode (supported: VT_I2, VT_I4, VT_LPSTR, VT_LPWSTR, VT_FILETIME)`, - ); + // A PropertyType this reader does not decode (e.g. VT_CF, a PIDSI_THUMBNAIL clipboard format) -- skipped rather than aborting the whole read, since an undecodable value is a projection gap, not a structural violation (see the module comment above). + break; } } From fecc356ad7971e4d66895e449335716afd15a0ee Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:06:13 +0100 Subject: [PATCH 06/12] fix(doc-codec): reject a malformed createdIso/modifiedIso before the FILETIME conversion layoutMetadataToSummaryInformation passed LayoutMetadata.createdIso/ modifiedIso straight through to archive-codec's writeSummaryInformationStream, which converts each into a FILETIME via `new Date(iso)`. A malformed string produces an Invalid Date, whose getTime() is NaN, and BigInt(NaN) then throws an opaque RangeError with no indication which field or package caused it. Both fields are now validated as real dates before crossing into archive-codec's own shape, throwing a DocFormatError that names the offending field instead. --- packages/doc-codec/src/metadata.ts | 15 +++++++++++++++ packages/doc-codec/src/write.test.ts | 18 +++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/doc-codec/src/metadata.ts b/packages/doc-codec/src/metadata.ts index b3402468a..2ed1f0478 100644 --- a/packages/doc-codec/src/metadata.ts +++ b/packages/doc-codec/src/metadata.ts @@ -1,5 +1,6 @@ import type { SummaryInformationProperties } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; +import { DocFormatError } from "./errors"; // Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a wordprocessing document. // @@ -21,9 +22,23 @@ export function summaryInformationToLayoutMetadata( }; } +// writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a DocFormatError naming the actual field instead. +function requireValidIsoDate( + value: string | undefined, + field: "createdIso" | "modifiedIso", +): void { + if (value !== undefined && Number.isNaN(new Date(value).getTime())) { + throw new DocFormatError( + `LayoutMetadata.${field} "${value}" is not a valid date string, so it cannot be written as a SummaryInformation FILETIME property`, + ); + } +} + export function layoutMetadataToSummaryInformation( metadata: LayoutMetadata, ): SummaryInformationProperties { + requireValidIsoDate(metadata.createdIso, "createdIso"); + requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); return { title: metadata.title, subject: metadata.subject, diff --git a/packages/doc-codec/src/write.test.ts b/packages/doc-codec/src/write.test.ts index 1a8c04254..a128ce338 100644 --- a/packages/doc-codec/src/write.test.ts +++ b/packages/doc-codec/src/write.test.ts @@ -8,7 +8,7 @@ import { } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { isDocBytes } from "./detect"; -import { DocUnsupportedError } from "./errors"; +import { DocFormatError, DocUnsupportedError } from "./errors"; import { readDocContent } from "./read"; import { writeDocContent } from "./write"; @@ -607,5 +607,21 @@ describe("writeDocContent tables", () => { ).toBe(false); expect(readDocContent(bytes).metadata).toEqual({}); }); + + it("throws a DocFormatError, not a raw RangeError, for a malformed createdIso", () => { + const input: ContentDocument = { + ...document([paragraph([{ text: "Hello." }])]), + metadata: { createdIso: "not-a-real-date" }, + }; + expect(() => writeDocContent(input)).toThrow(DocFormatError); + }); + + it("throws a DocFormatError, not a raw RangeError, for a malformed modifiedIso", () => { + const input: ContentDocument = { + ...document([paragraph([{ text: "Hello." }])]), + metadata: { modifiedIso: "not-a-real-date" }, + }; + expect(() => writeDocContent(input)).toThrow(DocFormatError); + }); }); }); From ee7e6e3f28a83ad4d61c5ac8f876b1f906b6dc8e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:06:17 +0100 Subject: [PATCH 07/12] fix(xls-codec): reject a malformed createdIso/modifiedIso before the FILETIME conversion layoutMetadataToSummaryInformation passed LayoutMetadata.createdIso/ modifiedIso straight through to archive-codec's writeSummaryInformationStream, which converts each into a FILETIME via `new Date(iso)`. A malformed string produces an Invalid Date, whose getTime() is NaN, and BigInt(NaN) then throws an opaque RangeError with no indication which field or package caused it. Both fields are now validated as real dates before crossing into archive-codec's own shape, throwing a BiffWriteError that names the offending field instead. --- packages/xls-codec/src/metadata.ts | 15 +++++++++++++++ packages/xls-codec/src/write.test.ts | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/xls-codec/src/metadata.ts b/packages/xls-codec/src/metadata.ts index e80e1c31c..0d5af03aa 100644 --- a/packages/xls-codec/src/metadata.ts +++ b/packages/xls-codec/src/metadata.ts @@ -1,5 +1,6 @@ import type { SummaryInformationProperties } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; +import { BiffWriteError } from "./biff/write-errors"; // Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a spreadsheet document. // @@ -21,9 +22,23 @@ export function summaryInformationToLayoutMetadata( }; } +// writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a BiffWriteError naming the actual field instead. +function requireValidIsoDate( + value: string | undefined, + field: "createdIso" | "modifiedIso", +): void { + if (value !== undefined && Number.isNaN(new Date(value).getTime())) { + throw new BiffWriteError( + `LayoutMetadata.${field} "${value}" is not a valid date string, so it cannot be written as a SummaryInformation FILETIME property`, + ); + } +} + export function layoutMetadataToSummaryInformation( metadata: LayoutMetadata, ): SummaryInformationProperties { + requireValidIsoDate(metadata.createdIso, "createdIso"); + requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); return { title: metadata.title, subject: metadata.subject, diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index d27c24719..0c1e52103 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -498,6 +498,26 @@ describe("writeXlsContent", () => { ).toBe(false); expect(readXlsContent(bytes).metadata).toEqual({}); }); + + it("throws a BiffWriteError, not a raw RangeError, for a malformed createdIso", () => { + const input: XlsContentDocument = { + ...document([ + sheet("Sheet1", [cell(0, 0, { kind: "number", value: 1 })]), + ]), + metadata: { createdIso: "not-a-real-date" }, + }; + expect(() => writeXlsContent(input)).toThrow(BiffWriteError); + }); + + it("throws a BiffWriteError, not a raw RangeError, for a malformed modifiedIso", () => { + const input: XlsContentDocument = { + ...document([ + sheet("Sheet1", [cell(0, 0, { kind: "number", value: 1 })]), + ]), + metadata: { modifiedIso: "not-a-real-date" }, + }; + expect(() => writeXlsContent(input)).toThrow(BiffWriteError); + }); }); }); From 642bb25e9cf7881f85564d49ed772887e0cee0b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:06:21 +0100 Subject: [PATCH 08/12] fix(ppt-codec): reject a malformed createdIso/modifiedIso before the FILETIME conversion layoutMetadataToSummaryInformation passed LayoutMetadata.createdIso/ modifiedIso straight through to archive-codec's writeSummaryInformationStream, which converts each into a FILETIME via `new Date(iso)`. A malformed string produces an Invalid Date, whose getTime() is NaN, and BigInt(NaN) then throws an opaque RangeError with no indication which field or package caused it. Both fields are now validated as real dates before crossing into archive-codec's own shape, throwing a PptUnsupportedContentError that names the offending field instead -- the same write-side error class this package's own convention reserves for content outside this writer's scope. --- packages/ppt-codec/src/metadata.ts | 15 +++++++++++++++ packages/ppt-codec/src/write.test.ts | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/ppt-codec/src/metadata.ts b/packages/ppt-codec/src/metadata.ts index f0095178f..337da569d 100644 --- a/packages/ppt-codec/src/metadata.ts +++ b/packages/ppt-codec/src/metadata.ts @@ -1,5 +1,6 @@ import type { SummaryInformationProperties } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; +import { PptUnsupportedContentError } from "./errors"; // Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a PptDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a presentation. // @@ -21,9 +22,23 @@ export function summaryInformationToLayoutMetadata( }; } +// writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a PptUnsupportedContentError naming the actual field instead -- the same write-side error class every other content-outside-this-writer's-scope case throws (see errors.ts). +function requireValidIsoDate( + value: string | undefined, + field: "createdIso" | "modifiedIso", +): void { + if (value !== undefined && Number.isNaN(new Date(value).getTime())) { + throw new PptUnsupportedContentError( + `LayoutMetadata.${field} "${value}" is not a valid date string, so it cannot be written as a SummaryInformation FILETIME property`, + ); + } +} + export function layoutMetadataToSummaryInformation( metadata: LayoutMetadata, ): SummaryInformationProperties { + requireValidIsoDate(metadata.createdIso, "createdIso"); + requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); return { title: metadata.title, subject: metadata.subject, diff --git a/packages/ppt-codec/src/write.test.ts b/packages/ppt-codec/src/write.test.ts index 9dba22e11..f879f7c70 100644 --- a/packages/ppt-codec/src/write.test.ts +++ b/packages/ppt-codec/src/write.test.ts @@ -540,6 +540,26 @@ describe("writePptContent / readPptContent round trip", () => { ).toBe(false); expect(readPptContent(bytes).metadata).toEqual({}); }); + + it("throws a PptUnsupportedContentError, not a raw RangeError, for a malformed createdIso", () => { + const document = { + metadata: { createdIso: "not-a-real-date" }, + slides: [slide()], + }; + expect(() => writePptContent(document)).toThrow( + PptUnsupportedContentError, + ); + }); + + it("throws a PptUnsupportedContentError, not a raw RangeError, for a malformed modifiedIso", () => { + const document = { + metadata: { modifiedIso: "not-a-real-date" }, + slides: [slide()], + }; + expect(() => writePptContent(document)).toThrow( + PptUnsupportedContentError, + ); + }); }); }); From 62fc832f31a389d5084875885bb79400d34ec810 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:17:59 +0100 Subject: [PATCH 09/12] feat(archive-codec): add the LayoutMetadata <-> SummaryInformationProperties mapping doc-codec, xls-codec, and ppt-codec each carried a byte-identical src/metadata.ts mapping SummaryInformationProperties to and from document-schema.js's LayoutMetadata, differing only in a few words of comment. The mapping itself is format-agnostic -- nothing about it is specific to .doc, .xls, or .ppt -- so it now lives once, in a new oleps/layout-metadata module alongside the property-set codec it sits on top of, exported from the package barrel. Depends on document-schema.js for the LayoutMetadata type: a foundation-to-foundation dependency already established by document-outline.js, and confirmed not to break Worker-isomorphism (test:workers still passes). --- packages/archive-codec/README.md | 29 +++-- packages/archive-codec/package.json | 1 + packages/archive-codec/src/index.ts | 3 +- .../src/oleps/layout-metadata.test.ts | 122 ++++++++++++++++++ .../src/oleps/layout-metadata.ts | 49 +++++++ packages/archive-codec/test/smoke.test.mjs | 7 + pnpm-lock.yaml | 3 + 7 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 packages/archive-codec/src/oleps/layout-metadata.test.ts create mode 100644 packages/archive-codec/src/oleps/layout-metadata.ts diff --git a/packages/archive-codec/README.md b/packages/archive-codec/README.md index 8df433a0b..9d006697a 100644 --- a/packages/archive-codec/README.md +++ b/packages/archive-codec/README.md @@ -42,18 +42,19 @@ import { walkArchive } from "archive-codec/zip/walk"; The smoke suite (`test/smoke.test.mjs`) is the guard on that advertisement: it loads each module below from the built `dist/` in both module systems, so a build config that stops serving an advertised subpath fails the suite — neither publint nor `attw` catches a wildcard whose targets are missing. -| Module | Exports | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` | -| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` | -| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` | -| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) | -| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` | -| `cfb/write` | `writeCompoundFile` ([MS-CFB] container generation), `CompoundFileWriteError`, `WriteCompoundFileOptions` — takes the `CompoundFileStream` array `cfb/read` returns | -| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` | -| `oleps/read` | `readPropertySetStream` (generic [MS-OLEPS] property-set decoding), `PropertySetFormatError` | -| `oleps/write` | `writePropertySetStream` (generic [MS-OLEPS] property-set encoding), `PropertySetWriteError` — takes the `PropertySet` shape `oleps/read` returns | -| `oleps/summary-information` | `readSummaryInformation`, `writeSummaryInformationStream`, `SummaryInformationProperties`, `FMTID_SUMMARY_INFORMATION` | +| Module | Exports | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` | +| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` | +| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` | +| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) | +| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` | +| `cfb/write` | `writeCompoundFile` ([MS-CFB] container generation), `CompoundFileWriteError`, `WriteCompoundFileOptions` — takes the `CompoundFileStream` array `cfb/read` returns | +| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` | +| `oleps/read` | `readPropertySetStream` (generic [MS-OLEPS] property-set decoding), `PropertySetFormatError` | +| `oleps/write` | `writePropertySetStream` (generic [MS-OLEPS] property-set encoding), `PropertySetWriteError` — takes the `PropertySet` shape `oleps/read` returns | +| `oleps/summary-information` | `readSummaryInformation`, `writeSummaryInformationStream`, `SummaryInformationProperties`, `FMTID_SUMMARY_INFORMATION` | +| `oleps/layout-metadata` | `summaryInformationToLayoutMetadata`, `layoutMetadataToSummaryInformation`, `hasSummaryInformationFields` — the `SummaryInformationProperties` <-> `document-schema.js`'s `LayoutMetadata` mapping, shared by `doc-codec`/`xls-codec`/`ppt-codec` (see [Property sets](#property-sets)) | ### Recursive walking @@ -143,6 +144,10 @@ const summaryStream = writeSummaryInformationStream({ title: "Q3 report" }); `readSummaryInformation`/`writeSummaryInformationStream` cover the seven SummaryInformation fields a caller actually needs (title, subject, author, keywords, comments, and the created/last-saved/last-printed FILETIME timestamps, as ISO-8601 strings); everything else the property set can carry (template, last author, revision number, application name, edit time, page/word/character counts, document security) is read into the stream but not projected into `SummaryInformationProperties`, and the separate `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. `readPropertySetStream`/`writePropertySetStream` are the generic layer beneath it — a `PropertySet`'s `formatId` and its `properties` map, keyed by `PropertyIdentifier`, valued by a `{ type, value }` pair over `VT_I2`/`VT_I4`/`VT_LPSTR`/`VT_LPWSTR`/`VT_FILETIME` — for a caller working with a different, non-SummaryInformation property set built on the identical [MS-OLEPS] wire format. The writer only emits `VT_LPWSTR` (Unicode) strings, never `VT_LPSTR`: a `CodePageString`'s ANSI encoding depends on the property set's own CodePage property, and writing an arbitrary codepage's bytes would need a full codepage table this package does not carry, so `VT_LPWSTR`'s codepage-independent UTF-16LE sidesteps the question entirely. The reader still decodes `VT_LPSTR` on the way in — `CP_WINUNICODE` (1200) and windows-1252 (1252, the value the [MS-OLEPS] SummaryInformation worked example itself declares, and the same ANSI convention `cfb/ole-package.ts` already uses) — since a real Office-authored file almost always writes ANSI strings, not Unicode ones. +A property whose type this reader does not decode — an unsupported `PropertyType` (e.g. `VT_CF`, the clipboard-format type `PIDSI_THUMBNAIL` uses), or a `VT_LPSTR` under a `CodePage` other than `CP_WINUNICODE`/windows-1252 — is skipped rather than aborting the whole read: a real SummaryInformation stream routinely carries a thumbnail or a non-Western codepage, and one undecodable property must not fail a read that also carries properties this reader can decode. Genuine structural nonconformance (a bad `ByteOrder`, a truncated stream, a `Dictionary` property, non-zero `TypedPropertyValue` padding) still throws `PropertySetFormatError`. + +`oleps/layout-metadata`'s `summaryInformationToLayoutMetadata`/`layoutMetadataToSummaryInformation`/`hasSummaryInformationFields` map `SummaryInformationProperties` to and from `document-schema.js`'s own `LayoutMetadata` — the shared metadata shape every codec's `ContentDocument` carries, format-agnostic rather than specific to any one legacy binary format. `doc-codec`, `xls-codec`, and `ppt-codec` each import these directly rather than maintaining their own copy, wrapping `layoutMetadataToSummaryInformation` with their own `createdIso`/`modifiedIso` date validation so a malformed date is reported through that package's own error vocabulary rather than an opaque `RangeError` out of the FILETIME conversion. + ### ZIP container `zipPackage` takes an _ordered_ array of `[path, entry]` tuples, not a `Record`, so the caller controls the exact emission order deterministically (the property formats with a fixed-offset first entry — ODF's `mimetype` — depend on), and any entry can be written stored-uncompressed via `stored: true`. `unzipPackage` is the read side; the returned `Record` makes no ordering promise and collapses duplicate paths. diff --git a/packages/archive-codec/package.json b/packages/archive-codec/package.json index 69bbdba38..2f7eb8466 100644 --- a/packages/archive-codec/package.json +++ b/packages/archive-codec/package.json @@ -67,6 +67,7 @@ }, "packageManager": "pnpm@11.6.0", "dependencies": { + "document-schema.js": "^5.5.0", "fflate": "^0.8.2" }, "devDependencies": { diff --git a/packages/archive-codec/src/index.ts b/packages/archive-codec/src/index.ts index d1df4e6f9..785bc975c 100644 --- a/packages/archive-codec/src/index.ts +++ b/packages/archive-codec/src/index.ts @@ -1,8 +1,9 @@ -// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in, and [MS-OLEPS] Property Set Stream reading and writing (generic, plus the SummaryInformation-specific mapping every legacy binary Office format's metadata lives in) -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this. +// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in, and [MS-OLEPS] Property Set Stream reading and writing (generic, the SummaryInformation-specific mapping every legacy binary Office format's metadata lives in, and the LayoutMetadata mapping on top of that, shared across every codec since the mapping itself is format-agnostic) -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this. export * from "./cfb/detect"; export * from "./cfb/ole-package"; export * from "./cfb/read"; export * from "./cfb/write"; +export * from "./oleps/layout-metadata"; export * from "./oleps/read"; export * from "./oleps/summary-information"; export type { PropertySet, PropertyValue } from "./oleps/wire"; diff --git a/packages/archive-codec/src/oleps/layout-metadata.test.ts b/packages/archive-codec/src/oleps/layout-metadata.test.ts new file mode 100644 index 000000000..6713bc090 --- /dev/null +++ b/packages/archive-codec/src/oleps/layout-metadata.test.ts @@ -0,0 +1,122 @@ +import type { LayoutMetadata } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import type { SummaryInformationProperties } from "./summary-information"; +import { + hasSummaryInformationFields, + layoutMetadataToSummaryInformation, + summaryInformationToLayoutMetadata, +} from "./layout-metadata"; + +// Direct unit coverage for the mapping doc-codec/xls-codec/ppt-codec's own metadata.ts modules used to duplicate three times before it moved here (ExaDev/documents.js#887 review finding 5). Each package's own write.test.ts still round-trips this through a real "\x05SummaryInformation" stream; these tests cover the mapping itself in isolation, including the two permanent gaps. + +describe("summaryInformationToLayoutMetadata", () => { + it("maps every field SummaryInformation and LayoutMetadata share, including the lastSavedIso -> modifiedIso rename", () => { + const info: SummaryInformationProperties = { + title: "Quarterly report", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + lastSavedIso: "2024-03-20T14:30:00.000Z", + }; + expect(summaryInformationToLayoutMetadata(info)).toEqual({ + title: "Quarterly report", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + modifiedIso: "2024-03-20T14:30:00.000Z", + }); + }); + + it("drops comments/lastPrintedIso, which LayoutMetadata has no field for", () => { + const info: SummaryInformationProperties = { + title: "Report", + comments: "Draft only", + lastPrintedIso: "2024-02-01T00:00:00.000Z", + }; + const metadata = summaryInformationToLayoutMetadata(info); + expect(metadata).not.toHaveProperty("comments"); + expect(metadata).not.toHaveProperty("lastPrintedIso"); + }); + + it("copies keywords into a new array rather than aliasing the input", () => { + const keywords = ["a", "b"]; + const info: SummaryInformationProperties = { keywords }; + const metadata = summaryInformationToLayoutMetadata(info); + expect(metadata.keywords).toEqual(keywords); + expect(metadata.keywords).not.toBe(keywords); + }); +}); + +describe("layoutMetadataToSummaryInformation", () => { + it("maps every field SummaryInformation and LayoutMetadata share, including the modifiedIso -> lastSavedIso rename", () => { + const metadata: LayoutMetadata = { + title: "Quarterly report", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + modifiedIso: "2024-03-20T14:30:00.000Z", + }; + expect(layoutMetadataToSummaryInformation(metadata)).toEqual({ + title: "Quarterly report", + subject: "Finance", + author: "Joe", + keywords: ["finance", "quarterly"], + createdIso: "2024-01-15T09:00:00.000Z", + lastSavedIso: "2024-03-20T14:30:00.000Z", + }); + }); + + it("drops creator/producer/language, which SummaryInformation has no field for", () => { + const metadata: LayoutMetadata = { + creator: "Some Tool", + producer: "Some Producer", + language: "en-GB", + }; + const info = layoutMetadataToSummaryInformation(metadata); + expect(info).not.toHaveProperty("creator"); + expect(info).not.toHaveProperty("producer"); + expect(info).not.toHaveProperty("language"); + }); + + it("does not validate createdIso/modifiedIso as real dates -- that is each caller's own responsibility", () => { + const metadata: LayoutMetadata = { createdIso: "not-a-real-date" }; + expect(() => layoutMetadataToSummaryInformation(metadata)).not.toThrow(); + expect(layoutMetadataToSummaryInformation(metadata).createdIso).toBe( + "not-a-real-date", + ); + }); +}); + +describe("hasSummaryInformationFields", () => { + it("is false for metadata carrying only fields SummaryInformation cannot hold", () => { + expect( + hasSummaryInformationFields({ + creator: "Some Tool", + producer: "Some Producer", + language: "en-GB", + }), + ).toBe(false); + }); + + it("is false for an empty metadata object", () => { + expect(hasSummaryInformationFields({})).toBe(false); + }); + + it("is false for an empty keywords array", () => { + expect(hasSummaryInformationFields({ keywords: [] })).toBe(false); + }); + + it.each([ + ["title", { title: "x" }], + ["subject", { subject: "x" }], + ["author", { author: "x" }], + ["keywords", { keywords: ["x"] }], + ["createdIso", { createdIso: "2024-01-15T09:00:00.000Z" }], + ["modifiedIso", { modifiedIso: "2024-01-15T09:00:00.000Z" }], + ])("is true when metadata carries %s", (_field, metadata) => { + expect(hasSummaryInformationFields(metadata)).toBe(true); + }); +}); diff --git a/packages/archive-codec/src/oleps/layout-metadata.ts b/packages/archive-codec/src/oleps/layout-metadata.ts new file mode 100644 index 000000000..6da218e9e --- /dev/null +++ b/packages/archive-codec/src/oleps/layout-metadata.ts @@ -0,0 +1,49 @@ +import type { LayoutMetadata } from "document-schema.js"; +import type { SummaryInformationProperties } from "./summary-information"; + +// Maps between the seven fields ./summary-information.ts reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape every codec's ContentDocument carries. This lives here rather than in doc-codec/xls-codec/ppt-codec because it used to be three byte-identical copies: LayoutMetadata is format-agnostic (document-schema.js is a foundation package, not tied to .doc/.xls/.ppt specifically), so nothing about this mapping is specific to any one of the three legacy binary formats that happen to consume it today. +// +// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: +// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no codec has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a ContentDocument. +// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers. +// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. +// +// Deliberately does not validate createdIso/modifiedIso as real dates: this module has no error vocabulary of its own to report a malformed one through (see PropertySetFormatError's own read-side scope), and each caller reports that failure through its own named write-side error class (DocFormatError/BiffWriteError/PptUnsupportedContentError). A caller validates both fields itself, immediately before calling layoutMetadataToSummaryInformation. + +export function summaryInformationToLayoutMetadata( + info: SummaryInformationProperties, +): LayoutMetadata { + return { + title: info.title, + subject: info.subject, + author: info.author, + keywords: info.keywords === undefined ? undefined : [...info.keywords], + createdIso: info.createdIso, + modifiedIso: info.lastSavedIso, + }; +} + +export function layoutMetadataToSummaryInformation( + metadata: LayoutMetadata, +): SummaryInformationProperties { + return { + title: metadata.title, + subject: metadata.subject, + author: metadata.author, + keywords: metadata.keywords, + createdIso: metadata.createdIso, + lastSavedIso: metadata.modifiedIso, + }; +} + +/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see each caller's own write-side entry point). */ +export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { + return ( + metadata.title !== undefined || + metadata.subject !== undefined || + metadata.author !== undefined || + (metadata.keywords !== undefined && metadata.keywords.length > 0) || + metadata.createdIso !== undefined || + metadata.modifiedIso !== undefined + ); +} diff --git a/packages/archive-codec/test/smoke.test.mjs b/packages/archive-codec/test/smoke.test.mjs index 8db4ca84a..7e92acdb4 100644 --- a/packages/archive-codec/test/smoke.test.mjs +++ b/packages/archive-codec/test/smoke.test.mjs @@ -21,6 +21,9 @@ const BARREL_FUNCTIONS = [ 'writePropertySetStream', 'readSummaryInformation', 'writeSummaryInformationStream', + 'summaryInformationToLayoutMetadata', + 'layoutMetadataToSummaryInformation', + 'hasSummaryInformationFields', ]; const BARREL_CONSTANTS = [ 'MAX_WALK_DEPTH', @@ -76,6 +79,10 @@ describe('dist/ deep imports resolve for every advertised module, in both builds path: '../dist/oleps/summary-information.js', exports: ['readSummaryInformation', 'writeSummaryInformationStream', 'FMTID_SUMMARY_INFORMATION'], }, + { + path: '../dist/oleps/layout-metadata.js', + exports: ['summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', 'hasSummaryInformationFields'], + }, { path: '../dist/magic.js', exports: [] }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2efe383eb..4e394d46d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: packages/archive-codec: dependencies: + document-schema.js: + specifier: ^5.5.0 + version: link:../document-schema.js fflate: specifier: ^0.8.2 version: 0.8.3 From 95f89bb10aae0ead4aa7057d9c9ae835695c5cec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:18:09 +0100 Subject: [PATCH 10/12] refactor(doc-codec): consume archive-codec's shared LayoutMetadata mapping summaryInformationToLayoutMetadata and hasSummaryInformationFields were a byte-identical copy of xls-codec's and ppt-codec's own -- format- agnostic mapping logic that had nothing to do with .doc specifically. Both now come directly from archive-codec; src/metadata.ts keeps only what is genuinely this package's own: layoutMetadataToSummaryInformation wrapped with createdIso/modifiedIso date validation, reporting a malformed date as a DocFormatError rather than delegating straight through. --- packages/doc-codec/README.md | 50 +++++++++++++------------- packages/doc-codec/src/metadata.ts | 42 ++-------------------- packages/doc-codec/src/read.ts | 7 ++-- packages/doc-codec/src/write.ts | 6 ++-- packages/doc-codec/test/smoke.test.mjs | 4 +-- 5 files changed, 36 insertions(+), 73 deletions(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index cb483f529..3d0553485 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -97,7 +97,7 @@ A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` an ## Metadata -A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writeDocContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. +A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `xls-codec`'s and `ppt-codec`'s identical need for it); `writeDocContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `DocFormatError` naming the offending field before delegating to `archive-codec`'s own mapping — see [Writing](#writing)), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. The mapping is not 1:1, and each gap is permanent rather than a remaining TODO: @@ -145,30 +145,30 @@ 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/metadata.ts` | Maps `archive-codec`'s `SummaryInformationProperties` to and from `document-schema.js`'s `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). | -| `src/table/tap.ts` | Folding a table row's own sgc-5 grpprl into its TAP — column boundaries and every physical cell's merge state from `sprmTDefTable`, folded with a `sprmTMerge` range or `sprmTVertMerge` flag where one is present, regardless of which order they appear in. | -| `src/table/read.ts` | Grouping a contiguous run of table-depth paragraphs (from `read.ts`'s own flat sequence) into a real `ContentTable`, refusing a nested table. | -| `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/table/tap-write.ts` | The inverse of `table/tap.ts`: a row's column boundaries, cell merge state and height to a `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge` grpprl. | -| `src/table/write.ts` | Expanding a `ContentTable` into its own real physical-cell paragraph stream, for `write.ts`'s own paragraph pipeline to lay out like any other paragraph. | -| `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/metadata.ts` | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `DocFormatError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). | +| `src/table/tap.ts` | Folding a table row's own sgc-5 grpprl into its TAP — column boundaries and every physical cell's merge state from `sprmTDefTable`, folded with a `sprmTMerge` range or `sprmTVertMerge` flag where one is present, regardless of which order they appear in. | +| `src/table/read.ts` | Grouping a contiguous run of table-depth paragraphs (from `read.ts`'s own flat sequence) into a real `ContentTable`, refusing a nested table. | +| `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/table/tap-write.ts` | The inverse of `table/tap.ts`: a row's column boundaries, cell merge state and height to a `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge` grpprl. | +| `src/table/write.ts` | Expanding a `ContentTable` into its own real physical-cell paragraph stream, for `write.ts`'s own paragraph pipeline to lay out like any other paragraph. | +| `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 diff --git a/packages/doc-codec/src/metadata.ts b/packages/doc-codec/src/metadata.ts index 2ed1f0478..e69d5b29f 100644 --- a/packages/doc-codec/src/metadata.ts +++ b/packages/doc-codec/src/metadata.ts @@ -1,26 +1,9 @@ import type { SummaryInformationProperties } from "archive-codec"; +import { layoutMetadataToSummaryInformation as mapLayoutMetadataToSummaryInformation } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; import { DocFormatError } from "./errors"; -// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a wordprocessing document. -// -// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: -// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a ContentDocument. -// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). -// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. - -export function summaryInformationToLayoutMetadata( - info: SummaryInformationProperties, -): LayoutMetadata { - return { - title: info.title, - subject: info.subject, - author: info.author, - keywords: info.keywords === undefined ? undefined : [...info.keywords], - createdIso: info.createdIso, - modifiedIso: info.lastSavedIso, - }; -} +// The LayoutMetadata <-> SummaryInformationProperties mapping itself is format-agnostic (document-schema.js's LayoutMetadata is not specific to .doc), so the pure mapping (and hasSummaryInformationFields/summaryInformationToLayoutMetadata, which need no package-local wrapping) lives in archive-codec/oleps/layout-metadata.ts, shared with xls-codec and ppt-codec, rather than being copied here -- read.ts and write.ts import those two directly from "archive-codec". What stays package-local is the one piece that genuinely is doc-codec's own: reporting a malformed createdIso/modifiedIso through this package's own error vocabulary (see requireValidIsoDate below). // writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a DocFormatError naming the actual field instead. function requireValidIsoDate( @@ -39,24 +22,5 @@ export function layoutMetadataToSummaryInformation( ): SummaryInformationProperties { requireValidIsoDate(metadata.createdIso, "createdIso"); requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); - return { - title: metadata.title, - subject: metadata.subject, - author: metadata.author, - keywords: metadata.keywords, - createdIso: metadata.createdIso, - lastSavedIso: metadata.modifiedIso, - }; -} - -/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writeDocContent's own call site). */ -export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { - return ( - metadata.title !== undefined || - metadata.subject !== undefined || - metadata.author !== undefined || - (metadata.keywords !== undefined && metadata.keywords.length > 0) || - metadata.createdIso !== undefined || - metadata.modifiedIso !== undefined - ); + return mapLayoutMetadataToSummaryInformation(metadata); } diff --git a/packages/doc-codec/src/read.ts b/packages/doc-codec/src/read.ts index 2bd7abab2..0502e527d 100644 --- a/packages/doc-codec/src/read.ts +++ b/packages/doc-codec/src/read.ts @@ -1,4 +1,8 @@ -import { readCompoundFile, readSummaryInformation } from "archive-codec"; +import { + readCompoundFile, + readSummaryInformation, + summaryInformationToLayoutMetadata, +} from "archive-codec"; import type { ContentDocument, ContentParagraph, @@ -10,7 +14,6 @@ import { slice } from "./bytes"; import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError } from "./errors"; import { parseFib, tableStreamName, type Fib } from "./fib/fib"; -import { summaryInformationToLayoutMetadata } from "./metadata"; import { applyCharacterSprms, type CharacterProperties } from "./prop/chp"; import { PropertyBinTable } from "./prop/fkp"; import { applyParagraphSprms, type ParagraphProperties } from "./prop/pap"; diff --git a/packages/doc-codec/src/write.ts b/packages/doc-codec/src/write.ts index f4f4dc5f1..9bf43053f 100644 --- a/packages/doc-codec/src/write.ts +++ b/packages/doc-codec/src/write.ts @@ -1,4 +1,5 @@ import { + hasSummaryInformationFields, writeCompoundFile, writeSummaryInformationStream, } from "archive-codec"; @@ -6,10 +7,7 @@ import type { ContentDocument, ContentParagraph } from "document-schema.js"; import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError, DocUnsupportedError } from "./errors"; import { buildFib } from "./fib/write"; -import { - hasSummaryInformationFields, - layoutMetadataToSummaryInformation, -} from "./metadata"; +import { layoutMetadataToSummaryInformation } from "./metadata"; import { encodeCharacterGrpprl } from "./prop/chp-write"; import { FKP_PAGE_SIZE } from "./prop/fkp"; import { diff --git a/packages/doc-codec/test/smoke.test.mjs b/packages/doc-codec/test/smoke.test.mjs index 2a4895439..65568885e 100644 --- a/packages/doc-codec/test/smoke.test.mjs +++ b/packages/doc-codec/test/smoke.test.mjs @@ -28,9 +28,7 @@ const BARREL_FUNCTIONS = [ 'parsePlc', 'findLargestAtMost', 'endsParagraph', - 'summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', - 'hasSummaryInformationFields', ]; const BARREL_CONSTANTS = [ 'FIB_W_IDENT', @@ -77,7 +75,7 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/detect.js', exports: ['isDocBytes', 'WORD_DOCUMENT_STREAM', 'SUMMARY_INFORMATION_STREAM'] }, { path: '../dist/metadata.js', - exports: ['summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', 'hasSummaryInformationFields'], + exports: ['layoutMetadataToSummaryInformation'], }, { path: '../dist/fib/offsets.js', exports: ['FIB_W_IDENT', 'FIB_FC_LCB_BLOB_OFFSET'] }, { path: '../dist/fib/fib.js', exports: ['parseFib', 'tableStreamName'] }, From 7be4094d267732a2cf5cb02d9c537dadc768dc53 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:18:16 +0100 Subject: [PATCH 11/12] refactor(xls-codec): consume archive-codec's shared LayoutMetadata mapping summaryInformationToLayoutMetadata and hasSummaryInformationFields were a byte-identical copy of doc-codec's and ppt-codec's own -- format- agnostic mapping logic that had nothing to do with .xls specifically. Both now come directly from archive-codec; src/metadata.ts keeps only what is genuinely this package's own: layoutMetadataToSummaryInformation wrapped with createdIso/modifiedIso date validation, reporting a malformed date as a BiffWriteError rather than delegating straight through. --- packages/xls-codec/README.md | 4 +-- packages/xls-codec/src/content.ts | 6 ++-- packages/xls-codec/src/metadata.ts | 42 ++------------------------ packages/xls-codec/src/write.ts | 6 ++-- packages/xls-codec/test/smoke.test.mjs | 4 +-- 5 files changed, 12 insertions(+), 50 deletions(-) diff --git a/packages/xls-codec/README.md b/packages/xls-codec/README.md index 5c7a95687..e8999072e 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -50,7 +50,7 @@ This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdf ## Metadata -A `.xls`'s title, author, and dates do not live in any BIFF8 record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Workbook` in the same [MS-CFB] compound file. `readXlsContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writeXlsContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. +A `.xls`'s title, author, and dates do not live in any BIFF8 record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Workbook` in the same [MS-CFB] compound file. `readXlsContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `doc-codec`'s and `ppt-codec`'s identical need for it); `writeXlsContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `BiffWriteError` naming the offending field before delegating to `archive-codec`'s own mapping), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. The mapping is not 1:1, and each gap is permanent rather than a remaining TODO: @@ -118,7 +118,7 @@ Layered bottom-up, each layer testable against hand-built byte sequences taken f - **`src/workbook/globals.ts`**, **`src/workbook/sheet.ts`** — the two substream readers, each walking the record sequence its ABNF in [MS-XLS] 2.1.7.20.3 / 2.1.7.20.5 defines; `globals.ts` also resolves a 3D reference's own `ixti` to a sheet range through `EXTERNSHEET` and a self-referencing `SupBook`, which `sheet.ts` threads into `ptg.ts` for a Formula record's own 3D references. - **[`excel-number-format`](../excel-number-format/README.md)**, **`src/serial.ts`** — number-format classification and date-serial conversion, the two pieces of xlsx semantics BIFF8 shares because ECMA-376 inherited them from BIFF. The classifier itself is a dependency shared with `ooxml.js`, not a module in this package (ExaDev/documents.js#848) — `classifyNumberFormat` and `BUILTIN_NUMBER_FORMATS` still ride this package's own barrel (`export * from "excel-number-format"` in `src/index.ts`), so `import { classifyNumberFormat } from "xls-codec"` is unchanged. - **`src/content.ts`** — the mapping onto `document-schema.js`. -- **`src/metadata.ts`** — maps `archive-codec`'s `SummaryInformationProperties` to and from `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). +- **`src/metadata.ts`** — wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `BiffWriteError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). ### Deliberately not depended on diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 1607bec37..cb082ef9a 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -1,4 +1,7 @@ -import { readSummaryInformation } from "archive-codec"; +import { + readSummaryInformation, + summaryInformationToLayoutMetadata, +} from "archive-codec"; import type { ContentCellValue, ContentDocument, @@ -19,7 +22,6 @@ import { type Substream, } from "./biff/substreams"; import { readWorkbookStreams } from "./container"; -import { summaryInformationToLayoutMetadata } from "./metadata"; import { classifyNumberFormat } from "excel-number-format"; import { serialToIsoDate, diff --git a/packages/xls-codec/src/metadata.ts b/packages/xls-codec/src/metadata.ts index 0d5af03aa..382fb8a5b 100644 --- a/packages/xls-codec/src/metadata.ts +++ b/packages/xls-codec/src/metadata.ts @@ -1,26 +1,9 @@ import type { SummaryInformationProperties } from "archive-codec"; +import { layoutMetadataToSummaryInformation as mapLayoutMetadataToSummaryInformation } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; import { BiffWriteError } from "./biff/write-errors"; -// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a ContentDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a spreadsheet document. -// -// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: -// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a ContentDocument. -// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). -// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. - -export function summaryInformationToLayoutMetadata( - info: SummaryInformationProperties, -): LayoutMetadata { - return { - title: info.title, - subject: info.subject, - author: info.author, - keywords: info.keywords === undefined ? undefined : [...info.keywords], - createdIso: info.createdIso, - modifiedIso: info.lastSavedIso, - }; -} +// The LayoutMetadata <-> SummaryInformationProperties mapping itself is format-agnostic (document-schema.js's LayoutMetadata is not specific to .xls), so the pure mapping (and hasSummaryInformationFields/summaryInformationToLayoutMetadata, which need no package-local wrapping) lives in archive-codec/oleps/layout-metadata.ts, shared with doc-codec and ppt-codec, rather than being copied here -- content.ts and write.ts import those two directly from "archive-codec". What stays package-local is the one piece that genuinely is xls-codec's own: reporting a malformed createdIso/modifiedIso through this package's own error vocabulary (see requireValidIsoDate below). // writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a BiffWriteError naming the actual field instead. function requireValidIsoDate( @@ -39,24 +22,5 @@ export function layoutMetadataToSummaryInformation( ): SummaryInformationProperties { requireValidIsoDate(metadata.createdIso, "createdIso"); requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); - return { - title: metadata.title, - subject: metadata.subject, - author: metadata.author, - keywords: metadata.keywords, - createdIso: metadata.createdIso, - lastSavedIso: metadata.modifiedIso, - }; -} - -/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writeXlsContent's own call site). */ -export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { - return ( - metadata.title !== undefined || - metadata.subject !== undefined || - metadata.author !== undefined || - (metadata.keywords !== undefined && metadata.keywords.length > 0) || - metadata.createdIso !== undefined || - metadata.modifiedIso !== undefined - ); + return mapLayoutMetadataToSummaryInformation(metadata); } diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index 939367262..87e491391 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -1,4 +1,5 @@ import { + hasSummaryInformationFields, writeCompoundFile, writeSummaryInformationStream, } from "archive-codec"; @@ -15,10 +16,7 @@ import { BUILTIN_NUMBER_FORMATS } from "excel-number-format"; import { BiffWriteError } from "./biff/write-errors"; import type { XlsContentDocument } from "./content"; import { SUMMARY_INFORMATION_STREAM } from "./container"; -import { - hasSummaryInformationFields, - layoutMetadataToSummaryInformation, -} from "./metadata"; +import { layoutMetadataToSummaryInformation } from "./metadata"; import { buildWorkbookGlobals, GENERAL_CELL_XF_INDEX, diff --git a/packages/xls-codec/test/smoke.test.mjs b/packages/xls-codec/test/smoke.test.mjs index d5cacf213..f80af725d 100644 --- a/packages/xls-codec/test/smoke.test.mjs +++ b/packages/xls-codec/test/smoke.test.mjs @@ -26,9 +26,7 @@ const BARREL_FUNCTIONS = [ 'twipsToPoints', 'columnWidthToPoints', 'readWorkbookStreams', - 'summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', - 'hasSummaryInformationFields', 'isXlsFile', 'readXlsContent', 'readXls', @@ -85,7 +83,7 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/content.js', exports: ['readXlsContent', 'readXls'] }, { path: '../dist/metadata.js', - exports: ['summaryInformationToLayoutMetadata', 'layoutMetadataToSummaryInformation', 'hasSummaryInformationFields'], + exports: ['layoutMetadataToSummaryInformation'], }, ]; From 544472683e535820ca3cb1c08acca29d28962cad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 19:18:23 +0100 Subject: [PATCH 12/12] refactor(ppt-codec): consume archive-codec's shared LayoutMetadata mapping summaryInformationToLayoutMetadata and hasSummaryInformationFields were a byte-identical copy of doc-codec's and xls-codec's own -- format- agnostic mapping logic that had nothing to do with .ppt specifically. Both now come directly from archive-codec; src/metadata.ts keeps only what is genuinely this package's own: layoutMetadataToSummaryInformation wrapped with createdIso/modifiedIso date validation, reporting a malformed date as a PptUnsupportedContentError rather than delegating straight through. --- packages/doc-codec/src/write.ts | 2 +- packages/ppt-codec/README.md | 58 +++++++++++++------------- packages/ppt-codec/src/metadata.ts | 42 ++----------------- packages/ppt-codec/src/read.ts | 7 +++- packages/ppt-codec/src/write.ts | 6 +-- packages/ppt-codec/test/smoke.test.mjs | 4 +- packages/xls-codec/README.md | 3 +- 7 files changed, 43 insertions(+), 79 deletions(-) diff --git a/packages/doc-codec/src/write.ts b/packages/doc-codec/src/write.ts index 9bf43053f..a8e07c497 100644 --- a/packages/doc-codec/src/write.ts +++ b/packages/doc-codec/src/write.ts @@ -3,7 +3,7 @@ import { writeCompoundFile, writeSummaryInformationStream, } from "archive-codec"; -import type { ContentDocument, ContentParagraph } from "document-schema.js"; +import type { ContentDocument } from "document-schema.js"; import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError, DocUnsupportedError } from "./errors"; import { buildFib } from "./fib/write"; diff --git a/packages/ppt-codec/README.md b/packages/ppt-codec/README.md index 451623209..dd186a4e1 100644 --- a/packages/ppt-codec/README.md +++ b/packages/ppt-codec/README.md @@ -147,7 +147,7 @@ Each of these is either a real construct this writer deliberately does not attem ## Metadata -A `.ppt`'s title, author, and dates do not live in any [MS-PPT] record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Current User`/`PowerPoint Document` in the same [MS-CFB] compound file. `readPptContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`src/metadata.ts`'s `summaryInformationToLayoutMetadata`); `writePptContent` does the inverse (`layoutMetadataToSummaryInformation`), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. +A `.ppt`'s title, author, and dates do not live in any [MS-PPT] record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Current User`/`PowerPoint Document` in the same [MS-CFB] compound file. `readPptContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `doc-codec`'s and `xls-codec`'s identical need for it); `writePptContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `PptUnsupportedContentError` naming the offending field before delegating to `archive-codec`'s own mapping), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. `readPptStreams`/`writePptStreams`, the record-level split one layer below, do not touch this at all: they take or return only the two required [MS-PPT] streams, with no compound file to look a third stream up in. `readPptContent`/`writePptContent` are where the container-level fact lives. @@ -169,34 +169,34 @@ import { readRecordAt } from "ppt-codec/record/tree"; import { readStyleTextPropAtom } from "ppt-codec/text/style"; ``` -| Module | What it owns | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `record/header` | The generic 8-byte record header and the container/atom distinction. | -| `record/types` | The `RecordType` values this reader dispatches on, plus the [MS-ODRAW] types the drawing walk crosses into. | -| `record/tree` | Offset-addressed records, sibling sequences, child walks, typed-descendant search. | -| `record/write` | Byte primitives and the atom/container builders every writer module below composes records from -- the write-side mirror of `record/header`/`record/tree`, and what this package's own test fixtures build on too. | -| `stream/current-user` | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted. | -| `stream/current-user-write` | Writes a real `CurrentUserAtom` pointing at the single edit this writer always produces. | -| `stream/persist` | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds. | -| `stream/persist-write` | Writes a single-edit `UserEditAtom`/`PersistDirectoryAtom` pair covering the document container and every slide container. | -| `document/document-atom` | `DocumentAtom`: slide and notes sizes, master persist references. | -| `document/document-atom-write` | Writes a `DocumentAtom` for the one slide size every slide must share. | -| `document/fonts` | The font collection, resolved to typeface names a `FontIndexRef` indexes. | -| `document/fonts-write` | Writes an `Environment`/`FontCollectionContainer` from a document's own distinct font families. | -| `document/slide-list` | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts. | -| `document/slide-list-write` | Writes a `SlideListWithTextContainer` naming each slide's persist reference, with no placeholder texts. | -| `drawing/shapes` | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups. | -| `drawing/shapes-write` | Writes the patriarch group and one plain, anchored `OfficeArtSpContainer` per shape. | -| `text/atoms` | The two text-body spellings, the text-type enumeration, and the paragraph split. | -| `text/style` | `StyleTextPropAtom`'s two run arrays and their mask-driven exception structures. | -| `text/style-write` | Writes a `StyleTextPropAtom` from the same `StyleRun`/`ParagraphProperties`/`CharacterProperties` shapes `text/style` reads into. | -| `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. | -| `content-write` | The inverse: a shape's `ContentBlock[]` to the flat character-counted text body and `StyleTextProps` `text/style-write` needs. | -| `metadata` | Maps `archive-codec`'s `SummaryInformationProperties` to and from `LayoutMetadata` — read and write together, since both directions share one field mapping (see [Metadata](#metadata)). | -| `read` | The whole read pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. | -| `write` | The whole write pipeline, and the `writePpt`/`writePptContent`/`writePptStreams` surface. | -| `units` | Master units to points, and points to master units. | -| `errors` | `PptFormatError` for malformed input, `PptEncryptedError` for well-formed input this package cannot decrypt, `PptUnsupportedContentError` for well-formed content this package's writer cannot express. | +| Module | What it owns | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `record/header` | The generic 8-byte record header and the container/atom distinction. | +| `record/types` | The `RecordType` values this reader dispatches on, plus the [MS-ODRAW] types the drawing walk crosses into. | +| `record/tree` | Offset-addressed records, sibling sequences, child walks, typed-descendant search. | +| `record/write` | Byte primitives and the atom/container builders every writer module below composes records from -- the write-side mirror of `record/header`/`record/tree`, and what this package's own test fixtures build on too. | +| `stream/current-user` | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted. | +| `stream/current-user-write` | Writes a real `CurrentUserAtom` pointing at the single edit this writer always produces. | +| `stream/persist` | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds. | +| `stream/persist-write` | Writes a single-edit `UserEditAtom`/`PersistDirectoryAtom` pair covering the document container and every slide container. | +| `document/document-atom` | `DocumentAtom`: slide and notes sizes, master persist references. | +| `document/document-atom-write` | Writes a `DocumentAtom` for the one slide size every slide must share. | +| `document/fonts` | The font collection, resolved to typeface names a `FontIndexRef` indexes. | +| `document/fonts-write` | Writes an `Environment`/`FontCollectionContainer` from a document's own distinct font families. | +| `document/slide-list` | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts. | +| `document/slide-list-write` | Writes a `SlideListWithTextContainer` naming each slide's persist reference, with no placeholder texts. | +| `drawing/shapes` | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups. | +| `drawing/shapes-write` | Writes the patriarch group and one plain, anchored `OfficeArtSpContainer` per shape. | +| `text/atoms` | The two text-body spellings, the text-type enumeration, and the paragraph split. | +| `text/style` | `StyleTextPropAtom`'s two run arrays and their mask-driven exception structures. | +| `text/style-write` | Writes a `StyleTextPropAtom` from the same `StyleRun`/`ParagraphProperties`/`CharacterProperties` shapes `text/style` reads into. | +| `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. | +| `content-write` | The inverse: a shape's `ContentBlock[]` to the flat character-counted text body and `StyleTextProps` `text/style-write` needs. | +| `metadata` | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `PptUnsupportedContentError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). | +| `read` | The whole read pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. | +| `write` | The whole write pipeline, and the `writePpt`/`writePptContent`/`writePptStreams` surface. | +| `units` | Master units to points, and points to master units. | +| `errors` | `PptFormatError` for malformed input, `PptEncryptedError` for well-formed input this package cannot decrypt, `PptUnsupportedContentError` for well-formed content this package's writer cannot express. | ### Every fixture is built from the specification, not captured diff --git a/packages/ppt-codec/src/metadata.ts b/packages/ppt-codec/src/metadata.ts index 337da569d..cf3e61b2c 100644 --- a/packages/ppt-codec/src/metadata.ts +++ b/packages/ppt-codec/src/metadata.ts @@ -1,26 +1,9 @@ import type { SummaryInformationProperties } from "archive-codec"; +import { layoutMetadataToSummaryInformation as mapLayoutMetadataToSummaryInformation } from "archive-codec"; import type { LayoutMetadata } from "document-schema.js"; import { PptUnsupportedContentError } from "./errors"; -// Maps between the seven fields archive-codec's oleps/summary-information module reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape a PptDocument's `metadata` field always is. This is the only document-format-specific knowledge in the metadata path: archive-codec knows PID 2 means a title, this module knows LayoutMetadata's `title` is where that belongs for a presentation. -// -// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO: -// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no other codec has a "last printed" or free-text "comments" concept, so these are read from the stream by archive-codec but simply never reach a PptDocument. -// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers (see the package README's metadata scope note). -// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies. - -export function summaryInformationToLayoutMetadata( - info: SummaryInformationProperties, -): LayoutMetadata { - return { - title: info.title, - subject: info.subject, - author: info.author, - keywords: info.keywords === undefined ? undefined : [...info.keywords], - createdIso: info.createdIso, - modifiedIso: info.lastSavedIso, - }; -} +// The LayoutMetadata <-> SummaryInformationProperties mapping itself is format-agnostic (document-schema.js's LayoutMetadata is not specific to .ppt), so the pure mapping (and hasSummaryInformationFields/summaryInformationToLayoutMetadata, which need no package-local wrapping) lives in archive-codec/oleps/layout-metadata.ts, shared with doc-codec and xls-codec, rather than being copied here -- read.ts and write.ts import those two directly from "archive-codec". What stays package-local is the one piece that genuinely is ppt-codec's own: reporting a malformed createdIso/modifiedIso through this package's own error vocabulary (see requireValidIsoDate below). // writeSummaryInformationStream converts createdIso/lastSavedIso straight into a FILETIME via `new Date(iso)`; a malformed string produces an Invalid Date, whose getTime() is NaN, and archive-codec's own BigInt(NaN) conversion throws an opaque RangeError with no indication which field or package caused it. Validated here, at the boundary into archive-codec's own shape, so a caller sees a PptUnsupportedContentError naming the actual field instead -- the same write-side error class every other content-outside-this-writer's-scope case throws (see errors.ts). function requireValidIsoDate( @@ -39,24 +22,5 @@ export function layoutMetadataToSummaryInformation( ): SummaryInformationProperties { requireValidIsoDate(metadata.createdIso, "createdIso"); requireValidIsoDate(metadata.modifiedIso, "modifiedIso"); - return { - title: metadata.title, - subject: metadata.subject, - author: metadata.author, - keywords: metadata.keywords, - createdIso: metadata.createdIso, - lastSavedIso: metadata.modifiedIso, - }; -} - -/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see writePptContent's own call site). */ -export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean { - return ( - metadata.title !== undefined || - metadata.subject !== undefined || - metadata.author !== undefined || - (metadata.keywords !== undefined && metadata.keywords.length > 0) || - metadata.createdIso !== undefined || - metadata.modifiedIso !== undefined - ); + return mapLayoutMetadataToSummaryInformation(metadata); } diff --git a/packages/ppt-codec/src/read.ts b/packages/ppt-codec/src/read.ts index 71e5e14f8..62f3f8665 100644 --- a/packages/ppt-codec/src/read.ts +++ b/packages/ppt-codec/src/read.ts @@ -1,4 +1,8 @@ -import { readCompoundFile, readSummaryInformation } from "archive-codec"; +import { + readCompoundFile, + readSummaryInformation, + summaryInformationToLayoutMetadata, +} from "archive-codec"; import { type ContentBlock, type ContentDocument, @@ -10,7 +14,6 @@ import { assembleTree, } from "document-schema.js"; import { buildParagraphs } from "./content"; -import { summaryInformationToLayoutMetadata } from "./metadata"; import { readDocumentAtom } from "./document/document-atom"; import { readFontNames } from "./document/fonts"; import { diff --git a/packages/ppt-codec/src/write.ts b/packages/ppt-codec/src/write.ts index 9e13be796..c71ffab33 100644 --- a/packages/ppt-codec/src/write.ts +++ b/packages/ppt-codec/src/write.ts @@ -1,4 +1,5 @@ import { + hasSummaryInformationFields, writeCompoundFile, writeSummaryInformationStream, } from "archive-codec"; @@ -17,10 +18,7 @@ import { } from "./document/slide-list-write"; import { writeSlideDrawing } from "./drawing/shapes-write"; import { PptUnsupportedContentError } from "./errors"; -import { - hasSummaryInformationFields, - layoutMetadataToSummaryInformation, -} from "./metadata"; +import { layoutMetadataToSummaryInformation } from "./metadata"; import { CURRENT_USER_STREAM, POWERPOINT_DOCUMENT_STREAM, diff --git a/packages/ppt-codec/test/smoke.test.mjs b/packages/ppt-codec/test/smoke.test.mjs index 1f7802f94..9241fa642 100644 --- a/packages/ppt-codec/test/smoke.test.mjs +++ b/packages/ppt-codec/test/smoke.test.mjs @@ -19,9 +19,7 @@ const BARREL_FUNCTIONS = [ "readTextBody", "buildParagraphs", "masterUnitsToPoints", - "summaryInformationToLayoutMetadata", "layoutMetadataToSummaryInformation", - "hasSummaryInformationFields", ]; const BARREL_CONSTANTS = [ "RECORD_HEADER_SIZE", @@ -62,7 +60,7 @@ describe("dist/ deep imports resolve for every advertised module, in both builds { path: "../dist/read.js", exports: ["readPpt", "readPptContent"] }, { path: "../dist/metadata.js", - exports: ["summaryInformationToLayoutMetadata", "layoutMetadataToSummaryInformation", "hasSummaryInformationFields"], + exports: ["layoutMetadataToSummaryInformation"], }, { path: "../dist/content.js", exports: ["buildParagraphs"] }, { path: "../dist/units.js", exports: ["masterUnitsToPoints"] }, diff --git a/packages/xls-codec/README.md b/packages/xls-codec/README.md index e8999072e..77ca5b756 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -31,9 +31,10 @@ What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `. | Cell decoration (fill, borders, alignment, per-cell font) | The reader does not read a `CellXF`'s decoration payload back (see below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits carries the same undecorated defaults (general alignment, bottom vertical alignment, no border, no fill) a genuinely undecorated Excel-written cell also carries. | | `Blank`/`MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Number`/`LabelSst`/`BoolErr` record already carries losslessly. An `empty`-kind cell is never written at all — `content.ts`'s own reader drops every blank cell it reads regardless, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone, so writing nothing for one is what round-trips correctly rather than a gap. | | Images, embedded objects, comments (`Note`/`Txo`), data validation, conditional formatting, defined names (`Lbl`) | Not read either (see below); there is no round trip to verify a writer for them against. | -| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) | Same reason — the reader always returns its own fixed "Normal" preset regardless of what a file states, so writing the real values would be unverifiable. Workbook metadata (`\x05SummaryInformation`) is a separate story: see [Metadata](#metadata). | +| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) | Same reason — the reader always returns its own fixed "Normal" preset regardless of what a file states, so writing the real values would be unverifiable. Workbook metadata (`\x05SummaryInformation`) is a separate story: see [Metadata](#metadata). | | `RECALC`/calc-state records (`CalcMode`, `CalcCount`, …), `Window1`/`Window2`, `CodePage`, `Index`/`DBCell`, the legacy interface records (`InterfaceHdr`, `WriteAccess`, …) | UI and interoperability bookkeeping [MS-XLS]'s own grammar names in the globals/worksheet substreams alongside the content-carrying records above, not data. `Index`/`DBCell` specifically is a pure cell-lookup performance optimisation (see [MS-XLS]'s own "Retrieval of Last-Calculated Cell Values Without Loading Cell Table") that this reader — and Excel's own reader — does not require to find a cell; real, well-established minimal BIFF8 writers (e.g. Python's `xlwt`) omit the same set and produce files Excel opens correctly. | | `Continue`-chain splitting | A record whose data would exceed the 8224-byte single-record ceiling ([MS-XLS] 2.1.4) — an extremely long shared string, an enormous shared string table, or thousands of merged ranges in one sheet — is refused with a thrown `BiffWriteError` rather than silently split across `Continue` records. | + Column widths round-trip to the nearest pixel Excel's own integer-pixel-grid quantization allows (matching the read direction's own "honestly approximate" contract, `units.ts`), never narrower than requested. A `.xls` cell outside BIFF8's own grid (65536 rows, 256 columns) is refused rather than silently wrapped or truncated. ### Read-side gaps