Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/wpd-codec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions packages/wpd-codec/src/bytes/base64.ts
Original file line number Diff line number Diff line change
@@ -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;
}
104 changes: 103 additions & 1 deletion packages/wpd-codec/src/read.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ContentBlock, { kind: "image" }> => 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<ContentBlock, { kind: "image" }> => 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([
Expand Down
56 changes: 56 additions & 0 deletions packages/wpd-codec/src/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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 ||
Expand Down
123 changes: 123 additions & 0 deletions packages/wpd-codec/src/stream/image.ts
Original file line number Diff line number Diff line change
@@ -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;
}