From 8e403600bb6fa89918fb00dd6d52b20a401cf3e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 04:33:47 +0100 Subject: [PATCH] feat(wpd-codec): lift image boxes carrying PNG or JPEG payloads An image box's content prefix names a packet whose container spelling this reader has no specification for, so the lift is magic-driven: the packet's raw bytes are scanned by signature and structural walk (the PNG chunk chain to IEND, the JPEG marker segments to EOI), never by guessing at a container header, and exactly that span lifts as a ContentImageBlock sized by the box's own frame -- with an absolute-from-page-edge position carried as the image's floatPosition. A WPG vector graphic or OLE payload stays honestly reported through wpd/box-content-unresolved rather than approximated. The base64 encoder is hand-written (no Buffer, no atob) keeping the package Worker-isomorphic. --- packages/wpd-codec/README.md | 2 +- packages/wpd-codec/src/bytes/base64.ts | 26 ++++++ packages/wpd-codec/src/read.test.ts | 104 ++++++++++++++++++++- packages/wpd-codec/src/read.ts | 56 +++++++++++ packages/wpd-codec/src/stream/image.ts | 123 +++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 packages/wpd-codec/src/bytes/base64.ts create mode 100644 packages/wpd-codec/src/stream/image.ts diff --git a/packages/wpd-codec/README.md b/packages/wpd-codec/README.md index 18038618e..e8fda0dd0 100644 --- a/packages/wpd-codec/README.md +++ b/packages/wpd-codec/README.md @@ -175,7 +175,7 @@ This is a bet against libwpd's _code_, not against citing its _data_: the charac Everything below is recognised by the tokeniser and skipped by the fold, so a document containing it still reads — losing that construct's own structure, never the surrounding text. Each is reported through the diagnostic sink rather than passed over in silence. - **Headers, footers, footnotes, and endnotes** (the 0xD6 and 0xD7 groups). Reported through `wpd/header-footer-dropped` and `wpd/note-dropped`. The text is genuinely recoverable — each function names a General WP Text packet (type 0x08) holding its own function-code stream, which this package's tokeniser and fold would read (the same packet type boxes now resolve their own text content through) — but the flat `ContentDocument` has no page-furniture position for a header or footer and no note position for a footnote body. That body's real home is `document-schema.js`'s tree-only `definitions` table, which a codec producing the flat form cannot reach; it is the same gap `rtf-codec` documents for its own equivalent constructs, and it closes at the schema boundary rather than here — the one gap in this list that stays blocked pending a DocumentTree-producing wpd-codec, distinct from every other entry, which is a parsing-effort gap rather than a schema-shape one. -- **A box whose content is an image, presentation, video, macro, sound, or external payload**, and a box relying on its own template's inherited geometry rather than an explicit function-level position/size override. Reported through `wpd/box-content-unresolved` and `wpd/box-frame-unresolved` respectively. An image box's own content resolves to a Graphics Filename prefix packet (type 0x40) whose children carry either WPG vector graphics (a distinct binary graphics format `ContentImageBlock`'s `png`/`jpeg`/`svg`/`gif` set cannot hold as recovered, and which this package does not decode — a project on the scale of this package's own WordPerfect reader, not a wiring job) or a native OLE object (see the next bullet). A box with no function-level content override at all — relying entirely on its template's own rendering defaults — is reported through `wpd/box-dropped`, unchanged from before. +- **A box whose content is a presentation, video, macro, sound, or external payload**, and a box relying on its own template's inherited geometry rather than an explicit function-level position/size override. Reported through `wpd/box-content-unresolved` and `wpd/box-frame-unresolved` respectively. An image box IS lifted when its content packet carries a whole PNG or JPEG payload: the packet's raw bytes are scanned by signature and structural walk (`src/stream/image.ts` — the chunk chain to IEND for PNG, the marker segments to EOI for JPEG), never by guessing at a container header, and the span lifts as a `ContentImageBlock` sized by the box's own frame, with an absolute-from-page-edge position carried as the image's `floatPosition`. What stays unresolved: a WPG vector graphic (a distinct binary graphics format `ContentImageBlock`'s `png`/`jpeg`/`svg`/`gif` set cannot hold as recovered, and which this package does not decode — a project on the scale of this package's own WordPerfect reader, not a wiring job) and a native OLE object (see the next bullet). A box with no function-level content override at all — relying entirely on its template's own rendering defaults — is reported through `wpd/box-dropped`, unchanged from before. - **Embedded OLE objects**, stored under the compound file's `PerfectOffice_OBJECTS` storage and named by an image box's Graphics Filename packet's own `0x70`/`0x71` (OLE Object Descriptor / OLE Object Data) children. `archive-codec`'s compound-file reader already reaches that storage, which is how `ooxml.js` recovers a ZIP-payload embedded object — but a WordPerfect OLE object's payload is a native OLE server's own stream rather than a nested document package (`ooxml.js`'s own equivalent case, a classic OLE1 `.bin` payload with no `Package` stream, stays opaque by the identical scope boundary), so recovering one generically is a project in its own right, not a wiring job. - **The counter groups** (0xD8, 0xD9, 0xDB, 0xDC): setting, numbering-method, increment and decrement carry no text and change no structure this reader models, so only the Display Number group's own paragraph-number pair is read. - **Every merge subfunction other than FIELD** (ASSIGN, CALL, IF, FOR, CASE, and the rest of WordPerfect's own merge scripting language) and **cross-references** (0xD5). A cross-reference's displayed text survives as ordinary text; its target binding does not. Reported through `wpd/merge-code-dropped` and `wpd/cross-reference-flattened`. Unlike FIELD, these can legitimately wrap whole paragraphs of body text as control flow, which the run-scoped field construct's own one-paragraph extent cannot express regardless — a schema gap for a scripting language's control flow, not a parsing gap. diff --git a/packages/wpd-codec/src/bytes/base64.ts b/packages/wpd-codec/src/bytes/base64.ts new file mode 100644 index 000000000..53fbcd327 --- /dev/null +++ b/packages/wpd-codec/src/bytes/base64.ts @@ -0,0 +1,26 @@ +// Standard base64 (RFC 4648, with padding), hand-written because this package carries no dependency that offers it and its published src/ must stay Worker-isomorphic -- no Buffer polyfill, no atob round trip through binary strings. Consumed by the image-box lift (read.ts), which hands an embedded PNG/JPEG payload to the shared schema's ContentImageBlock.base64 field. + +import { byteAt } from "./view"; + +const ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +export function bytesToBase64(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = byteAt(bytes, i); + const b1 = bytes[i + 1]; + const b2 = bytes[i + 2]; + out += ALPHABET.charAt(b0 >>> 2); + out += ALPHABET.charAt(((b0 & 0x03) << 4) | ((b1 ?? 0) >>> 4)); + if (b1 === undefined) { + return out + "=="; + } + out += ALPHABET.charAt(((b1 & 0x0f) << 2) | ((b2 ?? 0) >>> 6)); + if (b2 === undefined) { + return out + "="; + } + out += ALPHABET.charAt(b2 & 0x3f); + } + return out; +} diff --git a/packages/wpd-codec/src/read.test.ts b/packages/wpd-codec/src/read.test.ts index d960c46fd..d773947cc 100644 --- a/packages/wpd-codec/src/read.test.ts +++ b/packages/wpd-codec/src/read.test.ts @@ -1,4 +1,9 @@ -import type { ContentDocument, ContentParagraph } from "document-schema.js"; +import type { + ContentBlock, + ContentDocument, + ContentParagraph, +} from "document-schema.js"; +import { bytesToBase64 } from "./bytes/base64"; import { describe, expect, it } from "vitest"; import { WpdDiagnosticCodes, type WpdDiagnostic } from "./diagnostics"; import { readWpd, readWpdContent } from "./read"; @@ -650,6 +655,103 @@ describe("boxes", () => { ).toHaveLength(1); }); + // A minimal well-formed 1x1 white PNG: signature, IHDR, IDAT, IEND -- hand-built here as bytes so the fixture needs no encoder dependency, and structurally complete so stream/image.ts's chunk walk bounds it exactly. + function tinyPng(): Uint8Array { + const chunk = (type: string, data: readonly number[]): number[] => [ + 0, + 0, + 0, + data.length, + ...Array.from(type, (c) => c.charCodeAt(0)), + ...data, + 0, + 0, + 0, + 0, // crc not verified by the scanner + ]; + return new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ...chunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]), + ...chunk("IDAT", [0x78, 0x01]), + ...chunk("IEND", []), + ]); + } + + it("lifts an image box carrying a PNG payload as a real image block", () => { + const png = tinyPng(); + const document = readDocumentArea( + [...boxFunction(BOX_CONTENT_TYPE_IMAGE, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + // An "Image: WP"-shaped packet: a small unknown header ahead of the payload, so the test proves the magic scan rather than assuming the payload sits at offset 0. + { + packetType: 0x42, + bytes: new Uint8Array([9, 9, 9, 9, ...png, 7, 7]), + }, + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const block = document.sections[0]?.blocks.find( + (b): b is Extract => b.kind === "image", + ); + if (block === undefined) throw new Error("expected an image block"); + expect(block.format).toBe("png"); + expect(block.base64).toBe(bytesToBase64(png)); + expect(block.widthPt).toBeCloseTo(86.4); + expect(block.heightPt).toBeCloseTo(43.2); + expect(block.floatPosition).toBeUndefined(); + }); + + it("carries an image box's absolute page position as the image's floatPosition", () => { + const png = tinyPng(); + // Position override with all four members: horizontal and vertical absolute-from-page-edge offsets (type 0 flags) plus width and height. + const flags: number[] = [0, 0]; + putUint16(flags, 0, 0x3c00); // bits 13 (h), 12 (v), 11 (width), 10 (height) + const horizontal = [0x00, 0x10, 0x01, 0, 0]; // type 0 = absolute from page edge, offset 0x0110 WPU = 10.56pt + const vertical = [0x00, 0x20, 0x02]; // type 0, offset 0x0220 WPU = 21.12pt + const width = [0, 0, 0]; + putUint16(width, 1, 1440); + const height = [0, 0, 0]; + putUint16(height, 1, 720); + const positionedBox = variableFunction({ + group: BOX_GROUP, + subgroup: PAGE_ANCHORED_BOX, + prefixIds: [1, 2], + nonDeletable: boxNonDeletable( + 0x6000, + new Map([ + [14, [...flags, ...horizontal, ...vertical, ...width, ...height]], + [13, contentBlock(BOX_CONTENT_TYPE_IMAGE)], + ]), + ), + }); + const document = readDocumentArea( + [...positionedBox], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + { packetType: 0x42, bytes: png }, + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const block = document.sections[0]?.blocks.find( + (b): b is Extract => b.kind === "image", + ); + if (block === undefined) throw new Error("expected an image block"); + expect(block.floatPosition).toEqual({ + horizontal: { relativeTo: "page", offsetPt: 0x0110 * 0.06 }, + vertical: { relativeTo: "page", offsetPt: 0x0220 * 0.06 }, + }); + }); + it("reports a box with no content override through the diagnostic sink", () => { const diagnostics: WpdDiagnostic[] = []; const bytes = buildWpdFile([ diff --git a/packages/wpd-codec/src/read.ts b/packages/wpd-codec/src/read.ts index 5ad7021ca..0739bec5c 100644 --- a/packages/wpd-codec/src/read.ts +++ b/packages/wpd-codec/src/read.ts @@ -14,6 +14,7 @@ import type { RunConstructExtent, } from "document-schema.js"; import { assembleTree } from "document-schema.js"; +import { bytesToBase64 } from "./bytes/base64"; import { uint16At } from "./bytes/view"; import { openWpdDocument, @@ -101,11 +102,13 @@ import { } from "./stream/table"; import { BOX_CONTENT_TYPE_EQUATION, + BOX_CONTENT_TYPE_IMAGE, BOX_CONTENT_TYPE_LINKED_TEXT, BOX_CONTENT_TYPE_TEXT, readBoxContent, } from "./stream/box"; import { readTableFormula } from "./stream/formula"; +import { scanImagePayload } from "./stream/image"; import { tabEffectFor, TAB_GROUP } from "./stream/tab"; import { tokeniseDocumentArea, type WpdToken } from "./stream/tokenise"; @@ -1007,6 +1010,59 @@ function applyBoxGroup( return; } + // IMAGE content: the content prefix names a packet whose container spelling this reader has no specification for, so the lift is magic-driven -- scan the packet's raw bytes for a whole, structurally delimited PNG or JPEG payload (stream/image.ts) and carry exactly that span as a ContentImageBlock, never a guess at a container header. The box's own frame supplies the rendered size, and its absolute-from-page-edge position (the one case stream/box.ts can resolve) becomes the image's floatPosition -- the same anchored-position field a docx floating image carries. + if (boxContent.contentType === BOX_CONTENT_TYPE_IMAGE) { + const imagePacket = packetByPrefixId( + container.packets, + boxContent.contentPrefixId, + ); + const payload = + imagePacket === undefined + ? undefined + : scanImagePayload(imagePacket.bytes); + if (payload === undefined) { + reportOnce( + state, + sink, + WpdDiagnosticCodes.BoxContentUnresolved, + "This document contains an image box whose content packet carries no decodable PNG or JPEG payload -- a WPG graphic or other image spelling this reader does not decode.", + ); + return; + } + if (boxContent.frame === undefined) { + reportOnce( + state, + sink, + WpdDiagnosticCodes.BoxFrameUnresolved, + "This document contains a box whose content this reader could read, but whose function-level override states no width and height this reader can trust, so its content was not lifted.", + ); + return; + } + flushParagraphIfContent(state, sink); + targetBlocks(state).push({ + kind: "image", + format: payload.format, + base64: bytesToBase64(payload.bytes), + widthPt: boxContent.frame.widthPt, + heightPt: boxContent.frame.heightPt, + ...(boxContent.frame.positionResolved + ? { + floatPosition: { + horizontal: { + relativeTo: "page", + offsetPt: boxContent.frame.xPt, + }, + vertical: { + relativeTo: "page", + offsetPt: boxContent.frame.yPt, + }, + }, + } + : {}), + }); + return; + } + const isTextLike = boxContent.contentType === BOX_CONTENT_TYPE_TEXT || boxContent.contentType === BOX_CONTENT_TYPE_LINKED_TEXT || diff --git a/packages/wpd-codec/src/stream/image.ts b/packages/wpd-codec/src/stream/image.ts new file mode 100644 index 000000000..3411808a2 --- /dev/null +++ b/packages/wpd-codec/src/stream/image.ts @@ -0,0 +1,123 @@ +// -- Embedded image payloads inside a box's content prefix packet -- +// +// A box whose function-level override names IMAGE content (stream/box.ts's type 3) points at a prefix packet whose layout this reader has no specification for -- WordPerfect carried several image container spellings across its versions (raw WPG2 bitmaps, "Image: WP" packets, later straight PNG/JPEG embeds). Rather than guess at any container header, this module scans the packet's raw bytes for a whole, well-formed PNG or JPEG payload by signature and structure -- the same magic-driven discipline pdf-codec's byte schemas apply -- and lifts exactly the byte span the structure itself delimits. A packet carrying no such payload is honestly reported as unresolved by the caller rather than approximated. + +import { byteAt } from "../bytes/view"; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const; +const JPEG_SOI = [0xff, 0xd8] as const; + +export interface WpdImagePayload { + readonly format: "png" | "jpeg"; + readonly bytes: Uint8Array; +} + +// The first byte offset at which `needle` occurs in `bytes` at or after `from`, or undefined. A plain scan: packet payloads are small (an embedded figure), and no container this module knows of would justify a fancier search. +function indexOf( + bytes: Uint8Array, + needle: readonly number[], + from: number, +): number | undefined { + outer: for (let i = from; i + needle.length <= bytes.length; i += 1) { + for (let j = 0; j < needle.length; j += 1) { + if (bytes[i + j] !== needle[j]) { + continue outer; + } + } + return i; + } + return undefined; +} + +function scanPng( + bytes: Uint8Array, + signatureAt: number, +): WpdImagePayload | undefined { + // Walk the chunk chain from the signature: each chunk is [length (big-endian u32)][type][data][crc], and the image ends after IEND's own crc. A length that runs past the buffer is a truncated or malformed embed -- undefined, not a best-effort span. + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let cursor = signatureAt + PNG_SIGNATURE.length; + for (;;) { + if (cursor + 8 > bytes.length) { + return undefined; + } + const length = view.getUint32(cursor); + const type = String.fromCharCode( + byteAt(bytes, cursor + 4), + byteAt(bytes, cursor + 5), + byteAt(bytes, cursor + 6), + byteAt(bytes, cursor + 7), + ); + cursor += 8 + length + 4; + if (cursor > bytes.length) { + return undefined; + } + if (type === "IEND") { + return { format: "png", bytes: bytes.subarray(signatureAt, cursor) }; + } + } +} + +function scanJpeg( + bytes: Uint8Array, + soiAt: number, +): WpdImagePayload | undefined { + // Walk the marker segments from SOI: each non-standalone marker carries its own big-endian length; SOS opens entropy-coded data that only ends at EOI (FF D9). RSTn and TEM are standalone; a fill FF before a marker is legal and skipped. + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let cursor = soiAt + JPEG_SOI.length; + for (;;) { + if (cursor >= bytes.length) { + return undefined; + } + if (byteAt(bytes, cursor) !== 0xff) { + return undefined; + } + while (cursor < bytes.length && byteAt(bytes, cursor) === 0xff) { + cursor += 1; + } + if (cursor >= bytes.length) { + return undefined; + } + const marker = byteAt(bytes, cursor); + cursor += 1; + const standalone = + marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7); + if (standalone) { + continue; + } + if (marker === 0xd9) { + return { format: "jpeg", bytes: bytes.subarray(soiAt, cursor) }; + } + if (cursor + 2 > bytes.length) { + return undefined; + } + const length = view.getUint16(cursor); + if (length < 2 || cursor + length > bytes.length) { + return undefined; + } + cursor += length; + if (marker === 0xda) { + // Entropy-coded data: scan byte-wise for the EOI marker (a preceding 0xff run is the marker prefix). A stuffed FF inside the entropy stream is always followed by a non-zero byte, so FF D9 can only be EOI. + const eoi = indexOf(bytes, [0xff, 0xd9], cursor); + if (eoi === undefined) { + return undefined; + } + return { format: "jpeg", bytes: bytes.subarray(soiAt, eoi + 2) }; + } + } +} + +// Scans a packet's bytes for the first whole PNG or JPEG payload. The two signatures cannot be confused (PNG's opens 0x89..., JPEG's 0xFF D8), and the structural walk -- not the signature alone -- decides where the payload ends, so trailing container bytes after the image never leak into the lift. +export function scanImagePayload( + bytes: Uint8Array, +): WpdImagePayload | undefined { + const pngAt = indexOf(bytes, PNG_SIGNATURE, 0); + const jpegAt = indexOf(bytes, JPEG_SOI, 0); + // Whichever signature appears first wins; if only one exists, that one. Both absent is the common case (a WPG or OLE payload) and answers undefined. + if (pngAt !== undefined && (jpegAt === undefined || pngAt < jpegAt)) { + return scanPng(bytes, pngAt); + } + if (jpegAt !== undefined) { + return scanJpeg(bytes, jpegAt); + } + return undefined; +}