diff --git a/packages/wpd-codec/src/bytes/base64.test.ts b/packages/wpd-codec/src/bytes/base64.test.ts new file mode 100644 index 0000000000..15b4ca51d0 --- /dev/null +++ b/packages/wpd-codec/src/bytes/base64.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { bytesToBase64 } from "./base64"; + +// Direct unit coverage for the RFC 4648 base64 encoder, isolated from the image/OLE integration tests that only ever exercise it indirectly through a real embedded payload. +describe("bytesToBase64", () => { + it("encodes an empty buffer as an empty string", () => { + expect(bytesToBase64(new Uint8Array())).toBe(""); + }); + + it("encodes a length divisible by three with no padding", () => { + // "Man" -> "TWFu", the canonical RFC 4648 example. + expect(bytesToBase64(new Uint8Array([0x4d, 0x61, 0x6e]))).toBe("TWFu"); + }); + + it("encodes exactly one trailing byte with two padding characters", () => { + // "M" -> "TQ==". + expect(bytesToBase64(new Uint8Array([0x4d]))).toBe("TQ=="); + }); + + it("encodes exactly two trailing bytes with one padding character", () => { + // "Ma" -> "TWE=". + expect(bytesToBase64(new Uint8Array([0x4d, 0x61]))).toBe("TWE="); + }); + + it("uses every character of the alphabet across its full input range", () => { + // 0x00 through 0xff, 256 bytes: exercises b0/b1/b2 across every 6-bit slice value at least once, so a truncated or wrong alphabet index cannot go unnoticed the way a single short input would. + const bytes = new Uint8Array(256); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = i; + } + const encoded = bytesToBase64(bytes); + expect(encoded).toHaveLength(344); + // Cross-check against the platform's own base64 decoder rather than a second hand-rolled implementation. + const decoded = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + expect(decoded).toEqual(bytes); + }); +}); diff --git a/packages/wpd-codec/src/bytes/view.test.ts b/packages/wpd-codec/src/bytes/view.test.ts new file mode 100644 index 0000000000..40fab44ba5 --- /dev/null +++ b/packages/wpd-codec/src/bytes/view.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { WpdFormatError } from "../errors"; +import { byteAt, int16At, sliceAt, uint16At, uint32At } from "./view"; + +describe("byteAt", () => { + it("reads the byte at the given offset", () => { + expect(byteAt(new Uint8Array([0x12, 0x34]), 1)).toBe(0x34); + }); + + it("throws, naming the offset and file length, past the end of the buffer", () => { + expect(() => byteAt(new Uint8Array([0x12]), 1)).toThrow(WpdFormatError); + expect(() => byteAt(new Uint8Array([0x12]), 1)).toThrow( + "Byte read at offset 1 is past the end of a 1-byte file.", + ); + }); +}); + +describe("uint16At / uint32At", () => { + it("reads a little-endian 16-bit value", () => { + expect(uint16At(new Uint8Array([0x34, 0x12]), 0)).toBe(0x1234); + }); + + it("reads a little-endian 32-bit value without sign-extending a high bit", () => { + expect(uint32At(new Uint8Array([0x00, 0x00, 0x00, 0x80]), 0)).toBe( + 0x80000000, + ); + }); +}); + +describe("int16At", () => { + it("reads the largest positive value, 0x7fff, without reinterpreting it", () => { + expect(int16At(new Uint8Array([0xff, 0x7f]), 0)).toBe(0x7fff); + }); + + it("reinterprets 0x8000, the smallest value whose sign bit is set, as negative", () => { + expect(int16At(new Uint8Array([0x00, 0x80]), 0)).toBe(-0x8000); + }); + + it("reinterprets 0xffff as -1", () => { + expect(int16At(new Uint8Array([0xff, 0xff]), 0)).toBe(-1); + }); +}); + +describe("sliceAt", () => { + it("returns a view onto the same buffer, not a copy", () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const slice = sliceAt(bytes, 1, 2); + expect(slice).toEqual(new Uint8Array([2, 3])); + bytes[1] = 9; + expect(slice[0]).toBe(9); + }); + + it("accepts a slice that exactly reaches the end of the buffer", () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + expect(sliceAt(bytes, 2, 2)).toEqual(new Uint8Array([3, 4])); + }); + + it("rejects a negative offset", () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(() => sliceAt(bytes, -1, 1)).toThrow(WpdFormatError); + expect(() => sliceAt(bytes, -1, 1)).toThrow( + "A 1-byte read at offset -1 does not fit inside a 3-byte file.", + ); + }); + + it("rejects a negative length", () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(() => sliceAt(bytes, 0, -1)).toThrow(WpdFormatError); + }); + + it("rejects a length that runs one byte past the end of the buffer", () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(() => sliceAt(bytes, 2, 2)).toThrow(WpdFormatError); + expect(() => sliceAt(bytes, 2, 2)).toThrow( + "A 2-byte read at offset 2 does not fit inside a 3-byte file.", + ); + }); +}); diff --git a/packages/wpd-codec/src/codec.test.ts b/packages/wpd-codec/src/codec.test.ts new file mode 100644 index 0000000000..c332cb6300 --- /dev/null +++ b/packages/wpd-codec/src/codec.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { WpdBytesSchema } from "./codec"; + +// Direct coverage of hasWordPerfectOrCompoundHeader's two independent every()-over-a-magic-byte-array checks, neither of which any other test in this package exercises: read.test.ts and container.test.ts only ever build bytes that already carry a genuine WPD or compound file ID, and never a byte array that partially, but not fully, matches one. +describe("WpdBytesSchema", () => { + it("accepts bytes carrying the exact WPD file ID", () => { + const bytes = new Uint8Array([0xff, 0x57, 0x50, 0x43, 0, 0, 0, 0]); + expect(WpdBytesSchema.safeParse(bytes).success).toBe(true); + }); + + it("rejects bytes matching the WPD file ID's first byte but not its second", () => { + const bytes = new Uint8Array([0xff, 0x00, 0x50, 0x43, 0, 0, 0, 0]); + expect(WpdBytesSchema.safeParse(bytes).success).toBe(false); + }); + + it("accepts bytes carrying the exact OLE compound file signature", () => { + const bytes = new Uint8Array([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + expect(WpdBytesSchema.safeParse(bytes).success).toBe(true); + }); + + it("rejects bytes matching the compound signature's first byte but not its second", () => { + const bytes = new Uint8Array([ + 0xd0, 0x00, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + expect(WpdBytesSchema.safeParse(bytes).success).toBe(false); + }); + + it("rejects bytes matching neither signature, with a message naming both", () => { + const result = WpdBytesSchema.safeParse(new Uint8Array([1, 2, 3, 4])); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + "not a WordPerfect document (no FF 57 50 43 file ID, and no OLE compound file signature that could wrap one)", + ); + } + }); +}); diff --git a/packages/wpd-codec/src/container/container.test.ts b/packages/wpd-codec/src/container/container.test.ts index 426982c28d..df45a6316d 100644 --- a/packages/wpd-codec/src/container/container.test.ts +++ b/packages/wpd-codec/src/container/container.test.ts @@ -1,3 +1,4 @@ +import { writeCompoundFile } from "archive-codec"; import { describe, expect, it } from "vitest"; import { WpdNotAWordPerfectFileError } from "../errors"; import { compoundFileWithStream } from "../test-support/compound-file"; @@ -11,13 +12,28 @@ import { openWpdDocument, PERFECT_OFFICE_MAIN_STREAM } from "./container"; describe("openWpdDocument", () => { // The WordPerfect 6.x spelling: the prefix and document area written straight to disk, with the file ID at offset 0. it("opens a bare WordPerfect file", () => { - const container = openWpdDocument(genericHeaderBytes()); + const input = genericHeaderBytes(); + const container = openWpdDocument(input); expect(container.compound).toBe(false); expect(container.documentAreaOffset).toBe( GENERIC_HEADER_DOCUMENT_AREA_OFFSET, ); expect(container.documentAreaEnd).toBe(GENERIC_HEADER_SIZE); expect(container.packets).toHaveLength(4); + // An ArrayBuffer-backed input is used as-is, not copied: the container's own bytes are the identical object, not merely an equal one. + expect(container.bytes).toBe(input); + }); + + // document-schema.js's ContentCodec port types a read as taking a plain Uint8Array, whose backing buffer may be a SharedArrayBuffer rather than a plain ArrayBuffer. That case takes the one path this package ever copies bytes on, and no other test constructs a SharedArrayBuffer-backed input at all. + it("copies a SharedArrayBuffer-backed input into a real ArrayBuffer rather than reading it in place", () => { + const source = genericHeaderBytes(); + const shared = new Uint8Array(new SharedArrayBuffer(source.length)); + shared.set(source); + const container = openWpdDocument(shared); + expect(container.compound).toBe(false); + expect(container.bytes).not.toBe(shared); + expect(container.bytes.buffer).toBeInstanceOf(ArrayBuffer); + expect(container.documentAreaEnd).toBe(GENERIC_HEADER_SIZE); }); // The WP7-and-later spelling: the identical byte stream inside an OLE compound file's PerfectOffice_MAIN stream. Both must produce the same document, which is the point of deciding the container by inspecting bytes rather than by version. @@ -37,18 +53,39 @@ describe("openWpdDocument", () => { expect(container.documentAreaEnd).toBe(GENERIC_HEADER_SIZE); }); + // Only a stream nested under PerfectOffice_OBJECTS/ belongs in oleObjectStreams, keyed by the part of its path after that prefix -- a sibling top-level stream (here, a made-up SummaryInformation stream no test elsewhere carries alongside PerfectOffice_MAIN) must be excluded entirely, not merely mis-keyed. + it("collects only the streams nested under PerfectOffice_OBJECTS, keyed by their name within it", () => { + const objectBytes = new Uint8Array([1, 2, 3]); + const compound = writeCompoundFile([ + { path: PERFECT_OFFICE_MAIN_STREAM, bytes: genericHeaderBytes() }, + { path: "PerfectOffice_OBJECTS/OLE1", bytes: objectBytes }, + { path: "SummaryInformation", bytes: new Uint8Array([9, 9]) }, + ]); + const container = openWpdDocument(compound); + expect(container.oleObjectStreams.size).toBe(1); + expect(container.oleObjectStreams.get("OLE1")).toEqual(objectBytes); + }); + it("rejects a compound file carrying no PerfectOffice_MAIN stream", () => { const wrapped = compoundFileWithStream( "WordDocument", genericHeaderBytes(), ); expect(() => openWpdDocument(wrapped)).toThrow(WpdNotAWordPerfectFileError); + expect(() => openWpdDocument(wrapped)).toThrow( + "This OLE compound file carries no PerfectOffice_MAIN stream, so it holds no WordPerfect document.", + ); }); it("rejects bytes that are neither a WordPerfect file nor a compound file", () => { expect(() => openWpdDocument(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])), ).toThrow(WpdNotAWordPerfectFileError); + expect(() => + openWpdDocument(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])), + ).toThrow( + "These bytes are neither a WordPerfect file (which opens with the file ID FF 57 50 43) nor an OLE compound file that could contain one.", + ); }); // The SDK warns that a third-party writer forgetting to update {file size} after adding text is a common real-world defect, and that the symptom is a document that "will appear ... to be blank". Trusting a stale field over the bytes in hand is exactly how that happens, so a file size that stops at the document area's own start is disregarded. @@ -67,4 +104,17 @@ describe("openWpdDocument", () => { bytes[23] = 0xff; expect(openWpdDocument(bytes).documentAreaEnd).toBe(bytes.length); }); + + // The happy path documentAreaEnd exists for: a genuinely valid file size, strictly less than the buffer's own length (trailing bytes past the document's real end). Every other test either leaves the two equal or forces the fallback, so neither ever proves the field is actually honoured rather than the buffer's length being reported by coincidence. + it("honours a valid file size shorter than the buffer, rather than falling back to the buffer's own end", () => { + const source = genericHeaderBytes(); + const padded = new Uint8Array(source.length + 10); + padded.set(source); + expect(openWpdDocument(padded).documentAreaEnd).toBe(GENERIC_HEADER_SIZE); + }); + + it("treats an empty-string password identically to no password on an unencrypted document", () => { + const container = openWpdDocument(genericHeaderBytes(), { password: "" }); + expect(container.documentAreaEnd).toBe(GENERIC_HEADER_SIZE); + }); }); diff --git a/packages/wpd-codec/src/container/container.ts b/packages/wpd-codec/src/container/container.ts index 92dbca6dec..1185b7b971 100644 --- a/packages/wpd-codec/src/container/container.ts +++ b/packages/wpd-codec/src/container/container.ts @@ -99,8 +99,9 @@ function documentAreaEnd( header: WpdFileHeader, ): number { const { fileSize, documentAreaOffset } = header; - if (fileSize > documentAreaOffset && fileSize <= bytes.length) { - return fileSize; + // The upper bound is expressed through Math.min rather than a second comparison: a `fileSize <= bytes.length` guard would disagree with `<` only when fileSize === bytes.length exactly, and at that exact point both branches return the same number, so a bare comparator here would be an unkillable equivalent mutant no test could ever distinguish. Math.min carries the identical fallback (bytes.length whenever fileSize would run past it) without emitting a comparison whose boundary case has no observable effect. + if (fileSize > documentAreaOffset) { + return Math.min(fileSize, bytes.length); } return bytes.length; } @@ -115,13 +116,10 @@ export function openWpdDocument( oleObjectStreams, } = unwrapContainer(toArrayBufferBacked(input)); const header = readFileHeader(wrapped, options); - // An empty-string password means no password (the identical normalisation readFileHeader applies), so both gates below see one consistent value. - const suppliedPassword = - options.password === "" ? undefined : options.password; - // An encrypted document's index area, packet data, and document area are all beyond the fixed header and therefore all ciphertext; decrypting the whole buffer in one pass here means every downstream reader (the prefix walker, the tokeniser) parses plaintext with no encryption awareness of its own. A password supplied for an unencrypted document never reaches this branch -- the header word gates it -- and is harmlessly ignored, mirroring every other codec here. + // No separate empty-string normalisation is needed here: readFileHeader above already applies the identical "" -> no password rule and throws WpdEncryptedDocumentError before this line is reached for any encrypted document given an empty-string password, so by the time header.encryption !== 0 is true, options.password is already known to be a defined, non-empty string. An encrypted document's index area, packet data, and document area are all beyond the fixed header and therefore all ciphertext; decrypting the whole buffer in one pass here means every downstream reader (the prefix walker, the tokeniser) parses plaintext with no encryption awareness of its own. A password supplied for an unencrypted document never reaches this branch -- the header word gates it -- and is harmlessly ignored, mirroring every other codec here. const bytes = - header.encryption !== 0 && suppliedPassword !== undefined - ? decryptWpdDocument(wrapped, header, suppliedPassword) + header.encryption !== 0 && options.password !== undefined + ? decryptWpdDocument(wrapped, header, options.password) : wrapped; const packets = readPrefixPackets(bytes, header); return { diff --git a/packages/wpd-codec/src/container/encryption.test.ts b/packages/wpd-codec/src/container/encryption.test.ts index 314c235c15..464fdd7359 100644 --- a/packages/wpd-codec/src/container/encryption.test.ts +++ b/packages/wpd-codec/src/container/encryption.test.ts @@ -1,12 +1,19 @@ import { byteAt } from "../bytes/view"; import { describe, expect, it } from "vitest"; import { buildWpdFile } from "../test-support/build-wpd"; -import { WpdEncryptedDocumentError, WpdWrongPasswordError } from "../errors"; -import { readWpdContent } from "../read"; +import { + WpdEncryptedDocumentError, + WpdFormatError, + WpdWrongPasswordError, +} from "../errors"; +import { readWpd, readWpdContent } from "../read"; +import type { WpdFileHeader } from "./header"; import { applyWpdStandardEncryption, + decryptWpdDocument, encryptWpdDocumentForTests, normaliseWpdPassword, + passwordByteAt, wpdPasswordChecksum16, } from "./encryption"; @@ -24,6 +31,24 @@ describe("normaliseWpdPassword", () => { ]); expect(() => normaliseWpdPassword("passwörd日")).toThrow(/Latin-1/); }); + + it("accepts U+00FF, the last code unit Latin-1 can encode", () => { + expect(normaliseWpdPassword(String.fromCharCode(0xff))).toEqual([0xff]); + }); + + it("refuses U+0100, one past the last code unit Latin-1 can encode", () => { + expect(() => normaliseWpdPassword(String.fromCharCode(0x100))).toThrow( + WpdFormatError, + ); + }); + + it("states the offending code unit in uppercase hex, padded to four digits, and its position", () => { + expect(() => + normaliseWpdPassword(`ok${String.fromCharCode(0xabc)}`), + ).toThrow( + "A password with characters outside Latin-1 cannot be encoded into the byte-keyed WordPerfect cipher (code unit U+0ABC at position 2).", + ); + }); }); describe("wpdPasswordChecksum16", () => { @@ -90,6 +115,48 @@ describe("applyWpdStandardEncryption", () => { applyWpdStandardEncryption(bytes, password, 512); expect(Array.from(bytes)).toEqual(before); }); + + it("refuses an empty password, which the cipher cannot key with", () => { + expect(() => applyWpdStandardEncryption(filled(520), [], 512)).toThrow( + "The WordPerfect cipher is keyed by the password's own bytes, so an empty password decrypts nothing.", + ); + }); +}); + +describe("passwordByteAt", () => { + // applyWpdStandardEncryption's own empty-password guard means no real caller ever reaches this with an empty array, but the function is a plain exported contract with its own behaviour to prove directly, the same way decryptWpdDocument is tested on its own terms above. + it("throws when the password it is asked to cycle through is empty", () => { + expect(() => passwordByteAt([], 0)).toThrow( + "The password normalised to no bytes, which the cipher cannot key with.", + ); + }); + + it("wraps around the password's own length rather than reading past it", () => { + expect(passwordByteAt([0x41, 0x42, 0x43], 3)).toBe(0x41); + expect(passwordByteAt([0x41, 0x42, 0x43], 4)).toBe(0x42); + }); +}); + +describe("decryptWpdDocument", () => { + // decryptWpdDocument's own doc comment says it is "called only for ... a non-empty password", but it is a plain exported function with its own contract, tested directly here rather than only through the container-level guarantee that happens to hold today. + // + // The header's own encryption word is deliberately 0 here (never a real encrypted document's actual value, but this function never inspects that invariant itself): an empty password's checksum is always 0 too, so any non-zero encryption word would already fail the checksum comparison the normal flow performs anyway, masking whether the dedicated empty-password guard ran at all. Only encryption === 0 lets the guard's absence actually be observed -- without it, the empty password would fall through to applyWpdStandardEncryption and throw a WpdFormatError there instead, not a WpdWrongPasswordError. + it("treats an empty password as a wrong password rather than an empty-cipher-key error", () => { + const bytes = buildWpdFile([0]); + const header: WpdFileHeader = { + documentAreaOffset: 0, + productType: 1, + fileType: 0x0a, + majorVersion: 2, + minorVersion: 1, + indexAreaOffset: 512, + fileSize: bytes.length, + encryption: 0, + }; + expect(() => decryptWpdDocument(bytes, header, "")).toThrow( + WpdWrongPasswordError, + ); + }); }); describe("reading an encrypted document", () => { @@ -114,6 +181,12 @@ describe("reading an encrypted document", () => { ); }); + // readWpd threads its own password option through to the same openWpdDocument call readWpdContent uses -- proven separately, since readWpd builds its own tree-form read from scratch rather than delegating to readWpdContent. + it("reads the tree form of the same encrypted document with the password", () => { + const tree = readWpd(encrypted, { password: "sECret" }); + expect(tree.kind).toBe("wordprocessing"); + }); + it("throws WpdEncryptedDocumentError without a password", () => { expect(() => readWpdContent(encrypted)).toThrow(WpdEncryptedDocumentError); }); diff --git a/packages/wpd-codec/src/container/encryption.ts b/packages/wpd-codec/src/container/encryption.ts index 5d6d909b6d..9c082a769b 100644 --- a/packages/wpd-codec/src/container/encryption.ts +++ b/packages/wpd-codec/src/container/encryption.ts @@ -38,6 +38,20 @@ export function wpdPasswordChecksum16(normalised: readonly number[]): number { return checksum; } +// Reads the password byte a given cipher position cycles to, wrapping modulo the password's own length. `noUncheckedIndexedAccess` types this index access as possibly undefined even though it never is for a non-empty `normalised` (the only way applyWpdStandardEncryption ever calls this), so the throw below is unreachable from every real caller -- every one of them already rejects an empty password first. Exported for this package's own tests only, so the throw's own message is proven genuine by a direct test rather than left as a promise no real caller could ever keep (which is exactly what left it inlined and unreachable before: a Stryker mutant on that message's text had no test able to observe it either way). +export function passwordByteAt( + normalised: readonly number[], + relative: number, +): number { + const value = normalised[relative % normalised.length]; + if (value === undefined) { + throw new WpdFormatError( + "The password normalised to no bytes, which the cipher cannot key with.", + ); + } + return value; +} + // The cipher itself. A pure XOR keyed by position (password byte + ascending mask), so the same transform encrypts and decrypts -- wpbreak's paper relies on exactly this symmetry for its known-plaintext attack. Returns a new buffer (bytes at and after startOffset transformed, bytes before it verbatim); the input is never mutated, so an encrypted buffer stays available for a retry with a different password. export function applyWpdStandardEncryption( bytes: Uint8Array, @@ -54,15 +68,9 @@ export function applyWpdStandardEncryption( output.set(bytes.subarray(0, startOffset)); for (let pos = startOffset; pos < bytes.length; pos++) { const relative = pos - startOffset; - const passwordByte = normalised[relative % normalised.length]; - if (passwordByte === undefined) { - // Unreachable: the empty-password throw above guarantees a non-empty array, so a modulo of its length always indexes in bounds. This is the noUncheckedIndexedAccess narrowing, not a fallback. - throw new WpdFormatError( - "The password normalised to no bytes, which the cipher cannot key with.", - ); - } const mask = (maskBase + relative) & 0xff; - output[pos] = byteAt(bytes, pos) ^ passwordByte ^ mask; + output[pos] = + byteAt(bytes, pos) ^ passwordByteAt(normalised, relative) ^ mask; } return output; } diff --git a/packages/wpd-codec/src/container/header.test.ts b/packages/wpd-codec/src/container/header.test.ts index 9601b4791a..ea5d5bfe82 100644 --- a/packages/wpd-codec/src/container/header.test.ts +++ b/packages/wpd-codec/src/container/header.test.ts @@ -10,7 +10,7 @@ import { GENERIC_HEADER_SIZE, genericHeaderBytes, } from "../test-support/generic-header"; -import { readFileHeader } from "./header"; +import { hasWordPerfectFileId, readFileHeader } from "./header"; // A minimal conforming 16-byte header, assembled field by field from the SDK's own "File Header Format" table rather than copied from a real file, so each assertion below points at one named field. function headerBytes( @@ -45,6 +45,24 @@ function headerBytes( return bytes; } +describe("hasWordPerfectFileId", () => { + it("is true for the exact file ID", () => { + expect(hasWordPerfectFileId(new Uint8Array([0xff, 0x57, 0x50, 0x43]))).toBe( + true, + ); + }); + + it("is false when only a prefix of the file ID matches", () => { + expect(hasWordPerfectFileId(new Uint8Array([0xff, 0x57, 0, 0]))).toBe( + false, + ); + }); + + it("is false for an empty buffer", () => { + expect(hasWordPerfectFileId(new Uint8Array(0))).toBe(false); + }); +}); + describe("readFileHeader", () => { it("reads every field of the SDK's own generic header example", () => { const header = readFileHeader(genericHeaderBytes()); @@ -62,32 +80,46 @@ describe("readFileHeader", () => { }); it("rejects a file whose first four bytes are not the -1,'WPC' file ID", () => { - expect(() => - readFileHeader(headerBytes({ id: [0x50, 0x4b, 0x03, 0x04] })), - ).toThrow(WpdNotAWordPerfectFileError); + const bytes = headerBytes({ id: [0x50, 0x4b, 0x03, 0x04] }); + expect(() => readFileHeader(bytes)).toThrow(WpdNotAWordPerfectFileError); + expect(() => readFileHeader(bytes)).toThrow( + 'Expected the WordPerfect file ID FF 57 50 43 (-1,"WPC") at offset 0, found 50 4b 03 04.', + ); }); it("rejects an encrypted document rather than returning an unreadable header", () => { expect(() => readFileHeader(headerBytes({ encryption: 1 }))).toThrow( WpdEncryptedDocumentError, ); + expect(() => readFileHeader(headerBytes({ encryption: 1 }))).toThrow( + 'This document is encrypted (encryption word 1); nothing beyond the file header is intelligible without the password. Pass { password } to read it -- the standard ("original") encryption mode is supported, and a non-matching password throws WpdWrongPasswordError.', + ); }); it("rejects a WordPerfect 5.x file, whose major version is not the 6.x-X6 lineage's 2", () => { expect(() => readFileHeader(headerBytes({ majorVersion: 0 }))).toThrow( WpdUnsupportedVersionError, ); + expect(() => readFileHeader(headerBytes({ majorVersion: 0 }))).toThrow( + "Major version 0 is outside the WordPerfect 6.x-X6 lineage (major version 2), the one generation this reader covers.", + ); }); it("rejects a non-document WordPerfect file, such as a printer resource file", () => { expect(() => readFileHeader(headerBytes({ fileType: 0x10 }))).toThrow( WpdUnsupportedVersionError, ); + expect(() => readFileHeader(headerBytes({ fileType: 0x10 }))).toThrow( + "File type 16 is not a WordPerfect document (expected 10 or 36).", + ); }); it("rejects a file from another Corel product", () => { expect(() => readFileHeader(headerBytes({ productType: 3 }))).toThrow( WpdUnsupportedVersionError, ); + expect(() => readFileHeader(headerBytes({ productType: 3 }))).toThrow( + "Product type 3 is not WordPerfect (1); this file was produced by a different Corel product.", + ); }); }); diff --git a/packages/wpd-codec/src/container/header.ts b/packages/wpd-codec/src/container/header.ts index cef57025f6..cda3d6379d 100644 --- a/packages/wpd-codec/src/container/header.ts +++ b/packages/wpd-codec/src/container/header.ts @@ -48,9 +48,7 @@ export interface WpdFileHeader { // True when the bytes open with the -1,"WPC" file ID. Cheap enough to run before any other work, and the discriminator the container layer uses to decide whether a buffer is a bare WordPerfect file or something (an OLE compound file) that may contain one. export function hasWordPerfectFileId(bytes: Uint8Array): boolean { - if (bytes.length < WPD_FILE_ID.length) { - return false; - } + // No separate length guard is needed: indexing a Uint8Array past its own end always answers undefined rather than throwing, and undefined never equals one of WPD_FILE_ID's own byte values, so a buffer shorter than the file ID already fails the every() below on its own. return WPD_FILE_ID.every((expected, index) => bytes[index] === expected); } diff --git a/packages/wpd-codec/src/container/prefix.test.ts b/packages/wpd-codec/src/container/prefix.test.ts index 280daa757f..e745869813 100644 --- a/packages/wpd-codec/src/container/prefix.test.ts +++ b/packages/wpd-codec/src/container/prefix.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { WpdFormatError } from "../errors"; +import { buildWpdFile, word } from "../test-support/build-wpd"; import { genericHeaderBytes } from "../test-support/generic-header"; import { readFileHeader } from "./header"; import { @@ -92,6 +93,31 @@ describe("readPrefixPackets", () => { expect(() => readPrefixPackets(bytes, readFileHeader(bytes))).toThrow( WpdFormatError, ); + expect(() => readPrefixPackets(bytes, readFileHeader(bytes))).toThrow( + "The index block at offset 512 opens with flags 7, not the index header's documented value of 2.", + ); + }); + + it("rejects an index header claiming zero indexes, since it is itself one of them", () => { + const bytes = genericHeaderBytes(); + // The index header's own [count] word sits at 512 + 2. + bytes[514] = 0; + bytes[515] = 0; + expect(() => readPrefixPackets(bytes, readFileHeader(bytes))).toThrow( + "The index header claims 0 indexes, but it is itself one of them.", + ); + }); + + // Packet Type 0 ("Index Entry Is Available or Was Deleted") is a live slot whose size and pointer fields mean nothing -- it must still consume a prefix ID (recorded with empty bytes), never be resolved through sliceAt against its own (meaningless) size and offset fields the way a real packet is. + it("records a deleted index slot with empty bytes rather than resolving its meaningless size and offset", () => { + const bytes = buildWpdFile( + [0], + [{ packetType: 0, bytes: new Uint8Array([1, 2, 3]) }], + ); + const packets = readPrefixPackets(bytes, readFileHeader(bytes)); + expect(packets).toHaveLength(1); + expect(packets[0]?.packetType).toBe(0); + expect(packets[0]?.bytes).toEqual(new Uint8Array(0)); }); }); @@ -110,6 +136,21 @@ describe("readTypefaceName", () => { it("returns undefined for a packet too short to hold a descriptor's fixed fields", () => { expect(readTypefaceName(new Uint8Array(10))).toBeUndefined(); }); + + it("returns undefined for a stated name length of zero", () => { + const packet = new Uint8Array(24); + // [typeface name length] at offset 22 is already zero-initialised. + expect(readTypefaceName(packet)).toBeUndefined(); + }); + + // A descriptor whose stated name length (6) is genuinely shorter than the bytes physically remaining in the packet (12, six whole word slots) -- the SDK's own generic-header example never exercises this because its name length happens to equal its remaining bytes exactly, so the two candidate bounds always agree there. + it("stops at the stated name length even though more word slots physically follow it", () => { + const packet = new Uint8Array(36); + packet.set(word(6), 22); // [typeface name length] = 6 bytes = 3 words + const chars = Array.from("ABCDEF", (c) => c.charCodeAt(0)).flatMap(word); + packet.set(chars, 24); // six word slots physically present (12 bytes) + expect(readTypefaceName(packet)).toBe("ABC"); + }); }); describe("readGeneralWpTextBlocks", () => { @@ -133,4 +174,22 @@ describe("readGeneralWpTextBlocks", () => { const header = [1, 0, 4, 0, 100, 0]; // claims a 100-byte block with no data present expect(readGeneralWpTextBlocks(new Uint8Array(header))).toBeUndefined(); }); + + it("returns undefined for a packet too short to even hold the block count and offset", () => { + expect(readGeneralWpTextBlocks(new Uint8Array(2))).toBeUndefined(); + }); + + // A block count (10) claiming far more size-field slots than the ten-byte packet has room for: sizesEnd (4 + blockCount * 2 = 24) must correctly exceed the packet's own length here, or the reader would walk off the end of the buffer trying to read each block's own stated size. + it("returns undefined when the block count's own size fields would run past the packet", () => { + const header = [10, 0, 0, 0]; // [count=10] [offset=0] + expect( + readGeneralWpTextBlocks(new Uint8Array([...header, 0, 0, 0, 0, 0, 0])), + ).toBeUndefined(); + }); + + // A single zero-length block whose one size field exactly fills out the packet, with no room to spare -- sizesEnd (4 + blockCount * 2 = 6) lands exactly on the packet's own six-byte length, the one boundary where "runs past" and "fits exactly" agree or disagree depending on which comparison runs. + it("reads a zero-length block when its own size field exactly fills the packet", () => { + const bytes = new Uint8Array([1, 0, 0, 0, 0, 0]); // [count=1] [offset=0] [size1=0] + expect(readGeneralWpTextBlocks(bytes)).toEqual(new Uint8Array(0)); + }); }); diff --git a/packages/wpd-codec/src/container/prefix.ts b/packages/wpd-codec/src/container/prefix.ts index 3053dd209e..2aa290d4ca 100644 --- a/packages/wpd-codec/src/container/prefix.ts +++ b/packages/wpd-codec/src/container/prefix.ts @@ -118,43 +118,41 @@ export const PACKET_TYPE_GENERAL_WP_TEXT = 0x08; export function readGeneralWpTextBlocks( bytes: Uint8Array, ): Uint8Array | undefined { - if (bytes.length < 4) { - return undefined; - } - const blockCount = uint16At(bytes, 0); - const firstBlockOffset = uint16At(bytes, 2); - if (blockCount === 0) { - return undefined; - } - const sizesEnd = 4 + blockCount * 2; - if (sizesEnd > bytes.length) { - return undefined; - } - let totalSize = 0; - for (let index = 0; index < blockCount; index += 1) { - totalSize += uint16At(bytes, 4 + index * 2); - } - const end = firstBlockOffset + totalSize; - if (firstBlockOffset < 0 || end > bytes.length) { + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past bytes' own end, caught below -- so neither the block count, the first block offset, nor any one size word in the loop below needs a separate room check ahead of reading it. A dedicated sizesEnd > bytes.length guard used to sit ahead of the loop, checking room for every size word the loop is about to read in one go -- but unlike the text-block header guard style.ts's readStyleBeginBlock still needs (which checks room for a field the loop never reads), this guard's own threshold (4 + blockCount * 2) is exactly the byte offset the loop's own last iteration already requires, so a bytes.length short of it makes that same iteration throw in precisely the place the guard would have rejected it -- no input can tell the removed guard from the throw it deferred to. + try { + const blockCount = uint16At(bytes, 0); + const firstBlockOffset = uint16At(bytes, 2); + if (blockCount === 0) { + return undefined; + } + let totalSize = 0; + for (let index = 0; index < blockCount; index += 1) { + totalSize += uint16At(bytes, 4 + index * 2); + } + const end = firstBlockOffset + totalSize; + // No separate firstBlockOffset < 0 guard is needed: uint16At only ever answers an unsigned 16-bit value, so firstBlockOffset can never be negative in the first place. + if (end > bytes.length) { + return undefined; + } + return bytes.subarray(firstBlockOffset, end); + } catch { return undefined; } - return bytes.subarray(firstBlockOffset, end); } // "The typeface name is made up for four separate null word-terminated strings: 1st string = typeface family (such as Times or Swiss), 2nd string = attributes (such as Bold, Italic, or Bold Italic), 3rd string = name prefix ... 4th string = name extension." Only the first is returned: it is the one a ContentRun's fontFamily wants, and the attributes string duplicates information the document's own Attribute On/Off functions already carry. export function readTypefaceName(packet: Uint8Array): string | undefined { - if (packet.length < TYPEFACE_NAME_OFFSET) { - return undefined; - } - const nameLength = uint16At(packet, TYPEFACE_NAME_LENGTH_OFFSET); - const available = Math.min(nameLength, packet.length - TYPEFACE_NAME_OFFSET); - if (available <= 0) { + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past packet's own end, caught below -- so the name-length word itself needs no separate room check ahead of reading it. + try { + const nameLength = uint16At(packet, TYPEFACE_NAME_LENGTH_OFFSET); + // No separate bound against packet's own remaining length is needed here: decodeWordString already stops the moment it runs off the real end of `packet`, regardless of how many words it is asked for, so nameLength alone (converted from a byte count to a word count) is exactly as safe a bound as intersecting it with the packet's own remaining length would be. + const { text } = decodeWordString( + packet, + TYPEFACE_NAME_OFFSET, + Math.floor(nameLength / 2), + ); + return text.length > 0 ? text : undefined; + } catch { return undefined; } - const { text } = decodeWordString( - packet, - TYPEFACE_NAME_OFFSET, - Math.floor(available / 2), - ); - return text.length > 0 ? text : undefined; } diff --git a/packages/wpd-codec/src/container/summary.test.ts b/packages/wpd-codec/src/container/summary.test.ts index f4c4178f0a..f87807ef63 100644 --- a/packages/wpd-codec/src/container/summary.test.ts +++ b/packages/wpd-codec/src/container/summary.test.ts @@ -128,6 +128,54 @@ describe("readDocumentSummary", () => { new Uint8Array(group(14, DATE, dateField({ year: 0, month: 0, day: 0 }))), ); expect(metadata.createdIso).toBeUndefined(); + // Stronger than the property read above: an invalid date must leave the key entirely absent, not merely assign it the value undefined. + expect("createdIso" in metadata).toBe(false); + }); + + it("ignores a date with a zero year but a real month and day", () => { + const metadata = readDocumentSummary( + new Uint8Array( + group(14, DATE, dateField({ year: 0, month: 5, day: 10 })), + ), + ); + expect(metadata.createdIso).toBeUndefined(); + }); + + it("ignores a date with a zero month but a real year and day", () => { + const metadata = readDocumentSummary( + new Uint8Array( + group(14, DATE, dateField({ year: 1999, month: 0, day: 10 })), + ), + ); + expect(metadata.createdIso).toBeUndefined(); + }); + + it("ignores a date with a zero day but a real year and month", () => { + const metadata = readDocumentSummary( + new Uint8Array( + group(14, DATE, dateField({ year: 1999, month: 5, day: 0 })), + ), + ); + expect(metadata.createdIso).toBeUndefined(); + }); + + // A date field whose every genuinely-read byte (year, month, day, hour, minute, second) is present, but whose three trailing, entirely-unread bytes (day of week, time zone, unused) fall past the packet's own end -- the length guard must still refuse it defensively, even though every byte the function actually consumes is there. + it("refuses a date field cut off exactly after its last read byte, with none of the trailing unread padding present", () => { + const dataOffset = 8; // 6-byte group header + 2-byte empty name + const bytes = new Uint8Array([ + ...word(6 + 2 + 7), + ...word(14), + ...word(DATE), + ...word(0), // empty name + ...word(2001), + 1, + 1, + 9, + 30, + 0, + ]); + expect(bytes.length).toBe(dataOffset + 7); + expect(readDocumentSummary(bytes).createdIso).toBeUndefined(); }); // A summary carrying a field this package has no LayoutMetadata home for -- "1 | Abstract | Multi-line" -- is stepped over by its own size, leaving the fields after it readable. @@ -168,4 +216,89 @@ describe("readDocumentSummary", () => { ), ).toEqual({}); }); + + // A group's stated size lying far beyond the packet must refuse the group outright -- not fall through and let the reader trust whatever real bytes happen to sit within the packet's own true bounds as if they belonged to this group's data. + it("never lets a corrupted, oversized group borrow real bytes from beyond its own claimed extent", () => { + const tail = [...word(0), ...wordString("HELLO")]; // empty name, then real word data + const bytes = new Uint8Array([ + ...word(100), // claims 100 bytes, far more than the packet actually holds + ...word(5), // TAG_AUTHOR + ...word(SINGLE_LINE), + ...tail, + ]); + expect(readDocumentSummary(bytes)).toEqual({}); + }); + + // A group's stated size smaller than its own six-byte header is nonsensical and must stop the walk outright -- not advance the cursor by that bogus size and let the next iteration reinterpret real trailing bytes as a phantom group header. + it("never lets a group smaller than its own header desynchronise the cursor onto later bytes", () => { + const bytes = new Uint8Array([ + ...word(2), // size = 2, smaller than the six-byte header that already follows + ...word(20), // only meaningful if desynchronised into a phantom group's [size] + ...word(5), // only meaningful if desynchronised into a phantom group's [tag] (TAG_AUTHOR) + ...word(SINGLE_LINE), // only meaningful if desynchronised into a phantom group's [type] + ...word(0), // phantom group's empty name + ...wordString("HELLO"), + ]); + expect(readDocumentSummary(bytes)).toEqual({}); + }); + + // A group whose stated size is exactly the six-byte header (no room for any data) must still let the walk continue onto the next, genuinely well-formed group -- it is empty, not corrupt. + it("passes over a header-only group and still reads the group that follows it", () => { + const bytes = new Uint8Array([ + ...word(6), // an empty group: size exactly matches the six-byte header, [size] [tag] [type] + ...word(999), // an unrecognised tag, irrelevant since there is no data anyway + ...word(SINGLE_LINE), + ...group(5, SINGLE_LINE, wordString("A. Writer")), + ]); + expect(readDocumentSummary(bytes).author).toBe("A. Writer"); + }); + + // The two-word budget passed to decodeWordString for a group's data (availableWords minus the words the optional name consumed) must be exactly right: too generous, and a group with no null terminator of its own keeps reading real bytes that belong to the next group entirely. + it("stops reading a group's data at its own true boundary, even with no null terminator of its own", () => { + const rawChars = (value: string): number[] => + [...value].flatMap((character) => word(character.charCodeAt(0))); + const bytes = new Uint8Array([ + ...group(46, SINGLE_LINE, rawChars("ABC")), // TAG_SUBJECT, data with no trailing null word + ...group(5, SINGLE_LINE, wordString("ZZZZZ")), // TAG_AUTHOR + ]); + const metadata = readDocumentSummary(bytes); + expect(metadata.subject).toBe("ABC"); + expect(metadata.author).toBe("ZZZZZ"); + }); + + it("leaves author unset for a group whose data is empty text, rather than an empty string", () => { + const metadata = readDocumentSummary( + new Uint8Array(group(5, SINGLE_LINE, [])), + ); + expect(metadata.author).toBeUndefined(); + expect("author" in metadata).toBe(false); + }); + + it("leaves keywords unset when every split entry is empty, rather than an empty array", () => { + const metadata = readDocumentSummary( + new Uint8Array(group(26, SINGLE_LINE, wordString(","))), + ); + expect(metadata.keywords).toBeUndefined(); + expect("keywords" in metadata).toBe(false); + }); + + // A date-typed field carrying neither the creation nor the revision tag (an adversarial or simply unknown tag reusing the DATE type bit) must not be reported as either -- there is no third date slot in LayoutMetadata to fall back onto. + it("reports neither created nor modified for a date-typed field under an unrecognised tag", () => { + const metadata = readDocumentSummary( + new Uint8Array( + group(999, DATE, dateField({ year: 2001, month: 1, day: 1 })), + ), + ); + expect(metadata).toStrictEqual({}); + }); + + // The hundred-group safety net (MAX_SUMMARY_GROUPS) must stop the walk exactly at its own bound: a document with more genuinely well-formed groups than that must not have its 101st group read. + it("never reads a 101st group, even when every one of the first hundred is well-formed", () => { + const filler = group(999, SINGLE_LINE, []); // an unrecognised tag, six bytes of header plus an empty name + const bytes = new Uint8Array([ + ...Array.from({ length: 100 }, () => filler).flat(), + ...group(5, SINGLE_LINE, wordString("A. Writer")), // the 101st group + ]); + expect(readDocumentSummary(bytes).author).toBeUndefined(); + }); }); diff --git a/packages/wpd-codec/src/container/summary.ts b/packages/wpd-codec/src/container/summary.ts index f10d078aed..14dfca6ea2 100644 --- a/packages/wpd-codec/src/container/summary.ts +++ b/packages/wpd-codec/src/container/summary.ts @@ -85,12 +85,17 @@ export function readDocumentSummary(packet: Uint8Array): LayoutMetadata { let cursor = 0; for (let group = 0; group < MAX_SUMMARY_GROUPS; group += 1) { - if (cursor + GROUP_HEADER_SIZE > packet.length) { + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past packet's own end -- caught here and treated exactly like the size check just below, which ends the walk at whatever metadata has already been collected, since a group's own header running past the packet is the same "framing has gone out of step" case that check already handles. + let size: number; + let tag: number; + let type: number; + try { + size = uint16At(packet, cursor); + tag = uint16At(packet, cursor + 2); + type = uint16At(packet, cursor + 4); + } catch { break; } - const size = uint16At(packet, cursor); - const tag = uint16At(packet, cursor + 2); - const type = uint16At(packet, cursor + 4); if (size < GROUP_HEADER_SIZE || cursor + size > packet.length) { break; } @@ -141,7 +146,7 @@ export function readDocumentSummary(packet: Uint8Array): LayoutMetadata { break; } default: - break; + // No break needed: this is already the switch's last case. } } cursor += size; diff --git a/packages/wpd-codec/src/errors.test.ts b/packages/wpd-codec/src/errors.test.ts new file mode 100644 index 0000000000..25d20a9a79 --- /dev/null +++ b/packages/wpd-codec/src/errors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + WpdEncryptedDocumentError, + WpdFormatError, + WpdNotAWordPerfectFileError, + WpdUnsupportedVersionError, + WpdWrongPasswordError, +} from "./errors"; + +// Each subclass sets its own `.name`, and downstream reading code (and consumers catching by name) relies on it -- the integration tests exercising these errors never assert on `.name` itself, only on `instanceof`, so it needs its own direct coverage here. +describe("WpdFormatError subclasses", () => { + it("names WpdFormatError itself", () => { + expect(new WpdFormatError("x").name).toBe("WpdFormatError"); + }); + + it("names WpdNotAWordPerfectFileError", () => { + expect(new WpdNotAWordPerfectFileError("x").name).toBe( + "WpdNotAWordPerfectFileError", + ); + }); + + it("names WpdEncryptedDocumentError", () => { + expect(new WpdEncryptedDocumentError("x").name).toBe( + "WpdEncryptedDocumentError", + ); + }); + + it("names WpdUnsupportedVersionError", () => { + expect(new WpdUnsupportedVersionError("x").name).toBe( + "WpdUnsupportedVersionError", + ); + }); + + it("names WpdWrongPasswordError and states both checksums in its message", () => { + const error = new WpdWrongPasswordError(0x1234, 0xabcd); + expect(error.name).toBe("WpdWrongPasswordError"); + expect(error.headerEncryptionWord).toBe(0x1234); + expect(error.passwordChecksum).toBe(0xabcd); + expect(error.message).toBe( + "The header's encryption word (0x1234) does not match this password's checksum (0xabcd): either the password is wrong, or the file uses the enhanced encryption mode (WordPerfect 9 and later), which this reader does not support.", + ); + }); +}); diff --git a/packages/wpd-codec/src/format.test.ts b/packages/wpd-codec/src/format.test.ts new file mode 100644 index 0000000000..3c92cd6cc5 --- /dev/null +++ b/packages/wpd-codec/src/format.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { WPD_FILE_EXTENSION, WPD_MEDIA_TYPE } from "./format"; + +describe("format identifiers", () => { + it("states the IANA-registered media type", () => { + expect(WPD_MEDIA_TYPE).toBe("application/vnd.wordperfect"); + }); + + it("states the conventional file extension", () => { + expect(WPD_FILE_EXTENSION).toBe(".wpd"); + }); +}); diff --git a/packages/wpd-codec/src/read-structure.test.ts b/packages/wpd-codec/src/read-structure.test.ts index 3ecbac1cb1..d215c4b28e 100644 --- a/packages/wpd-codec/src/read-structure.test.ts +++ b/packages/wpd-codec/src/read-structure.test.ts @@ -247,6 +247,86 @@ describe("page geometry", () => { ), ).toBe(true); }); + + it("does not report a landscape orientation for a portrait form", () => { + const { diagnostics } = readWithDiagnostics([ + ...pageForm({ lengthWpu: 14031, widthWpu: 9921, orientation: 0 }), + ...text("A4"), + ]); + expect( + diagnostics.some( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.LandscapeOrientationUnmapped, + ), + ).toBe(false); + }); + + it("does not report a page geometry change when the same value is stated twice", () => { + const { diagnostics } = readWithDiagnostics([ + ...marginFunction(PAGE_GROUP, 0x00, 600), + ...text("first"), + HARD_EOL, + ...marginFunction(PAGE_GROUP, 0x00, 600), + ...text("second"), + ]); + expect( + diagnostics.filter( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.PageGeometryChanged, + ), + ).toHaveLength(0); + }); + + it("reports a page geometry change only once across more than one later change", () => { + const { diagnostics } = readWithDiagnostics([ + ...marginFunction(PAGE_GROUP, 0x00, 600), + ...text("first"), + HARD_EOL, + ...marginFunction(PAGE_GROUP, 0x00, 1200), + ...text("second"), + HARD_EOL, + ...marginFunction(PAGE_GROUP, 0x00, 2400), + ...text("third"), + ]); + expect( + diagnostics.filter( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.PageGeometryChanged, + ), + ).toHaveLength(1); + }); + + // applyPageGroup's own top/bottom dispatch must actually gate on the subgroup, not fall into the bottom-margin branch for any subgroup it does not recognise as either margin. + it("does not apply a page margin function whose subgroup is neither top nor bottom", () => { + const section = sectionOf( + readDocumentArea([ + ...marginFunction(PAGE_GROUP, 0x02, 600), + ...text("x"), + ]), + ); + expect(section.margins).toEqual({ + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }); + }); + + // applyColumnGroup's own left/right dispatch must actually gate on the subgroup, not fall into the right-margin branch for any subgroup it does not recognise as either margin. + it("does not apply a column margin function whose subgroup is neither left nor right", () => { + const section = sectionOf( + readDocumentArea([ + ...marginFunction(COLUMN_GROUP, 0x02, 600), + ...text("x"), + ]), + ); + expect(section.margins).toEqual({ + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }); + }); }); describe("tables", () => { @@ -392,6 +472,161 @@ describe("tables", () => { ); }); + // closeCell's own alignment walk narrows to paragraph blocks before setting alignment; a cell holding a non-paragraph block (a page break, here) alongside its paragraph must leave that other block alone rather than stamping an alignment field onto it too. + it("applies a cell's own justification only to its paragraph blocks, not a page break sharing the cell", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("centred"), + 0xc7, // hard end of page + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + embedded: embeddedSubfunction(CELL_INFORMATION, [ + 0x02, + 0x02, + 0x00, + ...word(0), + ...word(0), + ]), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + const cell = tablesOf(document)[0]?.rows[0]?.cells[0]; + const pageBreak = cell?.blocks.find((block) => block.kind === "pageBreak"); + expect(pageBreak).toBeDefined(); + expect( + pageBreak === undefined ? true : Object.hasOwn(pageBreak, "alignment"), + ).toBe(false); + }); + + it("gives a plain cell and row no optional keys at all, not keys holding undefined", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("plain"), + ...eolFunction({ subgroup: EOL_TABLE_ROW }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + const row = tablesOf(document)[0]?.rows[0]; + const cell = row?.cells[0]; + expect(cell).toBeDefined(); + for (const key of ["colSpan", "rowSpan", "background", "formula"]) { + expect(cell === undefined ? false : Object.hasOwn(cell, key)).toBe(false); + } + expect(row === undefined ? false : Object.hasOwn(row, "heightPt")).toBe( + false, + ); + // closeCell's alignment walk must never run at all for a cell with no stated justification -- not run and assign `undefined`, which the shared schema's own optional field cannot tell apart from "never set". + const paragraph = cell?.blocks[0]; + expect( + paragraph === undefined ? false : Object.hasOwn(paragraph, "alignment"), + ).toBe(false); + }); + + // readCellAttributes only reports a truncated attribute list when the walk actually stopped early; an ordinary cell with a well-formed (or absent) attribute list must never trigger it. + it("does not report a truncated attribute list for a cell with well-formed attributes", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("fine"), + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + embedded: embeddedSubfunction(CELL_SPANNING, [1, 1]), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.TableAttributesTruncated, + ), + ).toBe(false); + }); + + // A table definition the document never fills with a single row is dropped entirely -- an empty grid the author never actually built is not real content. + it("drops a table definition that closes with no rows at all", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + expect(tablesOf(document)).toHaveLength(0); + }); + + // A fixed row height set on an earlier cell within the same row must survive to the row's own close even when a later cell in that row carries no row-information subfunction of its own -- the absence of a later statement is not itself a statement that clears the height. + it("keeps a fixed row height set by an earlier cell once a later cell in the same row states none", () => { + const document = readDocumentArea([ + ...tableDefinition([1200, 1200]), + ...text("A"), + ...eolFunction({ + subgroup: EOL_TABLE_CELL, + embedded: embeddedSubfunction(ROW_INFORMATION, [0x02, ...word(1200)]), + }), + ...text("B"), + ...eolFunction({ subgroup: EOL_TABLE_ROW }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + expect(tablesOf(document)[0]?.rows[0]?.heightPt).toBe(72); + }); + + // A cell boundary must still close a cell whose pending text is empty but whose runs are not (a run already split off by an attribute change) -- checking only pending text and accumulated cell blocks would wrongly drop it, even at Table Off, which otherwise skips closing an already-closed cell. + it("closes a Table Off cell whose pending text is empty but whose runs are not", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("a"), + 0xf2, // ATTRIBUTE_ON (bold), a 3-byte fixed function: gate, attribute id, gate + 12, // BOLD + 0xf2, + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + expect(tablesOf(document)[0]?.rows[0]?.cells.map(cellText)).toEqual(["a"]); + }); + + // A cell boundary must still close a cell whose pending text and runs are both empty but which already holds a flushed paragraph (a hard return inside the cell) -- Table Off otherwise skips closing an already-closed cell, and must not mistake "nothing pending" for "nothing to close". + it("closes a Table Off cell holding only an already-flushed paragraph", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("first"), + HARD_EOL, + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + const cell = tablesOf(document)[0]?.rows[0]?.cells[0]; + expect( + cell?.blocks.map((block) => + block.kind === "paragraph" ? block.runs[0]?.text : undefined, + ), + ).toEqual(["first"]); + }); + + // The definition function is not recursive: an already-open table is closed, and any paragraph mid-flight in the enclosing document is flushed, before a second Table Definition starts a fresh grid. + it("closes an already-open table and flushes its paragraph when a new Table Definition arrives", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("first"), + ...tableDefinition([1200]), + ...text("second"), + ...eolFunction({ subgroup: EOL_TABLE_ROW }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + const tables = tablesOf(document); + expect(tables).toHaveLength(1); + expect(tables[0]?.rows[0]?.cells.map(cellText)).toEqual(["second"]); + expect(paragraphsOf(document).map((p) => p.runs[0]?.text)).toEqual([ + "first", + ]); + }); + + // Define Table End must clear the table's own "still defining columns" flag even when it fires directly rather than through a fresh Table Definition, so a Table Column function appearing after it (a document a hand-edit left in a state the format does not expect) is ignored rather than appended as a genuine extra column. + it("ignores a Table Column function that arrives after Define Table End", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...tableColumn(2400), + ...text("row"), + ...eolFunction({ subgroup: EOL_TABLE_ROW }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]); + expect(tablesOf(document)[0]?.columnWidthsPt).toEqual([72]); + }); + it("reads a fixed row height", () => { const document = readDocumentArea([ ...tableDefinition([1200]), @@ -433,6 +668,19 @@ describe("tables", () => { ]); expect(tablesOf(document)[0]?.rows).toHaveLength(1); }); + + // A row still accumulating closed cells but never itself closed by any EOL boundary before the document area ends must still become a real row -- not vanish along with the whole table, which happens only when it holds zero rows. + it("closes an unfinished row's own already-closed cells when the document area ends", () => { + const document = readDocumentArea([ + ...tableDefinition([1200]), + ...text("A"), + ...eolFunction({ subgroup: EOL_TABLE_CELL }), + ...text("unclosed"), + ]); + const tables = tablesOf(document); + expect(tables).toHaveLength(1); + expect(tables[0]?.rows[0]?.cells.map(cellText)).toEqual(["A"]); + }); }); describe("styles", () => { @@ -465,6 +713,16 @@ describe("styles", () => { expect(paragraphsOf(document)[0]?.headingLevel).toBe(2); }); + // The heading level is captured once, at the paragraph's own first character, and never re-derived from whatever style happens to be active later in the same paragraph -- a second, different structural style opening later must not overwrite it. + it("keeps the first style's own heading level, not a second style's, within one paragraph", () => { + const document = readDocumentArea([ + ...styleScope(70, text("a")), + ...styleScope(69, text("b")), + HARD_EOL, + ]); + expect(paragraphsOf(document)[0]?.headingLevel).toBe(3); + }); + // "52 = level 1 style (indented)" -- an outline level, counted from zero by ContentListMembership. it("reads an outline level style as a list membership", () => { const document = readDocumentArea([ @@ -474,6 +732,18 @@ describe("styles", () => { expect(paragraphsOf(document)[0]?.list).toEqual({ level: 1 }); }); + // Both structural facts (heading level and list membership) are captured together, at the paragraph's first character, from whichever single style is active then -- not independently, each from whatever style happens to be active when its own first non-undefined value shows up. A list style at the first character must keep the paragraph's own list membership even once a later, heading-only style becomes active in the same paragraph. + it("keeps the first style's own list membership once a later style sets a heading instead", () => { + const document = readDocumentArea([ + ...styleScope(53, text("a")), + ...styleScope(68, text("b")), + HARD_EOL, + ]); + const paragraph = paragraphsOf(document)[0]; + expect(paragraph?.list).toEqual({ level: 1 }); + expect(paragraph?.headingLevel).toBeUndefined(); + }); + // An enclosing Global On naming the document's own Normal style must not override a heading opened inside it. it("takes the innermost style that says something structural", () => { const document = readDocumentArea([ @@ -482,6 +752,15 @@ describe("styles", () => { ]); expect(paragraphsOf(document)[0]?.headingLevel).toBe(1); }); + + // The reverse nesting: a structural style opened OUTSIDE a later, transparent one. effectiveStyle's own findLast walk must skip the innermost (Normal) scope, whose semantics are undefined, to reach the outer heading style rather than stopping at the first scope it sees regardless of what it means. + it("reaches past an innermost style with no structural meaning to an outer heading style", () => { + const document = readDocumentArea([ + ...styleScope(68, styleScope(1, text("Heading"))), + HARD_EOL, + ]); + expect(paragraphsOf(document)[0]?.headingLevel).toBe(1); + }); }); describe("outline numbering", () => { @@ -501,15 +780,41 @@ describe("outline numbering", () => { const paragraph = paragraphsOf(document)[0]; expect(paragraph?.list).toEqual({ level: 2 }); expect(paragraph?.runs.map((run) => run.text).join("")).toBe("Item text"); - expect( - diagnostics.some( - (diagnostic) => - diagnostic.code === WpdDiagnosticCodes.OutlineNumberRegenerated, - ), - ).toBe(true); + const found = diagnostics.find( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.OutlineNumberRegenerated, + ); + expect(found?.message).toBe( + "An outline number's rendered digits were replaced by the list membership that regenerates them.", + ); }); // Every other member of the group displays a counter inside running text and carries no structure, so its digits stay exactly where they are. + // applyDisplayNumberGroup's own Off dispatch must actually gate on the subfunction being an Off, not decrement the suppression depth for any subgroup it does not recognise as one -- a page-number-display On (0x04) sits in the very same function group but names none of the paragraph-number On/Off codes. + it("does not end paragraph-number suppression for an unrelated function in the same group", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: DISPLAY_NUMBER_GROUP, + subgroup: 0x0c, + nonDeletable: [0], + }), + ...text("hidden"), + ...variableFunction({ + group: DISPLAY_NUMBER_GROUP, + subgroup: 0x04, // page number display On -- a real function, but not a paragraph-number Off + nonDeletable: [0], + }), + ...text("stillHidden"), + ...variableFunction({ group: DISPLAY_NUMBER_GROUP, subgroup: 0x0d }), + ...text("shown"), + ]); + expect( + paragraphsOf(document)[0] + ?.runs.map((r) => r.text) + .join(""), + ).toBe("shown"); + }); + it("leaves a page number display's own text in place", () => { const document = readDocumentArea([ ...text("page "), @@ -605,18 +910,47 @@ describe("document metadata", () => { it("answers an empty envelope for a document carrying no summary", () => { expect(readDocumentArea(text("body")).metadata).toEqual({}); }); + + // readMetadata's own packet lookup must actually filter on packet type, not just take the first packet in the index -- a document whose summary is not the first packet must still find it. + it("finds the summary packet even when it is not the first packet in the index", () => { + const document = readDocumentArea(text("body"), [ + { packetType: 0x08, bytes: new Uint8Array(0) }, // General WP Text, not a summary + summaryPacket([{ tag: 17, type: 0x01, data: wordString("Found it") }]), + ]); + expect(document.metadata).toEqual({ title: "Found it" }); + }); }); describe("constructs this reader does not lift", () => { // Each of these is recognised by the tokeniser and skipped by the fold, so a document containing it still reads -- and says what it lost rather than passing over it in silence. Group 0xD6 no longer appears here: a header, footer, or watermark function is LIFTED into ContentSection.headers/footers/watermarks (see the page-furniture describe below), and a function whose occurrence bits claim neither parity is suppressed in its own file and lifts nothing with nothing to report. it.each([ - [0xdf, WpdDiagnosticCodes.BoxDropped, 0x00], - [0xd7, WpdDiagnosticCodes.NoteDropped, 0x00], - [0xd5, WpdDiagnosticCodes.CrossReferenceFlattened, 0x00], - [0xde, WpdDiagnosticCodes.MergeCodeDropped, 0x00], + [ + 0xdf, + WpdDiagnosticCodes.BoxDropped, + 0x00, + "This document contains a box -- a figure, text box, equation, or graphic -- whose function-level override names no content this reader can resolve.", + ], + [ + 0xd7, + WpdDiagnosticCodes.NoteDropped, + 0x00, + "This document contains a footnote or endnote whose body packet this reader could not resolve; only its reference text survived.", + ], + [ + 0xd5, + WpdDiagnosticCodes.CrossReferenceFlattened, + 0x00, + "This document contains a cross-reference; its displayed text survives as ordinary text, and the reference's own target binding does not.", + ], + [ + 0xde, + WpdDiagnosticCodes.MergeCodeDropped, + 0x00, + "This document contains merge codes, which are a form-letter template's placeholders rather than text.", + ], ])( "reports group %i through the diagnostic sink", - (group, code, subgroup) => { + (group, code, subgroup, message) => { const { document, diagnostics } = readWithDiagnostics([ ...text("before"), ...variableFunction({ group, subgroup }), @@ -627,9 +961,470 @@ describe("constructs this reader does not lift", () => { ?.runs.map((run) => run.text) .join(""), ).toBe("beforeafter"); - expect( - diagnostics.filter((diagnostic) => diagnostic.code === code), - ).toHaveLength(1); + const matches = diagnostics.filter( + (diagnostic) => diagnostic.code === code, + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toBe(message); }, ); }); + +describe("page geometry margin subgroup isolation", () => { + it("sets only the bottom margin from PAGE_BOTTOM_MARGIN_SET, leaving the top at its default", () => { + const section = sectionOf( + readDocumentArea([ + ...marginFunction(PAGE_GROUP, 0x01, 900), // bottom only + ...text("x"), + ]), + ); + expect(section.margins.bottomPt).toBe(54); + expect(section.margins.topPt).toBe(72); // default, not touched + }); + + it("sets only the right margin from COLUMN_RIGHT_MARGIN_SET, leaving the left at its default", () => { + const section = sectionOf( + readDocumentArea([ + ...marginFunction(COLUMN_GROUP, 0x01, 2400), // right only + ...text("x"), + ]), + ); + expect(section.margins.rightPt).toBe(144); + expect(section.margins.leftPt).toBe(72); // default, not touched + }); + + it("reports the exact PageGeometryChanged message", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile([ + ...marginFunction(PAGE_GROUP, 0x00, 600), + ...text("first"), + HARD_EOL, + ...marginFunction(PAGE_GROUP, 0x00, 2400), + ...text("second"), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.PageGeometryChanged, + ); + expect(found?.message).toBe( + "This document changes its page size or margins partway through; the section carries the geometry the document opens with.", + ); + }); + + it("reports the exact landscape-orientation message", () => { + const { document, diagnostics } = (() => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...pageForm({ lengthWpu: 10200, widthWpu: 13200, orientation: 1 }), + ...text("wide"), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + return { document, diagnostics }; + })(); + void document; + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.LandscapeOrientationUnmapped, + ); + expect(found?.message).toBe( + "The document's form declares a landscape orientation; the form's own stated width and length are used as written, since a page size carries no orientation.", + ); + }); +}); + +describe("table cell attribute gaps", () => { + const CELL_FORMULA = 0x81; + + it("reports a truncated embedded subfunction list with the exact message", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("cell"), + ...variableFunction({ + group: 0xd0, + subgroup: EOL_TABLE_ROW, + // deletableSize word claims 50 bytes of deletable data, but none follow -- overruns the function's own nonDeletable region. + nonDeletable: [...word(50)], + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + void document; + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.TableAttributesTruncated, + ); + expect(found?.message).toBe( + "A cell's embedded attribute list held a record of undocumented length, so the attributes after it were not read.", + ); + }); + + it("reports an unresolved table formula with the exact message, keeping the cell's own text", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("42"), + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + // A formula subfunction whose own token bytes readTableFormula cannot decode with confidence. + embedded: embeddedSubfunction(CELL_FORMULA, [ + ...word(1), + 0xff, // not a recognised formula token code + 0, + 0, + ]), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + void document; + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.TableFormulaUnresolved, + ); + expect(found?.message).toBe( + "A table cell carries a formula this reader could not decode with confidence, so the cell keeps its displayed text but not the formula that produced it.", + ); + }); + + it("carries a resolved table formula onto the cell, reporting nothing", () => { + // A1+B1: a cell reference (code 64, absolute-flag word, row word, column word) for A1, the binary "+" token (1), then the same cell-reference shape for B1 -- the identical byte pattern stream/formula.test.ts proves readTableFormula resolves to "A1+B1" on its own, here wrapped in the embedded subfunction's own leading and trailing length-word framing. + const cellA1 = [64, ...word(0), ...word(0)]; + const cellB1 = [64, ...word(0), ...word(1)]; + const formulaTokens = [...cellA1, 1, ...cellB1]; + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("5"), + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + embedded: embeddedSubfunction(CELL_FORMULA, [ + ...word(formulaTokens.length), + ...formulaTokens, + ...word(formulaTokens.length), + ]), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + const cell = tablesOf(document)[0]?.rows[0]?.cells[0]; + expect(cell?.formula).toBe("A1+B1"); + // A formula that DID resolve must not also trigger the "could not decode with confidence" diagnostic -- the two are mutually exclusive outcomes of the same read. + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.TableFormulaUnresolved, + ), + ).toBe(false); + }); + + it("resolves a blended (pattern) cell fill and reports it", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("shaded"), + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + // foreground (10,20,30) shade 200 (unused), background (0,255,0), background shade 128 -- not FULL_SHADE (255), so the fill blends. + embedded: embeddedSubfunction( + CELL_FILL_COLORS, + [10, 20, 30, 200, 0, 255, 0, 128], + ), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + const cell = tablesOf(document)[0]?.rows[0]?.cells[0]; + expect(cell?.background?.kind).toBe("pattern"); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.CellFillBlended, + ); + expect(found?.message).toBe( + "A cell is filled with a shaded blend of two colours, resolved to a 'pattern' fill whose density is this reader's own best-effort derivation, not a value confirmed against a specification.", + ); + }); + + it("does not report an unresolved formula for a cell that carries no formula subfunction at all", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("plain"), + ...eolFunction({ subgroup: EOL_TABLE_ROW }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.TableFormulaUnresolved, + ), + ).toBe(false); + }); + + it("does not report a blended fill for a cell with a full-shade (solid) fill", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...tableDefinition([1200]), + ...text("solid"), + ...eolFunction({ + subgroup: EOL_TABLE_ROW, + // foreground unused (shade 0), background (0,0,255) at FULL_SHADE (255) -- a plain solid fill, not a blend. + embedded: embeddedSubfunction( + CELL_FILL_COLORS, + [0, 0, 0, 0, 0, 0, 255, 255], + ), + }), + ...eolFunction({ subgroup: EOL_TABLE_OFF }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + const cell = tablesOf(document)[0]?.rows[0]?.cells[0]; + expect(cell?.background?.kind).toBe("solid"); + expect( + diagnostics.some((d) => d.code === WpdDiagnosticCodes.CellFillBlended), + ).toBe(false); + }); +}); + +describe("style resolution depth and scope handling", () => { + const GLOBAL_ON = 0x0a; + const GLOBAL_OFF = 0x0b; + const NORMAL_STYLE_PACKET_TYPE = 0x30; + const NO_SYSTEM_STYLE = 0xff; + + function normalStylePacket( + prefixIdOfNextStyle: number | undefined, + beginBytes: readonly number[], + ) { + const headerSize = 2 + 2 + 16; + const bytes = new Uint8Array(headerSize + beginBytes.length); + bytes[2] = 4; + const putUint32 = (offset: number, value: number) => { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; + bytes[offset + 3] = (value >>> 24) & 0xff; + }; + putUint32(4, headerSize); + putUint32(8, 0); + putUint32(12, beginBytes.length); + bytes.set(beginBytes, headerSize); + void prefixIdOfNextStyle; + return { packetType: NORMAL_STYLE_PACKET_TYPE, bytes }; + } + + // A style whose own begin block opens ANOTHER style scope (naming the same packet again, at a fresh prefix ID) recurses through applyStylePacketBegin; repeating that packet at every depth walks past MAX_STYLE_RESOLUTION_DEPTH (16) on genuinely self-referential input. + it("stops resolving a style chain deeper than MAX_STYLE_RESOLUTION_DEPTH and reports it", () => { + const prefixIds = Array.from({ length: 20 }, (_, i) => i + 1); + const packets = prefixIds.map((id) => + normalStylePacket( + id, + variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [id + 1], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ), + ); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [ + ...variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [1], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ...text("deep"), + ], + packets, + ), + { sink: (d) => diagnostics.push(d) }, + ); + void document; + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.StyleResolutionDepthExceeded, + ); + expect(found?.message).toBe( + "A chain of styles resolving one another's own packets ran deeper than this reader will follow, so the deepest style's own direct formatting was not applied.", + ); + }); + + // Exactly MAX_STYLE_RESOLUTION_DEPTH (16) successful recursions must leave the 17th attempt refused: a chain one level too shallow to force a refusal under `>` (which would only trigger once depth genuinely exceeds 16) must trigger the guard under the real `>=` boundary. With a chain of exactly 17 style packets and nothing left to recurse into after the 17th, an off-by-one guard would let the whole chain resolve and never report anything at all. + it("refuses exactly the chain's 17th style recursion, not the 18th", () => { + const depth = 17; + const prefixIds = Array.from({ length: depth }, (_, i) => i + 1); + const packets = prefixIds.map((id) => + normalStylePacket( + id, + id === depth + ? variableFunction({ + group: CHARACTER_GROUP, + subgroup: 0x1b, // a font size change: a real, non-empty begin block that opens no further style -- nothing left to over-recurse into + nonDeletable: [0x58, 0x02, 0, 0, 0, 0, 0, 0], + }) + : variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [id + 1], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ), + ); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [ + ...variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [1], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ...text("deep"), + ], + packets, + ), + { sink: (d) => diagnostics.push(d) }, + ); + expect( + diagnostics.filter( + (d) => d.code === WpdDiagnosticCodes.StyleResolutionDepthExceeded, + ), + ).toHaveLength(1); + }); + + // The resolution depth counter must return to its starting value once a style's own begin block finishes resolving, not keep climbing -- otherwise a long enough run of entirely separate, non-nested style scopes would eventually (and wrongly) trip the same depth guard a genuinely self-referential chain trips. + it("never accumulates resolution depth across sibling, non-nested style scopes", () => { + const siblingCount = 9; // enough that a counter incrementing instead of decrementing after each one would cross MAX_STYLE_RESOLUTION_DEPTH (16) + const prefixIds = Array.from({ length: siblingCount }, (_, i) => i + 1); + const packets = prefixIds.map((id) => + normalStylePacket( + id, + variableFunction({ + group: CHARACTER_GROUP, + subgroup: 0x1b, // a font size change: real, harmless direct formatting that opens no further style + nonDeletable: [0x58, 0x02, 0, 0, 0, 0, 0, 0], + }), + ), + ); + const documentArea = prefixIds.flatMap((id) => [ + ...variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [id], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ...variableFunction({ group: STYLE_GROUP, subgroup: GLOBAL_OFF }), + ]); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent(buildWpdFile([...documentArea, ...text("done")], packets), { + sink: (d) => diagnostics.push(d), + }); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.StyleResolutionDepthExceeded, + ), + ).toBe(false); + }); + + // The four intermediate style subfunctions (per style.test.ts: 1, 2, 5, 6, 7, 8) delimit the style's own before/after codes but neither open nor close a scope -- one arriving mid-scope must not be mistaken for the scope's own closer. + it("does not close a style scope on an intermediate subfunction", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [1], + nonDeletable: [0, 0, 68], // heading level 1 + }), + ...variableFunction({ group: STYLE_GROUP, subgroup: 1 }), // intermediate, neither opener nor closer + ...text("Title"), + HARD_EOL, + ]); + expect(paragraphsOf(document)[0]?.headingLevel).toBe(1); + }); + + // restoreFormattingSnapshot's own for-of loop must carry every attribute the snapshot held, not just the first: bold AND italic both open before the style scope, the style's own begin block changes neither, and both must survive the scope's close. + it("restores every active attribute the snapshot held, not only one", () => { + const document = readDocumentArea( + [ + 0xf2, + 12, + 0xf2, // bold on (ATTRIBUTE_ON, BOLD, ATTRIBUTE_ON) + 0xf2, + 8, + 0xf2, // italic on (ATTRIBUTE_ON, ITALICS, ATTRIBUTE_ON) + ...variableFunction({ + group: STYLE_GROUP, + subgroup: GLOBAL_ON, + prefixIds: [1], + nonDeletable: [0, 0, NO_SYSTEM_STYLE], + }), + ...text("styled"), + ...variableFunction({ group: STYLE_GROUP, subgroup: GLOBAL_OFF }), + ...text("after"), + ], + [normalStylePacket(undefined, [0xf2, 14, 0xf2])], // begin block turns on underline too + ); + const runs = paragraphsOf(document)[0]?.runs; + expect(runs?.[0]).toEqual({ + text: "styled", + bold: true, + italic: true, + underline: true, + }); + expect(runs?.[1]).toEqual({ text: "after", bold: true, italic: true }); + }); +}); + +describe("outline numbering gaps", () => { + it("keeps the first paragraph number display's level when a second one arrives before it closes", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: DISPLAY_NUMBER_GROUP, + subgroup: 0x0c, + nonDeletable: [1], + }), + ...variableFunction({ + group: DISPLAY_NUMBER_GROUP, + subgroup: 0x0c, + nonDeletable: [5], // a second On, nested -- must not overwrite the first level + }), + ...text("Item"), + HARD_EOL, + ]); + expect(paragraphsOf(document)[0]?.list).toEqual({ level: 1 }); + }); + + it("does not let numberDisplayDepth go negative, which would wrongly suppress later text", () => { + const document = readDocumentArea([ + ...variableFunction({ group: DISPLAY_NUMBER_GROUP, subgroup: 0x0d }), // Off with no matching On + ...variableFunction({ group: DISPLAY_NUMBER_GROUP, subgroup: 0x0d }), // a second stray Off + ...variableFunction({ + group: DISPLAY_NUMBER_GROUP, + subgroup: 0x0c, + nonDeletable: [0], + }), // On: depth must become exactly 1, not climb out of a negative hole + ...text("hidden"), + ...variableFunction({ group: DISPLAY_NUMBER_GROUP, subgroup: 0x0d }), + ...text("shown"), + ]); + expect( + paragraphsOf(document)[0] + ?.runs.map((r) => r.text) + .join(""), + ).toBe("shown"); + }); +}); diff --git a/packages/wpd-codec/src/read.test.ts b/packages/wpd-codec/src/read.test.ts index 972dea626b..f99b5a988f 100644 --- a/packages/wpd-codec/src/read.test.ts +++ b/packages/wpd-codec/src/read.test.ts @@ -6,7 +6,12 @@ import type { import { bytesToBase64 } from "./bytes/base64"; import { describe, expect, it } from "vitest"; import { WpdDiagnosticCodes, type WpdDiagnostic } from "./diagnostics"; -import { readWpd, readWpdContent } from "./read"; +import { + assertDefined, + readWpd, + readWpdContent, + UNREACHABLE_CHARACTER_MAPPING_MESSAGE, +} from "./read"; import { buildWpdFile, fontDescriptorPacket, @@ -27,6 +32,7 @@ const ATTRIBUTE_OFF = 0xf3; const BOLD = 12; const ITALICS = 8; const UNDERLINE = 14; +const STRIKEOUT = 13; const DOUBLE_UNDERLINE = 11; const SMALL_CAPS = 15; @@ -46,6 +52,30 @@ function readDocumentArea( return readWpdContent(buildWpdFile(documentArea, packets)); } +describe("assertDefined", () => { + it("throws with the exact given message for an undefined value", () => { + expect(() => { + assertDefined(undefined, "should not be undefined"); + }).toThrow("should not be undefined"); + }); + + // UNREACHABLE_CHARACTER_MAPPING_MESSAGE's own exact text, asserted against a hardcoded duplicate rather than by importing and comparing the constant to itself -- no real document byte can ever trigger this message at its one call site (applyToken's "character" case), so this is the only test that can catch a change to its actual wording. + it("carries UNREACHABLE_CHARACTER_MAPPING_MESSAGE's own exact text", () => { + expect(UNREACHABLE_CHARACTER_MAPPING_MESSAGE).toBe( + "A single-byte document-area character had no character mapping, which the tokeniser's own byte range should make unreachable.", + ); + }); + + it("does not throw for a defined value, including a falsy one", () => { + expect(() => { + assertDefined(0, "unreachable"); + }).not.toThrow(); + expect(() => { + assertDefined("", "unreachable"); + }).not.toThrow(); + }); +}); + describe("readWpdContent", () => { it("reads a wordprocessing document", () => { const document = readDocumentArea(text("Hello")); @@ -64,6 +94,20 @@ describe("readWpdContent", () => { ]); }); + // A hard return's own case must actually end there in the switch, not fall through into the next case (hardEndOfColumn) and report a column-break diagnostic that never happened. + it("does not report a column break for a plain hard end of line", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile([...text("First"), HARD_EOL, ...text("Second")]), + { sink: (d) => diagnostics.push(d) }, + ); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.ColumnBreakFlattened, + ), + ).toBe(false); + }); + // "Soft EOL: The formatter inserts a code at the end of a line. Its position changes automatically as text is added or deleted", and the End-of-Line group's own conversion table maps it to a space rather than a break. it("turns a soft end of line into a space within one paragraph", () => { const document = readDocumentArea([ @@ -138,12 +182,50 @@ describe("readWpdContent", () => { it("renders an unmapped character visibly and reports it", () => { const diagnostics: WpdDiagnostic[] = []; // Character 0 of set 12 (Tibetan): libwpd's own tibetanMap1 table has no entry below character number 33, so this position genuinely has no mapping in the cited source rather than being a gap this package introduced. - readWpdContent(buildWpdFile([...text("x"), 0xf0, 0, 12, 0xf0]), { - sink: (diagnostic) => diagnostics.push(diagnostic), - }); - expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( - WpdDiagnosticCodes.UnmappedCharacter, + const document = readWpdContent( + buildWpdFile([...text("x"), 0xf0, 0, 12, 0xf0]), + { sink: (diagnostic) => diagnostics.push(diagnostic) }, ); + expect(paragraphsOf(document)[0]?.runs[0]?.text).toBe("x�"); + const found = diagnostics.find( + (diagnostic) => diagnostic.code === WpdDiagnosticCodes.UnmappedCharacter, + ); + expect(found?.message).toBe( + "Character 0 of WordPerfect character set 12 has no mapping in this package and was rendered as U+FFFD.", + ); + }); + + // A fixed-length function code this reader names no specific meaning for at all -- Undo (0xF1), reserved by the format but not one applyFixedFunction handles -- must contribute neither a character nor an attribute change. + it("contributes nothing for a fixed-length function code with no named meaning", () => { + const document = readDocumentArea([ + ...text("un"), + 0xf1, + 0, + 0, + 0, + 0xf1, // Undo: a genuine 5-byte fixed function, gated at both ends + ...text("broken"), + ]); + expect(paragraphsOf(document)[0]?.runs).toEqual([{ text: "unbroken" }]); + }); + + // A fixed-length function code with no named meaning must not be misread as an ATTRIBUTE_ON/OFF payload even when its own data byte happens to look like a real attribute number: since its own code is neither ATTRIBUTE_ON nor ATTRIBUTE_OFF, misreading it would take the ATTRIBUTE_OFF branch (deleting the attribute) regardless of which real code opened it, silently turning bold back off. + it("does not clear an active attribute for a fixed-length function code with no named meaning", () => { + const document = readDocumentArea([ + 0xf2, // ATTRIBUTE_ON (bold) + 12, + 0xf2, + ...text("before"), + 0xf1, + 12, // BOLD's own attribute number, in a code this reader does not treat as an attribute code at all + 0, + 0, + 0xf1, // Undo: a genuine 5-byte fixed function, gated at both ends + ...text("after"), + ]); + expect(paragraphsOf(document)[0]?.runs).toEqual([ + { text: "beforeafter", bold: true }, + ]); }); it("splits runs at an attribute boundary", () => { @@ -228,6 +310,45 @@ describe("readWpdContent", () => { expect(paragraphsOf(document)[0]?.runs).toEqual([{ text: "ab" }]); }); + it("splits a run at strikeout, the same way as the other boolean attributes", () => { + const document = readDocumentArea([ + ...text("a"), + ATTRIBUTE_ON, + STRIKEOUT, + ATTRIBUTE_ON, + ...text("b"), + ATTRIBUTE_OFF, + STRIKEOUT, + ATTRIBUTE_OFF, + ...text("c"), + ]); + expect(paragraphsOf(document)[0]?.runs).toEqual([ + { text: "a" }, + { text: "b", strike: true }, + { text: "c" }, + ]); + }); + + it("gives a plain run no optional keys at all, not keys holding undefined", () => { + const document = readDocumentArea([...text("plain")]); + const run = paragraphsOf(document)[0]?.runs[0]; + expect(run).toBeDefined(); + for (const key of ["strike", "fontFamily", "sizePt", "color"]) { + expect(run === undefined ? false : Object.hasOwn(run, key)).toBe(false); + } + }); + + it("gives a plain paragraph no optional keys at all, not keys holding undefined", () => { + const document = readDocumentArea([...text("plain")]); + const paragraph = paragraphsOf(document)[0]; + expect(paragraph).toBeDefined(); + for (const key of ["alignment", "headingLevel", "list", "constructs"]) { + expect( + paragraph === undefined ? false : Object.hasOwn(paragraph, key), + ).toBe(false); + } + }); + // "The surrounded text is passed over by the formatter and is not displayed." it("drops text between the Start and End of Text to Skip pair", () => { const document = readDocumentArea([ @@ -240,6 +361,43 @@ describe("readWpdContent", () => { expect(paragraphsOf(document)[0]?.runs[0]?.text).toBe("keepkeep"); }); + // An End of Text to Skip with no matching Start (a stray or duplicated code, possible in a document edited by a third-party writer) must not drive the skip depth negative: clamping at zero means the very next Start still raises it to exactly one, so the region it opens is skipped as normal. Without the clamp, an unmatched End would leave the depth one lower than it should be, and the following Start/End pair's own text would wrongly leak into the document instead of being dropped. + it("clamps skip depth at zero so an unmatched End of Text to Skip cannot leak a later skip region's text", () => { + const document = readDocumentArea([ + ...text("keep"), + 0x8d, // START_OF_TEXT_TO_SKIP + ...text("drop"), + 0x8e, // END_OF_TEXT_TO_SKIP -- balances the Start above + 0x8e, // an extra, unmatched END_OF_TEXT_TO_SKIP + 0x8d, // START_OF_TEXT_TO_SKIP again + ...text("hidden"), + 0x8e, // END_OF_TEXT_TO_SKIP + ...text("keep"), + ]); + expect(paragraphsOf(document)[0]?.runs[0]?.text).toBe("keepkeep"); + }); + + // A font face change must split off whatever text already accumulated before it into its own run, so that earlier text keeps its own (absent) font family rather than being retroactively folded into the new one. + it("splits the run at a font face change, leaving earlier text without the new font family", () => { + const document = readDocumentArea( + [ + ...text("before"), + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [1], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("after"), + ], + [fontDescriptorPacket("Courier New")], + ); + expect(paragraphsOf(document)[0]?.runs).toEqual([ + { text: "before" }, + { text: "after", fontFamily: "Courier New" }, + ]); + }); + it("takes a run's font family from the descriptor packet a font face change names", () => { const document = readDocumentArea( [ @@ -275,19 +433,106 @@ describe("readWpdContent", () => { }); }); + it("splits the run at a font size change, leaving earlier text without the new size", () => { + const document = readDocumentArea([ + ...text("before"), + ...variableFunction({ + group: 0xd4, + subgroup: 0x1b, + nonDeletable: [0x58, 0x02, 0, 0, 0, 0, 0, 0], + }), + ...text("after"), + ]); + expect(paragraphsOf(document)[0]?.runs).toEqual([ + { text: "before" }, + { text: "after", sizePt: 12 }, + ]); + }); + + it("ignores a font size change whose non-deletable data is too short to hold a size word", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1b, + nonDeletable: [0x58], // one byte -- not enough for the size word + }), + ...text("sized"), + ]); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ text: "sized" }); + }); + + it("reads a font size change whose non-deletable data is exactly the size word's own length", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1b, + nonDeletable: [0x58, 0x02], // exactly two bytes, the size word itself and nothing more + }), + ...text("sized"), + ]); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ + text: "sized", + sizePt: 12, + }); + }); + + it("ignores a font size change of exactly zero points", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1b, + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("sized"), + ]); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ text: "sized" }); + }); + + it("splits the run at a character colour change, leaving earlier text without the new colour", () => { + const document = readDocumentArea([ + ...text("before"), + ...variableFunction({ + group: 0xd4, + subgroup: 0x18, + nonDeletable: [102, 51, 204], + }), + ...text("after"), + ]); + expect(paragraphsOf(document)[0]?.runs).toEqual([ + { text: "before" }, + { + text: "after", + color: { r: 102 / 255, g: 51 / 255, b: 204 / 255 }, + }, + ]); + }); + + // applyCharacterGroup's own switch must fall through its default case, contributing nothing, for a character-group subgroup this reader names no handling for at all. + it("contributes nothing for a character-group subgroup with no named case", () => { + const document = readDocumentArea([ + ...text("un"), + ...variableFunction({ group: 0xd4, subgroup: 0x19 }), // an unassigned character-group subgroup + ...text("broken"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("unbroken"); + }); + it("reads a character colour change", () => { const document = readDocumentArea([ ...variableFunction({ group: 0xd4, subgroup: 0x18, - nonDeletable: [255, 0, 0], + // A distinct, non-zero, non-255 value on every channel: 0 or 255 would divide to the same result a stray multiplication would give. + nonDeletable: [102, 51, 204], }), - ...text("red"), + ...text("mix"), ]); expect(paragraphsOf(document)[0]?.runs[0]?.color).toEqual({ - r: 1, - g: 0, - b: 0, + r: 102 / 255, + g: 51 / 255, + b: 204 / 255, }); }); @@ -303,6 +548,19 @@ describe("readWpdContent", () => { expect(paragraphsOf(document)[0]?.alignment).toBe("center"); }); + // applyVariableFunction's own paragraph-group dispatch must actually gate on the subgroup being PARAGRAPH_SET_JUSTIFICATION -- a different subfunction in the same group, even one whose own first byte happens to look like a justification mode, must not be misread as one. + it("does not apply a justification change for an unrelated paragraph-group subfunction", () => { + const document = readDocumentArea([ + ...variableFunction({ + group: 0xd3, + subgroup: 0x01, // not PARAGRAPH_SET_JUSTIFICATION + nonDeletable: [2], // happens to look like "center" if misread as a justification mode + }), + ...text("plain"), + ]); + expect(paragraphsOf(document)[0]?.alignment).toBeUndefined(); + }); + // "Subfunctions 0 to 28 (0x1C) of this group are interchangeable with the single-byte function codes 180 (0xB4) to 207 (0xCF) ... A program reading WP 7.0 documents must handle both." it("handles the multi-byte spelling of a hard end of line", () => { const document = readDocumentArea([ @@ -316,7 +574,142 @@ describe("readWpdContent", () => { ]); }); + // applyVariableFunction's own group dispatch must fall through its default case, contributing nothing, for a variable-function group this reader names no handling for at all. + it("contributes nothing for a variable-function group with no named case", () => { + const document = readDocumentArea([ + ...text("un"), + ...variableFunction({ group: 0xd8, subgroup: 0 }), // an unassigned variable-function group + ...text("broken"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("unbroken"); + }); + + // Subfunction 0, Beginning of File, is the one End-of-Line subfunction with no single-byte spelling at all -- it exists solely as this group's own subgroup 0 -- and the SDK's own conversion table maps it to nothing: it contributes neither a character nor a paragraph break. + it("ignores the Beginning-of-File End-of-Line subfunction, reachable only through its multi-byte spelling", () => { + const document = readDocumentArea([ + ...text("before"), + ...variableFunction({ group: 0xd0, subgroup: 0 }), + ...text("after"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("beforeafter"); + }); + + // The shared content schema has no column-break block, so a hard end of column becomes a paragraph break instead, and the diagnostic sink is told exactly what was flattened away. + it("reports a column break becoming a paragraph break", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...text("first"), + ...variableFunction({ group: 0xd0, subgroup: 7 }), + ...text("second"), + ]), + { sink: (diagnostic) => diagnostics.push(diagnostic) }, + ); + expect(paragraphsOf(document).map((p) => p.runs[0]?.text)).toEqual([ + "first", + "second", + ]); + const found = diagnostics.find( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.ColumnBreakFlattened, + ); + expect(found?.message).toBe("A column break became a paragraph break."); + }); + + // "Both mark a permitted break point that is not currently taken, and neither shows a character": the invisible return contributes no text and does not split the run it sits in, exactly like the soft hyphen it is documented alongside. + it("contributes nothing for an invisible return in line", () => { + const document = readDocumentArea([ + ...text("un"), + 0x86, // INVISIBLE_RETURN_IN_LINE + ...text("broken"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("unbroken"); + }); + + // "An auto-hyphen was inserted by the formatter at the end of a line" -- displayed exactly like the other end-of-line hyphen functions. + it("appends a hyphen for an auto-hyphen at the end of a line", () => { + const document = readDocumentArea([ + ...text("auto"), + 0x85, // AUTO_HYPHEN_AT_END_OF_LINE + ...text("mated"), + ]); + expect(paragraphsOf(document)[0]?.runs[0]?.text).toBe("auto-mated"); + }); + + // "Whenever a [HRt] code appears alone at the top of a page that starts with a soft page break, the formatter changes the Hard Return code into a Dormant Hard Return code." The paragraph boundary the author typed is still there, so it still closes the paragraph. + it("splits paragraphs at a dormant hard return", () => { + const document = readDocumentArea([ + ...text("first"), + 0x87, // DORMANT_HARD_RETURN + ...text("second"), + ]); + expect(paragraphsOf(document).map((p) => p.runs[0]?.text)).toEqual([ + "first", + "second", + ]); + }); + + // "The formatter inserts a soft End of Line, which causes centering to end, but not the paragraph" -- a wrap, so it becomes the same space every other soft end of line converts to. + it("appends a space for a soft end of center align", () => { + const document = readDocumentArea([ + ...text("centred"), + 0x88, // SOFT_END_OF_CENTER_ALIGN + ...text("text"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("centred text"); + }); + + // "The Enter key is pressed, ending the line, the centering, and the paragraph." + it("splits paragraphs at a hard end of center align", () => { + const document = readDocumentArea([ + ...text("first"), + 0x89, // HARD_END_OF_CENTER_ALIGN + ...text("second"), + ]); + expect(paragraphsOf(document).map((p) => p.runs[0]?.text)).toEqual([ + "first", + "second", + ]); + }); + + // A single-byte function code this switch names no case for at all -- one of the format's own formatting/bookkeeping markers this reader has no specific behaviour for -- must fall through to the default case and contribute neither characters nor structure, exactly like the codes with an explicit no-op case. + it("contributes nothing for a single-byte function code with no named case", () => { + const document = readDocumentArea([ + ...text("un"), + 0x8a, // an unassigned single-byte function code between INVISIBLE_RETURN_IN_LINE (0x86) and START_OF_TEXT_TO_SKIP (0x8d) + ...text("broken"), + ]); + const paragraphs = paragraphsOf(document); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]?.runs[0]?.text).toBe("unbroken"); + }); + // A cell or row boundary with no Table Definition open has no grid to belong to, which a stray code left behind by an edit can produce. The text on either side still survives as paragraphs, in reading order. + // flushParagraphIfContent must still flush when the pending text is empty but a run has already been split off it (here, by an attribute change) -- checking only state.text.length would wrongly drop that already-built run. + it("flushes a paragraph at a boundary whose pending text is empty but whose runs are not", () => { + const document = readDocumentArea([ + ...text("plain"), + ATTRIBUTE_ON, + BOLD, + ATTRIBUTE_ON, + 0xc6, + ...text("next"), + 0xbf, + ]); + expect(paragraphsOf(document).map((p) => p.runs[0]?.text)).toEqual([ + "plain", + "next", + ]); + }); + it("flattens an orphaned cell boundary into paragraphs and says so", () => { const diagnostics: WpdDiagnostic[] = []; const document = readWpdContent( @@ -327,11 +720,13 @@ describe("readWpdContent", () => { "cell", "next", ]); - expect( - diagnostics.filter( - (diagnostic) => diagnostic.code === WpdDiagnosticCodes.TableFlattened, - ), - ).toHaveLength(1); + const matches = diagnostics.filter( + (diagnostic) => diagnostic.code === WpdDiagnosticCodes.TableFlattened, + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toBe( + "A table cell or row boundary appeared with no table definition open; its text became a paragraph.", + ); }); // The same document in both containers must read identically: a WordPerfect 6.x file writes the byte stream straight to disk, and WP7 onwards may wrap the identical stream in an OLE compound file. @@ -342,23 +737,154 @@ describe("readWpdContent", () => { ).toEqual(readWpdContent(bare)); }); - describe("style packet resolution", () => { - const GLOBAL_ON = 0x0a; - const GLOBAL_OFF = 0x0b; - const STYLE_GROUP = 0xdd; - const NORMAL_STYLE_PACKET_TYPE = 0x30; - // No system style number, so styleSemanticsFor contributes nothing -- isolating the packet's own direct-formatting effect from the heading/list mapping a system style number would otherwise add. - const NO_SYSTEM_STYLE = 0xff; - - // A Normal Style packet (type 0x30) carrying no link PID and a "beginning style text" block of the given raw document-area bytes, laid out exactly as WPFF Prefix Packet Type 48 states. - function normalStylePacket(beginBytes: readonly number[]) { - const headerSize = 2 + 2 + 16; // [pid count=0] [numTextBlocks=4] then four 32-bit sizes/offsets - const bytes = new Uint8Array(headerSize + beginBytes.length); - // pid count = 0, number of text blocks = 4 - bytes[2] = 4; - const putUint32 = (offset: number, value: number) => { - bytes[offset] = value & 0xff; - bytes[offset + 1] = (value >>> 8) & 0xff; + it("abandons a footnote left open across a paragraph boundary and reports it", () => { + const diagnostics: WpdDiagnostic[] = []; + const bytes = buildWpdFile([ + ...variableFunction({ group: 0xd7, subgroup: 0x00, prefixIds: [1] }), // FOOTNOTE_ON + ...text("1"), + HARD_EOL, + ...text("next"), + ...variableFunction({ group: 0xd7, subgroup: 0x01 }), // FOOTNOTE_OFF + ]); + const document = readWpdContent(bytes, { + sink: (d) => diagnostics.push(d), + }); + const paragraphs = paragraphsOf(document); + expect( + paragraphs.every((paragraph) => paragraph.constructs === undefined), + ).toBe(true); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.NoteSpansParagraphs, + ); + expect(found?.message).toBe( + "A footnote or endnote's own On/Off pair straddled a paragraph boundary, which the run-scoped note anchor cannot express; its reference text became ordinary paragraph text with no note anchor.", + ); + }); + + it("reports the exact missing-prefix-packet message for a font face change naming an unknown prefix ID", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile([ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [7], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("plain"), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.MissingPrefixPacket, + ); + expect(found?.message).toBe( + "A font face change names prefix ID 7, which this document's index does not carry.", + ); + }); + + it("does not apply a font face change when the named packet is not a font descriptor", () => { + const document = readDocumentArea( + [ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [1], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("plain"), + ], + [{ packetType: 0x08, bytes: new Uint8Array(0) }], // General WP Text, not a font descriptor + ); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ text: "plain" }); + }); + + it("does not apply a font face change when the descriptor packet's own typeface name cannot be read", () => { + const document = readDocumentArea( + [ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [1], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("plain"), + ], + [{ packetType: 0x55, bytes: new Uint8Array(0) }], // font descriptor packet type, but too short for a typeface name + ); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ text: "plain" }); + }); + + // The packet-type check must actually gate the read, not just happen to agree with it: a packet whose own bytes would decode as a valid typeface if read as a font descriptor, but which is not one, must not have its bytes read that way at all. + it("does not read a non-font-descriptor packet's bytes as a typeface even when they would decode as one", () => { + const descriptorShapedBytes = fontDescriptorPacket("Courier New").bytes; + const document = readDocumentArea( + [ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [1], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("plain"), + ], + [{ packetType: 0x08, bytes: descriptorShapedBytes }], // General WP Text, not a font descriptor, despite the descriptor-shaped bytes + ); + expect(paragraphsOf(document)[0]?.runs[0]).toEqual({ text: "plain" }); + }); + + // A font face change that names an unreadable typeface must leave a PREVIOUSLY set font family in place for the run it starts, rather than clearing it -- the failed change contributes nothing, it does not reset what came before it. + it("keeps a previously set font family when a later font face change cannot be read", () => { + const document = readDocumentArea( + [ + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [1], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("first"), + 0xf2, // ATTRIBUTE_ON (bold), forcing a run split independent of the font logic under test + 12, // BOLD + 0xf2, + ...variableFunction({ + group: 0xd4, + subgroup: 0x1a, + prefixIds: [2], + nonDeletable: [0, 0, 0, 0, 0, 0, 0, 0], + }), + ...text("second"), + ], + [ + fontDescriptorPacket("Georgia"), + { packetType: 0x55, bytes: new Uint8Array(0) }, // font descriptor packet type, but too short for a typeface name + ], + ); + expect( + paragraphsOf(document)[0]?.runs.map((run) => [run.text, run.fontFamily]), + ).toEqual([ + ["first", "Georgia"], + ["second", "Georgia"], + ]); + }); + + describe("style packet resolution", () => { + const GLOBAL_ON = 0x0a; + const GLOBAL_OFF = 0x0b; + const STYLE_GROUP = 0xdd; + const NORMAL_STYLE_PACKET_TYPE = 0x30; + // No system style number, so styleSemanticsFor contributes nothing -- isolating the packet's own direct-formatting effect from the heading/list mapping a system style number would otherwise add. + const NO_SYSTEM_STYLE = 0xff; + + // A Normal Style packet (type 0x30) carrying no link PID and a "beginning style text" block of the given raw document-area bytes, laid out exactly as WPFF Prefix Packet Type 48 states. + function normalStylePacket(beginBytes: readonly number[]) { + const headerSize = 2 + 2 + 16; // [pid count=0] [numTextBlocks=4] then four 32-bit sizes/offsets + const bytes = new Uint8Array(headerSize + beginBytes.length); + // pid count = 0, number of text blocks = 4 + bytes[2] = 4; + const putUint32 = (offset: number, value: number) => { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; bytes[offset + 2] = (value >>> 16) & 0xff; bytes[offset + 3] = (value >>> 24) & 0xff; }; @@ -453,6 +979,23 @@ describe("readWpdContent", () => { ]); }); + it("joins a field instruction split across more than one run with no separator", () => { + const document = readDocumentArea([ + ...variableFunction({ group: MERGE_GROUP, subgroup: FIELD_ON }), + ...text("Company"), + 0xf2, // ATTRIBUTE_ON (bold), splitting the instruction across two runs + 12, // BOLD + 0xf2, + ...text("Name"), + ...variableFunction({ group: MERGE_GROUP, subgroup: FIELD_OFF }), + ]); + const construct = paragraphsOf(document)[0]?.constructs?.[0]?.descriptor; + expect(construct).toEqual({ + kind: "field", + instruction: "CompanyName", + }); + }); + it("reports every other merge subfunction through the diagnostic sink, unchanged", () => { const diagnostics: WpdDiagnostic[] = []; const bytes = buildWpdFile([ @@ -484,12 +1027,14 @@ describe("readWpdContent", () => { expect( paragraphs.every((paragraph) => paragraph.constructs === undefined), ).toBe(true); - expect( - diagnostics.filter( - (diagnostic) => - diagnostic.code === WpdDiagnosticCodes.MergeFieldSpansParagraphs, - ), - ).toHaveLength(1); + const matches = diagnostics.filter( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.MergeFieldSpansParagraphs, + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toBe( + "A merge field's own On/Off pair straddled a paragraph boundary, which the run-scoped field construct cannot express; its text became ordinary paragraph text with no field tag.", + ); }); }); }); @@ -499,12 +1044,75 @@ describe("readWpd", () => { const tree = readWpd(buildWpdFile(text("Hello"))); expect(tree.kind).toBe("wordprocessing"); }); + + it("gives a plain document's own section no headers, footers, or watermarks keys at all", () => { + const tree = readWpd(buildWpdFile(text("plain"))); + if (tree.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const section = tree.children[0]?.node; + expect(section).toBeDefined(); + for (const key of ["headers", "footers", "watermarks"]) { + expect(section === undefined ? false : Object.hasOwn(section, key)).toBe( + false, + ); + } + }); + + // The tree-form section must actually carry a header, footer, and watermark when the document declares them -- proving readWpd's own headers/footers/watermarks spreads fire when non-empty, not just that they stay absent when empty. + it("carries a header, footer, and watermark on the tree-form section", () => { + function furniturePacket(text_: string) { + const documentArea = text(text_); + const header = [ + 1, + 0, + 6, + 0, + documentArea.length & 0xff, + (documentArea.length >>> 8) & 0xff, + ]; + return { + packetType: 0x08, + bytes: new Uint8Array([...header, ...documentArea]), + }; + } + function furnitureFunction(subgroup: number, prefixId: number): number[] { + return variableFunction({ + group: 0xd6, + subgroup, + prefixIds: [prefixId], + nonDeletable: [1, 0], // occurrence: odd/default pages + }); + } + const tree = readWpd( + buildWpdFile( + [ + ...text("body"), + ...furnitureFunction(0x00, 1), // header + ...furnitureFunction(0x02, 2), // footer + ...furnitureFunction(0x04, 3), // watermark + ], + [furniturePacket("H"), furniturePacket("F"), furniturePacket("W")], + ), + ); + if (tree.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const section = tree.children[0]?.node; + if (section?.kind !== "section") { + throw new Error("expected a section node"); + } + expect(Object.hasOwn(section, "headers")).toBe(true); + expect(Object.hasOwn(section, "footers")).toBe(true); + expect(Object.hasOwn(section, "watermarks")).toBe(true); + }); }); describe("boxes", () => { const BOX_GROUP = 0xdf; const PAGE_ANCHORED_BOX = 0x02; const BOX_CONTENT_TYPE_TEXT = 1; + const BOX_CONTENT_TYPE_LINKED_TEXT = 2; const BOX_CONTENT_TYPE_EQUATION = 4; const BOX_CONTENT_TYPE_IMAGE = 3; @@ -612,6 +1220,130 @@ describe("boxes", () => { }); }); + it("lifts a linked-text box's own content the same way as a plain text box", () => { + const document = readDocumentArea( + [...boxFunction(BOX_CONTENT_TYPE_LINKED_TEXT, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + generalWpTextPacket(text("linked text")), + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + if ( + block?.kind !== "embeddedObject" || + block.document.kind !== "wordprocessing" + ) { + throw new Error("expected a nested wordprocessing document"); + } + expect(block.document.sections[0]?.blocks[0]).toMatchObject({ + kind: "paragraph", + runs: [{ text: "linked text" }], + }); + }); + + it("does not treat an unrecognised content type as text-like, even with a readable General WP Text packet at its prefix ID", () => { + const UNKNOWN_CONTENT_TYPE = 5; + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...boxFunction(UNKNOWN_CONTENT_TYPE, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + generalWpTextPacket(text("should not be lifted")), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect( + document.sections[0]?.blocks.some((b) => b.kind === "embeddedObject"), + ).toBe(false); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.BoxContentUnresolved, + ), + ).toBe(true); + }); + + it("reports the exact box-content-unresolved message for a text-like box whose content packet is not General WP Text", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [...boxFunction(BOX_CONTENT_TYPE_TEXT, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + { packetType: 0x55, bytes: new Uint8Array(0) }, // font descriptor, not General WP Text + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.BoxContentUnresolved, + ); + expect(found?.message).toBe( + "This document contains a box whose content this reader could not read -- an image, OLE object, or other content type this reader does not yet decode into the shared schema.", + ); + }); + + it("reports the exact box-frame-unresolved message for a text-like box stating no width or height", () => { + const noFrameBox = variableFunction({ + group: BOX_GROUP, + subgroup: PAGE_ANCHORED_BOX, + prefixIds: [1, 2], + nonDeletable: boxNonDeletable( + 0x2000, // bit 13 (content) only -- no position/size override at all + new Map([[13, contentBlock(BOX_CONTENT_TYPE_TEXT)]]), + ), + }); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...noFrameBox], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + generalWpTextPacket(text("boxed")), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect( + document.sections[0]?.blocks.some((b) => b.kind === "embeddedObject"), + ).toBe(false); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.BoxFrameUnresolved, + ); + expect(found?.message).toBe( + "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.", + ); + }); + + it("flushes preceding text into its own paragraph before a text-like box's own embedded document", () => { + const document = readDocumentArea( + [...text("before"), ...boxFunction(BOX_CONTENT_TYPE_TEXT, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + generalWpTextPacket(text("boxed")), + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const blocks = document.sections[0]?.blocks ?? []; + expect(blocks.map((b) => b.kind)).toEqual(["paragraph", "embeddedObject"]); + const paragraph = blocks[0]; + expect( + paragraph?.kind === "paragraph" + ? paragraph.runs.map((run) => run.text).join("") + : undefined, + ).toBe("before"); + }); + it("lifts an equation box's own content as unparsed residue, not fabricated MathML", () => { const document = readDocumentArea( [...boxFunction(BOX_CONTENT_TYPE_EQUATION, [1, 2])], @@ -638,6 +1370,41 @@ describe("boxes", () => { }); }); + // plainTextOf must only ever read paragraph blocks -- a non-paragraph block folded alongside them (a page break, here) carries no `runs` field at all and must be skipped rather than read as one. It must also join a paragraph's own runs with no separator, and join separate paragraphs with a newline. + it("builds an equation's plain-text residue from only its paragraph blocks, joined correctly", () => { + const document = readDocumentArea( + [...boxFunction(BOX_CONTENT_TYPE_EQUATION, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + generalWpTextPacket([ + ...text("a"), + 0xf2, // ATTRIBUTE_ON (bold), splitting the first paragraph across two runs + 12, // BOLD + 0xf2, + ...text("b"), + 0xcc, // HARD_EOL: ends the first paragraph + 0xc7, // hard end of page: a non-paragraph block between the two paragraphs + ...text("c"), + ]), + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + if (block?.kind !== "embeddedObject") + throw new Error("expected embeddedObject"); + if (block.document.kind !== "formula") { + throw new Error("expected a formula document"); + } + // The hard end of page unconditionally flushes a paragraph before it, which is empty here (the hard return just before it already flushed the pending text) -- so the join sees three paragraphs ("ab", "", "c"), with the intervening page break filtered out entirely rather than read as a fourth. + expect(block.document.formula.source).toEqual({ + format: "wpd", + xml: "ab\n\nc", + }); + }); + it("reports an image box through the diagnostic sink rather than guessing at its content", () => { const diagnostics: WpdDiagnostic[] = []; const bytes = buildWpdFile( @@ -648,12 +1415,14 @@ describe("boxes", () => { ], ); readWpdContent(bytes, { sink: (d) => diagnostics.push(d) }); - expect( - diagnostics.filter( - (diagnostic) => - diagnostic.code === WpdDiagnosticCodes.BoxContentUnresolved, - ), - ).toHaveLength(1); + const matches = diagnostics.filter( + (diagnostic) => + diagnostic.code === WpdDiagnosticCodes.BoxContentUnresolved, + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toBe( + "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.", + ); }); // 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. @@ -711,12 +1480,68 @@ describe("boxes", () => { expect(block.floatPosition).toBeUndefined(); }); - it("carries an image box's absolute page position as the image's floatPosition", () => { + it("reports the exact box-frame-unresolved message for an image box stating no width or height", () => { 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 noFrameBox = variableFunction({ + group: BOX_GROUP, + subgroup: PAGE_ANCHORED_BOX, + prefixIds: [1, 2], + nonDeletable: boxNonDeletable( + 0x2000, // bit 13 (content) only -- no position/size override at all + new Map([[13, contentBlock(BOX_CONTENT_TYPE_IMAGE)]]), + ), + }); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...noFrameBox], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + { packetType: 0x42, bytes: png }, + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect(document.sections[0]?.blocks.some((b) => b.kind === "image")).toBe( + false, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.BoxFrameUnresolved, + ); + expect(found?.message).toBe( + "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.", + ); + }); + + it("flushes preceding text into its own paragraph before an image box's own image block", () => { + const png = tinyPng(); + const document = readDocumentArea( + [...text("before"), ...boxFunction(BOX_CONTENT_TYPE_IMAGE, [1, 2])], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + { packetType: 0x42, bytes: png }, + ], + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const blocks = document.sections[0]?.blocks ?? []; + expect(blocks.map((b) => b.kind)).toEqual(["paragraph", "image"]); + const paragraph = blocks[0]; + expect( + paragraph?.kind === "paragraph" + ? paragraph.runs.map((run) => run.text).join("") + : undefined, + ).toBe("before"); + }); + + 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); @@ -797,6 +1622,66 @@ describe("page furniture and notes (D6/D7, #1128)", () => { }); } + it("gives a plain flat document's own section no headers, footers, or watermarks keys at all", () => { + const document = readDocumentArea(text("plain")); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const section = document.sections[0]; + expect(section).toBeDefined(); + for (const key of ["headers", "footers", "watermarks"]) { + expect(section === undefined ? false : Object.hasOwn(section, key)).toBe( + false, + ); + } + }); + + it("reports the exact could-not-resolve message for a header naming no packet, without setting a header slot", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile([ + ...text("body"), + ...variableFunction({ + group: 0xd6, + subgroup: 0x00, + prefixIds: [7], // names a prefix ID this document's index carries no packet for + nonDeletable: [1, 0], + }), + ]), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect(Object.hasOwn(document.sections[0] ?? {}, "headers")).toBe(false); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.HeaderFooterDropped, + ); + expect(found?.message).toBe( + "This document declares a header, footer, or watermark whose body packet this reader could not resolve; it was not lifted.", + ); + }); + + it("reports the exact could-not-read message for a header whose body packet cannot be parsed", () => { + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...text("body"), ...headerFunction(0x00, 0x01)], + // General WP Text, the right packet type, but too short for even its own block-count word. + [{ packetType: 0x08, bytes: new Uint8Array(0) }], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect(Object.hasOwn(document.sections[0] ?? {}, "headers")).toBe(false); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.HeaderFooterDropped, + ); + expect(found?.message).toBe( + "This document declares a header, footer, or watermark whose body packet this reader could not read; it was not lifted.", + ); + }); + it("lifts a header occurring on odd pages into the section's default header slot", () => { const document = readDocumentArea( [...text("body"), ...headerFunction(0x00, 0x01)], @@ -954,9 +1839,12 @@ describe("page furniture and notes (D6/D7, #1128)", () => { b.kind === "paragraph" ? b.runs.map((run) => run.text).join("") : "", ), ).toEqual(["First header"]); - expect( - diagnostics.some((d) => d.code === "wpd/header-footer-dropped"), - ).toBe(true); + const found = diagnostics.find( + (d) => d.code === "wpd/header-footer-dropped", + ); + expect(found?.message).toBe( + "This document declares a second header for the default slot -- WordPerfect's own A/B two-slot-per-kind mechanism, which the shared one-flow-per-slot page-furniture vocabulary does not carry; the first header to claim the slot is the one lifted.", + ); }); it("anchors a footnote reference in the flat form and carries its body in the tree's definitions table", () => { @@ -988,9 +1876,13 @@ describe("page furniture and notes (D6/D7, #1128)", () => { throw new Error("expected an anchor descriptor"); } // The flat form reports the body it cannot carry. - expect( - diagnostics.filter((d) => d.code === "wpd/note-dropped"), - ).toHaveLength(1); + const noteDroppedMatches = diagnostics.filter( + (d) => d.code === "wpd/note-dropped", + ); + expect(noteDroppedMatches).toHaveLength(1); + expect(noteDroppedMatches[0]?.message).toBe( + "This document contains a footnote whose body the flat ContentDocument has no home for; its reference anchor survives and readWpd lifts the body into the tree form's definitions table.", + ); const tree = readWpd(buildWpdFile(documentArea, [noteBody])); // The definitions table is deliberately tenant-loose (document-schema.js's own design), so the whole entry is asserted in one toEqual rather than through typed field access. @@ -1004,6 +1896,58 @@ describe("page furniture and notes (D6/D7, #1128)", () => { }, ], }); + // No OLE objects anywhere in this document -- the attachments table must not appear at all, not even empty. + expect(tree.attachments).toBeUndefined(); + }); + + it("reports the exact could-not-read message for a note whose body packet is the wrong type", () => { + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [ + ...text("See this"), + ...variableFunction({ group: 0xd7, subgroup: 0x00, prefixIds: [1] }), + ...text("1"), + ...variableFunction({ group: 0xd7, subgroup: 0x01 }), + ], + [{ packetType: 0x55, bytes: new Uint8Array(0) }], // a real packet, but not General WP Text + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.NoteDropped, + ); + expect(found?.message).toBe( + "This document contains a footnote or endnote whose body packet this reader could not read; its reference anchor survives and its body does not.", + ); + }); + + // The marker text is built from every run between a note's On and Off, flushing whatever text is still pending first -- and only falls back to a generated numeral when that text is genuinely empty. A marker that IS real text, spanning more than one run and happening to be truthy, must be used as-is rather than replaced by the numeral, and the numeral itself must come from the notes already carried plus one, not minus one. + it("builds a multi-run marker over the generated-numeral fallback, and numbers a genuinely empty marker correctly", () => { + const bodies = [ + generalWpTextPacket(text("first body")), + generalWpTextPacket(text("second body")), + ]; + const tree = readWpd( + buildWpdFile( + [ + // Note A: an empty reference marker -- must fall back to the generated numeral "1" (state.notes.length is 0 at this point). + ...variableFunction({ group: 0xd7, subgroup: 0x00, prefixIds: [1] }), + ...variableFunction({ group: 0xd7, subgroup: 0x01 }), + // Note B: a genuine, non-empty, two-run marker ("star") that must win over the fallback numeral ("2"). + ...variableFunction({ group: 0xd7, subgroup: 0x00, prefixIds: [2] }), + ...text("st"), + 0xf2, // ATTRIBUTE_ON (bold), splitting the marker across two runs + 12, // BOLD + 0xf2, + ...text("ar"), + ...variableFunction({ group: 0xd7, subgroup: 0x01 }), + ], + bodies, + ), + ); + expect(tree.definitions?.["note-1"]?.marker).toBe("1"); + expect(tree.definitions?.["note-2"]?.marker).toBe("star"); }); it("carries an endnote pair as the endnote tenant", () => { @@ -1021,6 +1965,24 @@ describe("page furniture and notes (D6/D7, #1128)", () => { const definition = tree.definitions?.["note-1"]; expect(definition?.kind).toBe("endnote"); }); + + // An unrelated subfunction sharing the D7 group (neither Footnote Off nor Endnote Off) must not be mistaken for a closing code and prematurely abandon a note already open -- the note must still resolve normally once its own real Off arrives. + it("does not abandon an open footnote for an unrelated subfunction sharing its own function group", () => { + const tree = readWpd( + buildWpdFile( + [ + ...variableFunction({ group: 0xd7, subgroup: 0x00, prefixIds: [1] }), // Footnote On + ...text("mark"), + ...variableFunction({ group: 0xd7, subgroup: 0x04 }), // an unassigned D7 subfunction, neither an On nor an Off + ...variableFunction({ group: 0xd7, subgroup: 0x01 }), // Footnote Off + ], + [generalWpTextPacket(text("The fine print"))], + ), + ); + const definition = tree.definitions?.["note-1"]; + expect(definition?.kind).toBe("footnote"); + expect(definition?.marker).toBe("mark"); + }); }); describe("native OLE objects (#1191)", () => { @@ -1162,9 +2124,13 @@ describe("native OLE objects (#1191)", () => { // The flat read recovers the bytes but has no field for them, and says so through the OLE-specific code rather than the generic box-content-unresolved one. const diagnostics: WpdDiagnostic[] = []; readWpdContent(compound, { sink: (d) => diagnostics.push(d) }); - expect( - diagnostics.filter((d) => d.code === WpdDiagnosticCodes.OleObjectDropped), - ).toHaveLength(1); + const oleDroppedMatches = diagnostics.filter( + (d) => d.code === WpdDiagnosticCodes.OleObjectDropped, + ); + expect(oleDroppedMatches).toHaveLength(1); + expect(oleDroppedMatches[0]?.message).toBe( + "This document embeds a native OLE object ('OLE10') whose bytes the flat ContentDocument has no home for; readWpd lifts them into the tree form's attachments table.", + ); expect( diagnostics.filter( (d) => d.code === WpdDiagnosticCodes.BoxContentUnresolved, @@ -1178,6 +2144,8 @@ describe("native OLE objects (#1191)", () => { name: "OLE10", base64: bytesToBase64(nativeBytes), }); + // No notes anywhere in this document -- the definitions table must not appear at all, not even empty. + expect(tree.definitions).toBeUndefined(); }); it("carries an OLE 1 object's inline descriptor bytes as a tree-form attachment in a bare file", () => { @@ -1207,6 +2175,8 @@ describe("native OLE objects (#1191)", () => { name: "ole1-2", base64: bytesToBase64(new Uint8Array(ole1Data)), }); + // No footnotes or endnotes rode along with the OLE object, so the tree carries no definitions table entry at all -- not merely an empty one. + expect(tree.definitions).toBeUndefined(); }); it("collapses two boxes naming the same OLE object into one attachment entry", () => { @@ -1248,3 +2218,479 @@ describe("native OLE objects (#1191)", () => { ).toHaveLength(0); }); }); + +describe("WPG vector graphics embedded in an image box", () => { + const BOX_GROUP = 0xdf; + const PAGE_ANCHORED_BOX = 0x02; + const BOX_CONTENT_TYPE_IMAGE = 3; + const PACKET_TYPE_GRAPHICS_CACHED_FILE_DATA = 0x6f; + + function putUint16(bytes: number[], offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + } + + function contentBlock(contentType: number): number[] { + const flags: number[] = [0, 0]; + putUint16(flags, 0, 0x4000); + return [...flags, contentType]; + } + + function positionBlock(widthWpu: number, heightWpu: number): number[] { + const flags: number[] = [0, 0]; + putUint16(flags, 0, 0x0c00); + const width = [0, 0, 0]; + putUint16(width, 1, widthWpu); + const height = [0, 0, 0]; + putUint16(height, 1, heightWpu); + return [...flags, ...width, ...height]; + } + + function boxNonDeletable( + overrideFlags: number, + blocks: ReadonlyMap, + ): number[] { + const bytes = new Array(18).fill(0); + putUint16(bytes, 18, overrideFlags); + for (let bit = 15; bit >= 5; bit -= 1) { + const data = blocks.get(bit); + if (data === undefined) { + continue; + } + putUint16(bytes, bytes.length, data.length); + bytes.push(...data); + } + return bytes; + } + + // An image box naming a Graphics Filename packet at prefix ID 2, itself naming one Graphics Cached File Data child at prefix ID 3 -- the one path stream/wpg.ts's own decoder is reached through. `withFrame` false omits the position override entirely, for the "no trustworthy frame" branch. + function imageBoxFunction(withFrame = true): number[] { + const blocks = new Map([ + [13, contentBlock(BOX_CONTENT_TYPE_IMAGE)], + ]); + if (withFrame) { + blocks.set(14, positionBlock(1440, 720)); + } + return variableFunction({ + group: BOX_GROUP, + subgroup: PAGE_ANCHORED_BOX, + prefixIds: [1, 2], + nonDeletable: boxNonDeletable(withFrame ? 0x6000 : 0x2000, blocks), + }); + } + + function graphicsFilenamePacket() { + return { + packetType: 0x40, + flags: 0x01, + bytes: new Uint8Array([1, 0, 3, 0, 0, 0, 0, 0]), + }; + } + + function graphicsCachedFileDataPacket(wpgBytes: Uint8Array) { + return { + packetType: PACKET_TYPE_GRAPHICS_CACHED_FILE_DATA, + bytes: wpgBytes, + }; + } + + function word(value: number): number[] { + return [value & 0xff, (value >>> 8) & 0xff]; + } + + function dword(value: number): number[] { + return [...word(value & 0xffff), ...word((value >>> 16) & 0xffff)]; + } + + function wpgRecord(type: number, data: readonly number[]): number[] { + return [0x0f, type, 0, data.length, ...data]; + } + + // A minimal, well-formed WPG 2.x stream: the 26-byte prefix, a Start WPG stating a 288x144pt extent at 72ppi, one framed Rectangle vector, and End WPG. + function wpgFile(options: { + readonly majorVersion?: number; + readonly encrypted?: boolean; + readonly withRecordStream?: boolean; + readonly withVector?: boolean; + }): Uint8Array { + const majorVersion = options.majorVersion ?? 2; + const startWpgData = [ + ...word(72), + ...word(72), + 0, + ...word(0), + ...word(0), + ...word(0x7fff), + ...word(0x7fff), + ...word(0), + ...word(0), + ...word(288), + ...word(144), + ]; + const records = + options.withRecordStream === false + ? [] + : [ + ...wpgRecord(0x01, startWpgData), + ...(options.withVector === false + ? [] + : wpgRecord(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ])), + ...wpgRecord(0x02, []), + ]; + const head = [ + 0xff, + 0x57, + 0x50, + 0x43, + ...dword(26), + 1, + 0x16, + majorVersion, + 0, + ...word(options.encrypted ? 1 : 0), + ...word(26), + 0, + 0, + ...word(0), + ...dword(26 + records.length), + ...word(0), + ]; + return new Uint8Array([...head, ...records]); + } + + it("lifts a decoded WPG graphic as a nested drawing embeddedObject, naming its one skipped record", () => { + // A Polyspline record (0x16, unrecognised by this reader) rides alongside the framed rectangle, so the decode both succeeds and reports a skipped record. + const wpg = wpgFile({}); + const withSkip = new Uint8Array([ + ...wpg.subarray(0, wpg.length - 4), // drop the trailing End WPG record + ...wpgRecord(0x16, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(5), + ...word(5), + ]), + ...wpgRecord(0x02, []), + ]); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(withSkip), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + if (block?.kind !== "embeddedObject") { + throw new Error("expected an embeddedObject block"); + } + expect(block.objectKind).toBe("drawing"); + expect(block.frame).toEqual({ + xPt: 0, + yPt: 0, + widthPt: 86.4, + heightPt: 43.2, + }); + if (block.document.kind !== "drawing") { + throw new Error("expected a drawing document"); + } + expect(block.document.pages).toHaveLength(1); + expect(block.document.pages[0]?.size).toEqual({ + widthPt: 288, + heightPt: 144, + }); + expect(block.document.pages[0]?.vectors).toHaveLength(1); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ); + expect(found?.message).toBe( + "This document embeds a WPG vector graphic that partially decoded; the following record types were skipped: Polyspline.", + ); + }); + + it("flushes preceding text into its own paragraph before a decoded WPG's own drawing block", () => { + const wpg = wpgFile({}); + const document = readWpdContent( + buildWpdFile( + [...text("before"), ...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const blocks = document.sections[0]?.blocks ?? []; + expect( + blocks.map((b) => + b.kind === "paragraph" + ? "paragraph" + : b.kind === "embeddedObject" + ? "embeddedObject" + : b.kind, + ), + ).toEqual(["paragraph", "embeddedObject"]); + const paragraph = blocks.find((b) => b.kind === "paragraph"); + expect( + paragraph?.kind === "paragraph" + ? paragraph.runs.map((run) => run.text).join("") + : undefined, + ).toBe("before"); + }); + + it("carries a decoded WPG graphic's own text shapes alongside its vectors", () => { + const wpg = wpgFile({}); + // A Text Block (with one extension, its Text Data) inserted before the trailing End WPG record, alongside the rectangle wpgFile({}) already carries as a vector. + const withShape = new Uint8Array([ + ...wpg.subarray(0, wpg.length - 4), + 0x0f, + 0x1d, + 1, + 10, // extension count 1, [flags word, x, y, width, height] + ...word(0), + ...word(10), + ...word(10), + ...word(60), + ...word(50), + ...wpgRecord(0x0f, [...text("Hi"), 0xcc]), + ...wpgRecord(0x02, []), + ]); + const document = readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(withShape), + ], + ), + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + if (block?.kind !== "embeddedObject" || block.document.kind !== "drawing") { + throw new Error("expected a drawing embeddedObject"); + } + expect(block.document.pages[0]?.vectors).toHaveLength(1); + expect(block.document.pages[0]?.shapes).toHaveLength(1); + }); + + it("tries every Graphics Cached File Data child until one decodes as WPG, not just the first", () => { + const wpg = wpgFile({}); + const document = readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + { + packetType: 0x40, + flags: 0x01, + // Two children (prefix IDs 3 and 4), not the usual one. + bytes: new Uint8Array([2, 0, 3, 0, 4, 0]), + }, + { + packetType: PACKET_TYPE_GRAPHICS_CACHED_FILE_DATA, + bytes: new Uint8Array([1, 2, 3, 4]), // not a WPG signature at all + }, + graphicsCachedFileDataPacket(wpg), // the real one, at the second child + ], + ), + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + expect(block?.kind).toBe("embeddedObject"); + }); + + it("names more than one skipped WPG record type, joined by a comma and a space", () => { + // Polyspline (0x16) and Polycurve (0x17), both unrecognised by this reader, alongside the framed rectangle. + const wpg = wpgFile({}); + const withSkips = new Uint8Array([ + ...wpg.subarray(0, wpg.length - 4), // drop the trailing End WPG record + ...wpgRecord(0x16, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(5), + ...word(5), + ]), + ...wpgRecord(0x17, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(5), + ...word(5), + ]), + ...wpgRecord(0x02, []), + ]); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(withSkips), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ); + expect(found?.message).toBe( + "This document embeds a WPG vector graphic that partially decoded; the following record types were skipped: Polyspline, Polycurve.", + ); + }); + + it("lifts a decoded WPG graphic with no skipped records, reporting nothing", () => { + const wpg = wpgFile({}); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + const block = document.sections[0]?.blocks.find( + (b) => b.kind === "embeddedObject", + ); + expect(block?.kind).toBe("embeddedObject"); + expect( + diagnostics.some( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ), + ).toBe(false); + }); + + it("reports a decoded WPG graphic with no trustworthy frame, lifting nothing", () => { + const wpg = wpgFile({}); + const diagnostics: WpdDiagnostic[] = []; + const document = readWpdContent( + buildWpdFile( + [...imageBoxFunction(false)], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + if (document.kind !== "wordprocessing") { + throw new Error("expected wordprocessing"); + } + expect( + document.sections[0]?.blocks.some((b) => b.kind === "embeddedObject"), + ).toBe(false); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.BoxFrameUnresolved, + ); + expect(found?.message).toBe( + "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.", + ); + }); + + it("reports a WPG 1.0 graphic through the diagnostic sink with its own exact message", () => { + const wpg = wpgFile({ majorVersion: 1 }); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ); + expect(found?.message).toBe( + "This document embeds a WPG 1.0 vector graphic, whose type-and-length record vocabulary predates the framed WPG 2.x stream this reader decodes, so it was not lifted.", + ); + }); + + it("reports an encrypted WPG graphic through the diagnostic sink with its own exact message", () => { + const wpg = wpgFile({ encrypted: true }); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ); + expect(found?.message).toBe( + "This document embeds an encrypted WPG vector graphic, which this reader does not decrypt, so it was not lifted.", + ); + }); + + it("reports a malformed WPG graphic (no walkable Start WPG record) with its own exact message", () => { + const wpg = wpgFile({ withRecordStream: false }); + const diagnostics: WpdDiagnostic[] = []; + readWpdContent( + buildWpdFile( + [...imageBoxFunction()], + [ + { packetType: 0x41, bytes: new Uint8Array(0) }, + graphicsFilenamePacket(), + graphicsCachedFileDataPacket(wpg), + ], + ), + { sink: (d) => diagnostics.push(d) }, + ); + const found = diagnostics.find( + (d) => d.code === WpdDiagnosticCodes.WpgRecordsUndecoded, + ); + expect(found?.message).toBe( + "This document embeds a WPG graphic whose record stream this reader could not walk (no well-formed Start WPG record), so it was not lifted.", + ); + }); +}); diff --git a/packages/wpd-codec/src/read.ts b/packages/wpd-codec/src/read.ts index a7248c7a24..fd6d519877 100644 --- a/packages/wpd-codec/src/read.ts +++ b/packages/wpd-codec/src/read.ts @@ -17,6 +17,7 @@ import type { import { assembleTree } from "document-schema.js"; import { bytesToBase64 } from "./bytes/base64"; import { uint16At } from "./bytes/view"; +import { WpdFormatError } from "./errors"; import { readFurnitureClaim } from "./stream/furniture"; import { openWpdDocument, @@ -334,6 +335,20 @@ function flushRun(state: ReaderState): void { state.text = ""; } +// assertDefined's own message for its one real call site (applyToken's "character" case). A fixed constant rather than a per-byte template, exported alongside assertDefined for this package's own tests only, so its exact text stays directly testable even though no real document byte can ever trigger it. +export const UNREACHABLE_CHARACTER_MAPPING_MESSAGE = + "A single-byte document-area character had no character mapping, which the tokeniser's own byte range should make unreachable."; + +// Narrows a value this reader has already proven cannot genuinely be undefined at its one call site, throwing loudly rather than silently substituting a sentinel if that proof is ever wrong. Exported for this package's own tests only: a real caller reaches it through applyToken's "character" case, never directly. +export function assertDefined( + value: T | undefined, + message: string, +): asserts value is T { + if (value === undefined) { + throw new WpdFormatError(message); + } +} + // Where a closed block belongs: a table's current cell while one is open, the section's own list otherwise. function targetBlocks(state: ReaderState): ContentBlock[] { return state.table === undefined ? state.blocks : state.table.cellBlocks; @@ -397,15 +412,10 @@ function flushParagraphIfContent( flushParagraph(state, sink); } -// The innermost open style scope that says something structural. An enclosing Global On naming the document's Normal style does not override a heading style opened inside it, and a scope with no meaning at all is transparent. +// The innermost open style scope that says something structural. An enclosing Global On naming the document's Normal style does not override a heading style opened inside it, and a scope with no meaning at all is transparent. findLast walks the scope stack from its own last (innermost) entry backward toward the first (outermost), exactly the search order this needs, with no separate index arithmetic of its own to keep in step with the stack's own length. function effectiveStyle(state: ReaderState): WpdStyleSemantics | undefined { - for (let index = state.styleScopes.length - 1; index >= 0; index -= 1) { - const semantics = state.styleScopes[index]?.semantics; - if (semantics !== undefined) { - return semantics; - } - } - return undefined; + return state.styleScopes.findLast((scope) => scope.semantics !== undefined) + ?.semantics; } function appendText(state: ReaderState, text: string): void { @@ -696,8 +706,7 @@ function applySingleByteFunction( state.skipDepth = Math.max(0, state.skipDepth - 1); return; default: - // Every remaining single-byte function is a formatting or bookkeeping marker that contributes neither characters nor structure. - return; + // Every remaining single-byte function is a formatting or bookkeeping marker that contributes neither characters nor structure. No separate `return` is needed: this is the switch's own last case, so falling through here reaches this void function's end exactly as a `return` would. } } @@ -896,9 +905,8 @@ function applyDisplayNumberGroup( ): void { if (isParagraphNumberDisplayOn(token.subgroup)) { const level = readDisplayNumberLevel(token.nonDeletable); - if (level !== undefined && state.pendingListLevel === undefined) { - state.pendingListLevel = level; - } + // No separate `level !== undefined` guard is needed: pendingListLevel is only ever compared against undefined (never enumerated or spread conditionally on its own presence), so assigning it an undefined level when the level itself could not be read is indistinguishable from leaving it untouched. + state.pendingListLevel ??= level; state.numberDisplayDepth += 1; reportOnce( state, @@ -981,7 +989,7 @@ function applyCharacterGroup( return; } default: - return; + // This is the switch's own last case, so falling through here reaches this void function's end exactly as a `return` would. } } @@ -1524,7 +1532,7 @@ function applyVariableFunction( applyBoxGroup(state, token, container, sink); return; default: - return; + // This is the switch's own last case, so falling through here reaches this void function's end exactly as a `return` would. } } @@ -1597,14 +1605,8 @@ function applyToken( switch (token.kind) { case "character": { const character = decodeSingleByteCharacter(token.byte); - if (character === undefined) { - sink({ - code: WpdDiagnosticCodes.UnmappedCharacter, - message: `Byte ${token.byte} in the document area has no character mapping and was rendered as U+FFFD.`, - }); - appendText(state, UNMAPPED_CHARACTER); - return; - } + // decodeSingleByteCharacter's own domain (1..127) is exhaustively covered by its shorthand table (bytes 1..32) plus its literal ASCII range (33..127) -- proven by iterating every byte in 1..127 and confirming none decode to undefined (stream/characters.test.ts's own exhaustiveness check) -- and the tokeniser only ever mints a "character" token for a byte already restricted to exactly that domain (0 is skipped upstream, 0x80 and above becomes a function instead, per tokenise.ts's own FIRST_SINGLE_BYTE_FUNCTION cutoff). So this can never actually be undefined for a byte this reader hands it; assertDefined states that proven fact as a real, throwing check rather than a silent cast. The message is a fixed constant, not a per-byte template, specifically so it stays directly testable on its own terms (see read.test.ts) even though no real document byte can ever reach it. + assertDefined(character, UNREACHABLE_CHARACTER_MAPPING_MESSAGE); appendText(state, character); return; } @@ -1814,9 +1816,7 @@ export function readWpd( { kind: note.anchorType, marker: note.marker, blocks: note.blocks }, ]), ); - if (Object.keys(attachments).length === 0 && notes.length === 0) { - return assembled; - } + // No separate "neither table has anything to add" early return is needed: when both are empty, the spread below produces an object with exactly assembled's own keys and values -- a shallow copy indistinguishable from assembled itself to any caller, since nothing here ever mutates assembled afterwards. return { ...assembled, ...(Object.keys(attachments).length > 0 diff --git a/packages/wpd-codec/src/stream/box.test.ts b/packages/wpd-codec/src/stream/box.test.ts index 5bdd66d8bf..ba3633ba06 100644 --- a/packages/wpd-codec/src/stream/box.test.ts +++ b/packages/wpd-codec/src/stream/box.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from "vitest"; -import { BOX_CONTENT_TYPE_TEXT, readBoxContent } from "./box"; +import { pointsFromWpu as pointsFromWpuForTest } from "./units"; +import { + BOX_CONTENT_TYPE_IMAGE, + BOX_CONTENT_TYPE_TEXT, + readBoxContent, +} from "./box"; function putUint16(bytes: number[], offset: number, value: number): void { bytes[offset] = value & 0xff; bytes[offset + 1] = (value >>> 8) & 0xff; } +function word16(value: number): number[] { + return [value & 0xff, (value >>> 8) & 0xff]; +} + // Builds a box function's own `nonDeletable` bytes: 14 reserved, [override+wrap size], [override size], [override flags], then each set bit's own [size] block in descending bit order. function boxNonDeletable(options: { readonly overrideFlags: number; @@ -103,4 +112,335 @@ describe("readBoxContent", () => { const result = readBoxContent(nonDeletable, [41, 42]); expect(result?.frame).toBeUndefined(); }); + + // A stated bit (5) below the three this module names (counter, position, content). Nothing here ever reads its own block, but the walk must still correctly reject a function whose bit-5 block itself declares a corrupt, overrunning size -- not silently skip straight past it to whatever content override happens to sit earlier in the same function. + it("rejects the whole function when a walked-but-unread bit (5) declares an overrunning size, even though a valid content override precedes it", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2020, // bit 13 (content) and bit 5 + blocks: new Map([ + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + [5, [0]], // any single byte; only its (lying) size prefix matters below + ]), + }); + // Overwrite bit 5's own size prefix (the two bytes just before its one data byte) to claim far more than remains. + nonDeletable[nonDeletable.length - 3] = 0xe8; + nonDeletable[nonDeletable.length - 2] = 0x03; + expect(readBoxContent(nonDeletable, [41, 42])).toBeUndefined(); + }); + + it("skips bit 7 (HTML) without trying to read an inline size-prefixed block for it", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2080, // bit 13 (content) and bit 7 (HTML, no inline data) + blocks: new Map([[13, contentBlock(BOX_CONTENT_TYPE_TEXT)]]), + }); + const result = readBoxContent(nonDeletable, [41, 42]); + expect(result?.contentType).toBe(BOX_CONTENT_TYPE_TEXT); + }); + + it("rejects a function that states a content override bit with no block data at all", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2000, // claims bit 13, but no block is supplied below + blocks: new Map(), + }); + expect(readBoxContent(nonDeletable, [41, 42])).toBeUndefined(); + }); + + // A content override whose own stated size lies far beyond the function's true extent: the walk must refuse it outright, never let a lying size borrow whatever real bytes happen to follow within the function's own true bounds as if they belonged to this block. + it("never lets a content override with a lying, oversized declaration borrow real trailing bytes", () => { + const nonDeletable = new Array(14 + 2 + 2).fill(0); + putUint16(nonDeletable, 18, 0x2000); // bit 13 (content) + putUint16(nonDeletable, nonDeletable.length, 100); // lying size: 100 + nonDeletable.push(...contentBlock(BOX_CONTENT_TYPE_IMAGE)); // 3 real bytes, far short of 100 + expect( + readBoxContent(new Uint8Array(nonDeletable), [41, 42]), + ).toBeUndefined(); + }); + + it("rejects a content override block too short to even hold its own flags word", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2000, + blocks: new Map([[13, [0x40]]]), // one byte: too short for the two-byte flags word + }); + expect(readBoxContent(nonDeletable, [41, 42])).toBeUndefined(); + }); + + // The content block's own PID-flags skip (bit 15) must consume exactly two bytes before the type byte, not zero -- otherwise the type ends up misread as the first byte of what was actually the PID data. + it("reads the type byte after the content block's own PID-flags skip, not before it", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2000, + blocks: new Map([ + [ + 13, + [ + 0x00, + 0xc0, // content flags: bit 15 (PID) and bit 14 (type override) + 0x99, + 0x99, // PID data, skipped + BOX_CONTENT_TYPE_IMAGE, + ], + ], + ]), + }); + const result = readBoxContent(nonDeletable, [41, 42]); + expect(result?.contentType).toBe(BOX_CONTENT_TYPE_IMAGE); + }); + + it("declines a content block whose own bit 14 (type override) is not set", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x2000, + blocks: new Map([[13, [0x00, 0x00, BOX_CONTENT_TYPE_IMAGE]]]), + }); + expect(readBoxContent(nonDeletable, [41, 42])).toBeUndefined(); + }); + + it("rejects a position override block too short to even hold its own flags word", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [14, [0x00]], // one byte: too short for the two-byte flags word + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + it("declines a horizontal-position sub-block with no room for its own five bytes", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + // bit 13 (horizontal) claimed: flags word, then one more byte -- not even the three (flags, offset) this code actually reads, let alone the declared five. + [14, [0x00, 0x20, 0]], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + it("declines a vertical-position sub-block with no room for its own three bytes", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [14, [0x00, 0x10, 0]], // bit 12 (vertical) claimed, only 1 of its 3 bytes present + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + it("declines a width sub-block with no room for its own three bytes", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [14, [0x00, 0x08, 0]], // bit 11 (width) claimed, only 1 of its 3 bytes present + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + it("declines a height sub-block with no room for its own three bytes", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [14, [0x00, 0x04, 0]], // bit 10 (height) claimed, only 1 of its 3 bytes present + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + // Every optional position sub-block set at once, each with a distinct value, so a wrong cursor advance anywhere throws reading a later field rather than silently landing on a coincidentally-plausible one. + it("resolves every position sub-block together, each reading its own bytes rather than a neighbour's", () => { + const positionData = [ + 0x00, + 0xaa, // PID flags (bit 15), skipped -- 2 bytes + 0x00, + 0xbb, // general flags (bit 14), skipped -- 2 bytes + 0x00, + ...word16(1000), + 0, + 0, // horizontal (bit 13): flags=0 (absolute), offset=1000, leftcol, rightcol + 0x00, + ...word16(500), // vertical (bit 12): flags=0 (absolute), offset=500 + 0x00, + ...word16(1200), // width (bit 11): flags, width=1200 + 0x00, + ...word16(600), // height (bit 10): flags, height=600 + ]; + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, // bit 14 (position) and bit 13 (content) + blocks: new Map([ + [14, [0x00, 0xfc, ...positionData]], // bits 15,14,13,12,11,10 all set + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + const result = readBoxContent(nonDeletable, [41, 42]); + expect(result?.frame).toEqual({ + xPt: pointsFromWpuForTest(1000), + yPt: pointsFromWpuForTest(500), + widthPt: pointsFromWpuForTest(1200), + heightPt: pointsFromWpuForTest(600), + positionResolved: true, + }); + }); + + // Width and height are also set here (unlike a bare "no other bits set" case), because frame is only ever computed at all once both are defined -- otherwise a version that wrongly resolved x regardless of the offset's own type would still report frame: undefined, for the unrelated reason that width and height were never supplied, and the bug would go unnoticed. + it("does not resolve x from a horizontal offset whose own type is not absolute-from-page", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x2c, // bits 13 (horizontal), 11 (width), 10 (height) + 0x01, + ...word16(1000), + 0, + 0, // horizontal flags = 1 (not absolute), offset = 1000 + 0x00, + ...word16(1200), // width + 0x00, + ...word16(600), // height + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + const result = readBoxContent(nonDeletable, [41, 42]); + expect(result?.frame).toEqual({ + xPt: pointsFromWpuForTest(0), + yPt: pointsFromWpuForTest(0), + widthPt: pointsFromWpuForTest(1200), + heightPt: pointsFromWpuForTest(600), + positionResolved: false, + }); + }); + + it("does not resolve y from a vertical offset whose own type is not absolute-from-page", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x1c, // bits 12 (vertical), 11 (width), 10 (height) + 0x01, + ...word16(500), // vertical flags = 1 (not absolute), offset = 500 + 0x00, + ...word16(1200), // width + 0x00, + ...word16(600), // height + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + const result = readBoxContent(nonDeletable, [41, 42]); + expect(result?.frame).toEqual({ + xPt: pointsFromWpuForTest(0), + yPt: pointsFromWpuForTest(0), + widthPt: pointsFromWpuForTest(1200), + heightPt: pointsFromWpuForTest(600), + positionResolved: false, + }); + }); + + // Exactly one of x/y resolved -- the existing "full house" test resolves both, which cannot tell positionResolved's && from ||, and neither non-absolute case above ever reaches this field at all (frame's xWpu/yWpu stay undefined there for an unrelated reason upstream). + it("reports positionResolved false when only x resolved, not just when neither did", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x2c, // bits 13 (horizontal, absolute), 11 (width), 10 (height) + 0x00, + ...word16(1000), + 0, + 0, + 0x00, + ...word16(1200), + 0x00, + ...word16(600), + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect( + readBoxContent(nonDeletable, [41, 42])?.frame?.positionResolved, + ).toBe(false); + }); + + it("reports positionResolved false when only y resolved, not just when neither did", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x1c, // bits 12 (vertical, absolute), 11 (width), 10 (height) + 0x00, + ...word16(500), + 0x00, + ...word16(1200), + 0x00, + ...word16(600), + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect( + readBoxContent(nonDeletable, [41, 42])?.frame?.positionResolved, + ).toBe(false); + }); + + // Width is not set in the override flags at all; the walk must never read a phantom width from bytes that in fact belong entirely to the (genuinely set) height sub-block, even when there happen to be enough trailing bytes for such a misread to succeed silently. + it("never resolves a width the position override never actually stated", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x04, // bit 10 (height) only -- bit 11 (width) is NOT set + 0x00, + ...word16(999), // would-be phantom width source + 0x00, + ...word16(500), // the real height data, once correctly reached + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); + + // Height is not set in the override flags at all; the mirror image of the width case above. + it("never resolves a height the position override never actually stated", () => { + const nonDeletable = boxNonDeletable({ + overrideFlags: 0x6000, + blocks: new Map([ + [ + 14, + [ + 0x00, + 0x08, // bit 11 (width) only -- bit 10 (height) is NOT set + 0x00, + ...word16(999), // would-be phantom height source + 0x00, + ...word16(1200), // the real width data, once correctly reached + ], + ], + [13, contentBlock(BOX_CONTENT_TYPE_TEXT)], + ]), + }); + expect(readBoxContent(nonDeletable, [41, 42])?.frame).toBeUndefined(); + }); }); diff --git a/packages/wpd-codec/src/stream/box.ts b/packages/wpd-codec/src/stream/box.ts index d138a8071e..9100bd7f12 100644 --- a/packages/wpd-codec/src/stream/box.ts +++ b/packages/wpd-codec/src/stream/box.ts @@ -26,50 +26,49 @@ const FIRST_OVERRIDE_BLOCK_OFFSET = OVERRIDE_FLAGS_OFFSET + 2; function walkOverrideBlocks( nonDeletable: Uint8Array, ): { flags: number; blocks: ReadonlyMap } | undefined { - if (nonDeletable.length < FIRST_OVERRIDE_BLOCK_OFFSET) { - return undefined; - } - const flags = uint16At(nonDeletable, OVERRIDE_FLAGS_OFFSET); - const blocks = new Map(); - let cursor = FIRST_OVERRIDE_BLOCK_OFFSET; - for (let bit = 15; bit >= 5; bit -= 1) { - if ((flags & (1 << bit)) === 0) { - continue; - } - if (bit === OVERRIDE_BIT_HTML) { - continue; - } - if (cursor + 2 > nonDeletable.length) { - return undefined; - } - const size = uint16At(nonDeletable, cursor); - cursor += 2; - if (cursor + size > nonDeletable.length) { - return undefined; + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past nonDeletable's own end, caught once below -- so neither the flags word itself nor a block's own size field needs a separate room check ahead of reading it. + try { + const flags = uint16At(nonDeletable, OVERRIDE_FLAGS_OFFSET); + const blocks = new Map(); + let cursor = FIRST_OVERRIDE_BLOCK_OFFSET; + for (let bit = 15; bit >= 5; bit -= 1) { + if ((flags & (1 << bit)) === 0) { + continue; + } + if (bit === OVERRIDE_BIT_HTML) { + continue; + } + const size = uint16At(nonDeletable, cursor); + cursor += 2; + if (cursor + size > nonDeletable.length) { + return undefined; + } + blocks.set(bit, nonDeletable.subarray(cursor, cursor + size)); + cursor += size; } - blocks.set(bit, nonDeletable.subarray(cursor, cursor + size)); - cursor += size; + return { flags, blocks }; + } catch { + return undefined; } - return { flags, blocks }; } // The content override block's own nested flags (WPFF_DF-BOX.htm, "bit 13: box content data"): [content override flags], then bit15 (PID flags, 2 bytes, no size prefix of its own) and bit14 (the content type byte itself). Bit 13 (rendering information) and bit 12 (alignment) are not read -- this module only needs the type, not how it renders. function readContentType(contentBlock: Uint8Array): number | undefined { - if (contentBlock.length < 2) { - return undefined; - } - const flags = uint16At(contentBlock, 0); - let cursor = 2; - if ((flags & 0x8000) !== 0) { - if (cursor + 2 > contentBlock.length) { + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past contentBlock's own end, caught below -- so the flags word needs no separate room check ahead of reading it. + try { + const flags = uint16At(contentBlock, 0); + let cursor = 2; + // No separate room guard is needed for the PID-flags skip: it only advances cursor (no read of its own), and the type byte this function ultimately returns is read through plain bracket access, which safely answers undefined for any offset this skip could have advanced cursor past without a guard -- there is no buffer length where skipping the guard produces an in-bounds-but-wrong byte instead of the identical out-of-bounds undefined a guard would have forced. + if ((flags & 0x8000) !== 0) { + cursor += 2; + } + if ((flags & 0x4000) === 0) { return undefined; } - cursor += 2; - } - if ((flags & 0x4000) === 0) { + return contentBlock[cursor]; + } catch { return undefined; } - return contentBlock[cursor]; } // The position override block's own nested flags (WPFF_DF-BOX.htm, "bit 14: Box positioning data"), read only for bit 11 (width) and bit 10 (height) -- both unconditionally in WPU -- and bits 13/12 (horizontal/vertical offset), accepted only when their own alignment-type bits state "absolute from page edge" (type 0), the one case whose offset is unambiguously the box's own page-space position rather than a value relative to margins or columns this module has no page geometry in hand to resolve against. @@ -78,62 +77,56 @@ function readPositionOverride( ): | { widthWpu?: number; heightWpu?: number; xWpu?: number; yWpu?: number } | undefined { - if (positionBlock.length < 2) { - return undefined; - } - const flags = uint16At(positionBlock, 0); - let cursor = 2; - let widthWpu: number | undefined; - let heightWpu: number | undefined; - let xWpu: number | undefined; - let yWpu: number | undefined; - - const need = (bytes: number): boolean => - cursor + bytes <= positionBlock.length; + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past positionBlock's own end, caught below -- so none of the four sub-block reads below need a separate room guard ahead of them. A dedicated need(n) guard (checking room for a whole sub-block, e.g. 5 bytes for horizontal positioning, even though 2 of those are unread leftcol/rightcol fields) used to sit ahead of each one, but it was never observably different from the throw it deferred to: a buffer too short even for THIS walk's own reads throws in exactly the place the guard would have rejected it, and a buffer with enough real data for cursor to legitimately reach a LATER bit's own reads is, by construction, already long enough to satisfy every earlier bit's own need, since cursor only ever advances by each bit's full declared width regardless -- so no input can tell a removed guard from the throw it would have deferred to. + try { + const flags = uint16At(positionBlock, 0); + let cursor = 2; + let widthWpu: number | undefined; + let heightWpu: number | undefined; + let xWpu: number | undefined; + let yWpu: number | undefined; - if ((flags & 0x8000) !== 0) { - // bit 15: PID flags, 2 bytes. - if (!need(2)) return undefined; - cursor += 2; - } - if ((flags & 0x4000) !== 0) { - // bit 14: general positioning flags, 2 bytes. - if (!need(2)) return undefined; - cursor += 2; - } - if ((flags & 0x2000) !== 0) { - // bit 13: horizontal positioning, 5 bytes -- [offset]. - if (!need(5)) return undefined; - const horizontalFlags = positionBlock[cursor]; - const offset = uint16At(positionBlock, cursor + 1); - if (horizontalFlags !== undefined && (horizontalFlags & 0x03) === 0) { - xWpu = offset; + // Neither the PID-flags skip (bit 15) nor the general-positioning-flags skip (bit 14) below needs a room guard: neither reads anything through it (each only advances cursor), so an insufficient buffer only ever surfaces once a later bit that actually reads data hits its own throw -- or, with no later bit set, the walk safely ends with every optional field left undefined, exactly as if this block had been correctly rejected. + if ((flags & 0x8000) !== 0) { + // bit 15: PID flags, 2 bytes. + cursor += 2; } - cursor += 5; - } - if ((flags & 0x1000) !== 0) { - // bit 12: vertical positioning, 3 bytes -- [offset]. - if (!need(3)) return undefined; - const verticalFlags = positionBlock[cursor]; - const offset = uint16At(positionBlock, cursor + 1); - if (verticalFlags !== undefined && (verticalFlags & 0x03) === 0) { - yWpu = offset; + if ((flags & 0x4000) !== 0) { + // bit 14: general positioning flags, 2 bytes. + cursor += 2; } - cursor += 3; - } - if ((flags & 0x0800) !== 0) { - // bit 11: width, 3 bytes -- [width]. - if (!need(3)) return undefined; - widthWpu = uint16At(positionBlock, cursor + 1); - cursor += 3; - } - if ((flags & 0x0400) !== 0) { - // bit 10: height, 3 bytes -- [height]. - if (!need(3)) return undefined; - heightWpu = uint16At(positionBlock, cursor + 1); - cursor += 3; + if ((flags & 0x2000) !== 0) { + // bit 13: horizontal positioning, 5 bytes -- [offset]. + const horizontalFlags = positionBlock[cursor]; + const offset = uint16At(positionBlock, cursor + 1); + if (horizontalFlags !== undefined && (horizontalFlags & 0x03) === 0) { + xWpu = offset; + } + cursor += 5; + } + if ((flags & 0x1000) !== 0) { + // bit 12: vertical positioning, 3 bytes -- [offset]. + const verticalFlags = positionBlock[cursor]; + const offset = uint16At(positionBlock, cursor + 1); + if (verticalFlags !== undefined && (verticalFlags & 0x03) === 0) { + yWpu = offset; + } + cursor += 3; + } + if ((flags & 0x0800) !== 0) { + // bit 11: width, 3 bytes -- [width]. + widthWpu = uint16At(positionBlock, cursor + 1); + cursor += 3; + } + if ((flags & 0x0400) !== 0) { + // bit 10: height, 3 bytes -- [height]. + heightWpu = uint16At(positionBlock, cursor + 1); + // cursor is never read again after this: height is always the last bit this walk processes, and the function returns unconditionally next, so there is nothing left for a final "cursor += 3" to affect. + } + return { widthWpu, heightWpu, xWpu, yWpu }; + } catch { + return undefined; } - return { widthWpu, heightWpu, xWpu, yWpu }; } export interface WpdBoxFrame { diff --git a/packages/wpd-codec/src/stream/characters.test.ts b/packages/wpd-codec/src/stream/characters.test.ts index 4c0b88c30e..d0cd083e8b 100644 --- a/packages/wpd-codec/src/stream/characters.test.ts +++ b/packages/wpd-codec/src/stream/characters.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { decodeWpCharacter } from "./characters"; +import { + decodeSingleByteCharacter, + decodeWordString, + decodeWpCharacter, +} from "./characters"; // Every expectation here is checked against libwpd's own WP6-to-Unicode tables (see character-sets.ts's own top-of-file citation), not typed from memory: each (character set, character number) pair below is the value at that 0-based index in libwpd's own array, and the resulting code point was independently confirmed against the Unicode Character Database (Python's unicodedata module) to carry the expected script and letter name. @@ -94,4 +98,61 @@ describe("decodeWpCharacter", () => { it("reports no mapping for a character set this package does not name", () => { expect(decodeWpCharacter(15, 0)).toBeUndefined(); }); + + // Character set 0's ASCII range is 0x20-0x7f (0x20 itself only reachable through this path, not the document-area byte stream -- see the module's own top comment), boundaries pinned directly since the rest of this describe block never exercises character numbers near either edge. + it("rejects a character set 0 number one below the ASCII range", () => { + expect(decodeWpCharacter(0, 0x1f)).toBeUndefined(); + }); + + it("accepts a character set 0 number at the low end of the ASCII range", () => { + expect(decodeWpCharacter(0, 0x20)).toBe(" "); + }); + + it("accepts a character set 0 number at the high end of the ASCII range", () => { + expect(decodeWpCharacter(0, 0x7f)).toBe(String.fromCharCode(0x7f)); + }); + + it("rejects a character set 0 number one above the ASCII range", () => { + expect(decodeWpCharacter(0, 0x80)).toBeUndefined(); + }); +}); + +describe("decodeSingleByteCharacter", () => { + it("rejects byte 0, below the ASCII range and not one of the thirty-two shorthands", () => { + expect(decodeSingleByteCharacter(0)).toBeUndefined(); + }); + + it("accepts the lowest byte in the ASCII range", () => { + expect(decodeSingleByteCharacter(0x21)).toBe("!"); + }); + + it("accepts the highest byte in the ASCII range", () => { + expect(decodeSingleByteCharacter(0x7f)).toBe(String.fromCharCode(0x7f)); + }); + + it("rejects a byte one above the ASCII range", () => { + expect(decodeSingleByteCharacter(0x80)).toBeUndefined(); + }); + + // The document area's own tokeniser only ever mints a "character" token for a byte in exactly this range (0 is skipped upstream, 0x80 and above becomes a function instead), and read.ts's applyToken relies on this range being gap-free to treat a decode as never failing for a byte it hands in. This is the test that invariant actually rests on: if the shorthand table and the ASCII range ever drifted apart and left a gap, this is what would catch it. + it("has no gap anywhere across its own documented domain (1 through 127)", () => { + const gaps: number[] = []; + for (let byte = 1; byte <= 0x7f; byte += 1) { + if (decodeSingleByteCharacter(byte) === undefined) { + gaps.push(byte); + } + } + expect(gaps).toEqual([]); + }); +}); + +describe("decodeWordString", () => { + it("reads each word's own high byte, not a neighbouring word's", () => { + // 'A' (ASCII, high byte 0), then character-set 5 number 0 ('♡', high byte 5), then the null terminator -- every existing caller only ever writes pure-ASCII words (high byte always 0), which cannot distinguish a word's own high byte from its neighbour's. + const bytes = new Uint8Array([0x41, 0, 0, 5, 0, 0]); + expect(decodeWordString(bytes, 0, 10)).toEqual({ + text: "A♡", + wordsRead: 3, + }); + }); }); diff --git a/packages/wpd-codec/src/stream/characters.ts b/packages/wpd-codec/src/stream/characters.ts index f751174355..67d11326ed 100644 --- a/packages/wpd-codec/src/stream/characters.ts +++ b/packages/wpd-codec/src/stream/characters.ts @@ -101,6 +101,9 @@ export function decodeSingleByteCharacter(byte: number): string | undefined { return undefined; } +// A caller with no separate length prefix of its own to bound the read -- it just wants "the rest of this buffer, read as a word string" -- passes this rather than computing its own arithmetic bound from the buffer's own remaining length: decodeWordString already stops at the first null word or the moment `bytes[]` itself answers undefined past the buffer's real end (see its own comment below), so any caller-computed cap merely restates that same stopping point and can never be observed to change the text decoded. `Number.POSITIVE_INFINITY` is a genuine sentinel for "no separate bound", not a magic number: the while loop's own `wordsRead < maxWords` holds for every finite wordsRead, exactly the "keep going until the buffer itself ends" behaviour these callers want. +export const UNBOUNDED_WORDS = Number.POSITIVE_INFINITY; + // Decodes a WP word string: a run of 16-bit values, each "the high byte is the number of the WordPerfect character set, the low byte contains an offset value into the character set", terminated by a null word. Used by packet data (a typeface name, a comment, a bookmark name), never by the document area's own byte stream. // // Reads at most `maxWords` words and stops at the first null word or at the end of the available bytes, whichever comes first -- an unterminated string is the packet running out, not a failure to raise, since a WordPerfect packet's own last string legitimately abuts the packet's end. diff --git a/packages/wpd-codec/src/stream/eol.test.ts b/packages/wpd-codec/src/stream/eol.test.ts new file mode 100644 index 0000000000..22552043e4 --- /dev/null +++ b/packages/wpd-codec/src/stream/eol.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + eolMappingForSubfunction, + FIRST_SINGLE_BYTE_EOL, + isSingleByteEol, + LAST_SINGLE_BYTE_EOL, + subfunctionForSingleByteEol, +} from "./eol"; + +describe("eolMappingForSubfunction", () => { + it("maps subfunction 0 (Beginning of File) to ignore", () => { + expect(eolMappingForSubfunction(0)).toBe("ignore"); + }); + + it("maps subfunction 28 (Deletable Hard EOP) to hardEndOfPage", () => { + expect(eolMappingForSubfunction(28)).toBe("hardEndOfPage"); + }); + + it("returns undefined outside the table", () => { + expect(eolMappingForSubfunction(29)).toBeUndefined(); + }); +}); + +describe("subfunctionForSingleByteEol", () => { + it("reverses the single-byte code back to its subfunction number", () => { + expect(subfunctionForSingleByteEol(0xcf)).toBe(1); + expect(subfunctionForSingleByteEol(0xb4)).toBe(28); + }); +}); + +describe("isSingleByteEol", () => { + it("is false one below the first single-byte code", () => { + expect(isSingleByteEol(FIRST_SINGLE_BYTE_EOL - 1)).toBe(false); + }); + + it("is true at the first single-byte code", () => { + expect(isSingleByteEol(FIRST_SINGLE_BYTE_EOL)).toBe(true); + }); + + it("is true at the last single-byte code", () => { + expect(isSingleByteEol(LAST_SINGLE_BYTE_EOL)).toBe(true); + }); + + it("is false one above the last single-byte code", () => { + expect(isSingleByteEol(LAST_SINGLE_BYTE_EOL + 1)).toBe(false); + }); +}); diff --git a/packages/wpd-codec/src/stream/formula.test.ts b/packages/wpd-codec/src/stream/formula.test.ts index 4c033758bb..1f84f5b562 100644 --- a/packages/wpd-codec/src/stream/formula.test.ts +++ b/packages/wpd-codec/src/stream/formula.test.ts @@ -10,6 +10,18 @@ function lengthPrefixedWordString(value: string): number[] { ]; } +// Code 30's own byte-string spelling: a 16-bit count, then that many raw ASCII bytes (one per character, not the two-byte word convention every other string-carrying code uses). +function lengthPrefixedByteString(value: string): number[] { + return [...word(value.length), ...[...value].map((c) => c.charCodeAt(0))]; +} + +// Code 30's own 8-byte IEEE-754 double, little-endian, per the SDK's own field description. +function doubleBytes(value: number): number[] { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, true); + return Array.from(new Uint8Array(buffer)); +} + const CELL_A1 = [64, ...word(0), ...word(0)]; // cell reference, no absolute flags: row 0, column 0 const CELL_B1 = [64, ...word(0), ...word(1)]; // row 0, column 1 @@ -84,4 +96,283 @@ describe("readTableFormula", () => { it("aborts on truncated input rather than reading past the end", () => { expect(readTableFormula(new Uint8Array([64, 0, 0, 0]))).toBeUndefined(); }); + + // Every existing cell reference names a column under 26 (a single base-26 digit), which the loop's own boundary happens to satisfy on its first pass regardless of when it stops -- only a two-digit column proves the loop actually continues into a second pass rather than stopping after exactly one. + it("renders a column at or past the base-26 rollover with two letters", () => { + // code 64, flags 0: row 0, column 26 -- the base-26 convention's "AA". + expect( + readTableFormula(new Uint8Array([64, ...word(0), ...word(26)])), + ).toBe("AA1"); + }); + + it("aborts a cell reference whose row is negative", () => { + // code 64: row = -1 (0xFFFF as a signed 16-bit read), column = 0. + expect( + readTableFormula(new Uint8Array([64, ...word(0xffff), ...word(0)])), + ).toBeUndefined(); + }); + + it("aborts a cell reference whose column is negative", () => { + expect( + readTableFormula(new Uint8Array([64, ...word(0), ...word(0xffff)])), + ).toBeUndefined(); + }); + + it("renders a literal run of spaces for code 25's own count", () => { + expect(readTableFormula(new Uint8Array([25, ...word(3)]))).toBe(" "); + }); + + it("aborts a space count with no room for its own 16-bit count", () => { + expect(readTableFormula(new Uint8Array([25, 0]))).toBeUndefined(); + }); + + it("decodes a plain, non-absolute cell reference (code 27)", () => { + expect(readTableFormula(new Uint8Array([27, ...word(2), ...word(0)]))).toBe( + "A3", + ); + }); + + it("decodes a plain, non-absolute range reference (code 28)", () => { + const bytes = new Uint8Array([ + 28, + ...word(0), + ...word(0), // start: A1 + ...word(2), + ...word(1), // end: B3 + ]); + expect(readTableFormula(bytes)).toBe("A1:B3"); + }); + + it("aborts a plain range reference whose end cell is truncated", () => { + const bytes = new Uint8Array([28, ...word(0), ...word(0)]); // start only, no end cell at all + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a plain range reference whose start cell's row is negative", () => { + const bytes = new Uint8Array([ + 28, + ...word(0xffff), + ...word(0), + ...word(0), + ...word(0), + ]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a plain range reference whose end cell's row is negative", () => { + const bytes = new Uint8Array([ + 28, + ...word(0), + ...word(0), + ...word(0xffff), + ...word(0), + ]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("decodes a floating point constant from its double and confirms the spelling is present", () => { + const bytes = new Uint8Array([ + 30, + ...doubleBytes(3.5), + ...lengthPrefixedByteString("3.5"), + ]); + expect(readTableFormula(bytes)).toBe("3.5"); + }); + + it("reads the double as little-endian, not big-endian", () => { + // 1.0's little-endian IEEE-754 bytes read back as a wildly different (but still finite) value in big-endian order, so misreading the endianness is easy to observe through String(value) rather than only through a coincidental NaN. + const bytes = new Uint8Array([ + 30, + ...doubleBytes(1), + ...lengthPrefixedByteString("1"), + ]); + expect(readTableFormula(bytes)).toBe("1"); + }); + + it("continues correctly after a floating point constant's own spelling, not one byte off", () => { + const bytes = new Uint8Array([ + 30, + ...doubleBytes(2), + ...lengthPrefixedByteString("2"), + 1, // + + 30, + ...doubleBytes(3), + ...lengthPrefixedByteString("3"), + ]); + expect(readTableFormula(bytes)).toBe("2+3"); + }); + + it("aborts a floating point constant with no room for its own 8-byte double", () => { + expect( + readTableFormula(new Uint8Array([30, ...doubleBytes(1).slice(0, 4)])), + ).toBeUndefined(); + }); + + it("aborts a floating point constant whose own spelling has no room for its length prefix", () => { + const bytes = new Uint8Array([30, ...doubleBytes(1), 0]); // one stray byte, not the two the length prefix needs + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("accepts a floating point constant whose own spelling is empty, rather than rejecting it", () => { + const bytes = new Uint8Array([30, ...doubleBytes(1), ...word(0)]); + expect(readTableFormula(bytes)).toBe("1"); + }); + + it("decodes a user argument reference by its own number", () => { + expect(readTableFormula(new Uint8Array([31, ...word(7)]))).toBe("ARG7"); + }); + + it("aborts a user argument reference with no room for its own number", () => { + expect(readTableFormula(new Uint8Array([31, 0]))).toBeUndefined(); + }); + + it("decodes a user function call by its own verbatim name", () => { + const bytes = new Uint8Array([32, ...lengthPrefixedWordString("MYFUNC")]); + expect(readTableFormula(bytes)).toBe("MYFUNC"); + }); + + it("skips an attribute-off marker (42), contributing no text", () => { + const bytes = new Uint8Array([...CELL_A1, 42, ...word(1), 1, ...CELL_B1]); + expect(readTableFormula(bytes)).toBe("A1+B1"); + }); + + it("skips a total-attribute-mask marker (43), contributing no text", () => { + const bytes = new Uint8Array([...CELL_A1, 43, ...word(1), 1, ...CELL_B1]); + expect(readTableFormula(bytes)).toBe("A1+B1"); + }); + + it("skips a conditional-attribute marker (44), contributing no text", () => { + const bytes = new Uint8Array([...CELL_A1, 44, 1, ...CELL_B1]); + expect(readTableFormula(bytes)).toBe("A1+B1"); + }); + + // Every one of the four absolute-reference flag bits a range reference (codes 48-63) carries, isolated one at a time -- the existing "decodes an absolute range reference" test only ever sets bits 2 and 3 together (0x0C), which cannot tell any one of the four apart from the others. + it.each([ + [0x01, "A1:$B3"], // bit 0: end column absolute + [0x02, "A1:B$3"], // bit 1: end row absolute + [0x04, "$A1:B3"], // bit 2: start column absolute + [0x08, "A$1:B3"], // bit 3: start row absolute + ])("marks only the range reference flag bit 0x%s absolute", (flag, text) => { + const bytes = new Uint8Array([ + 48 + flag, + ...word(0), + ...word(0), // start: A1 + ...word(2), + ...word(1), // end: B3 + ]); + expect(readTableFormula(bytes)).toBe(text); + }); + + it("treats code 47 (one below the range-reference range) as unrecognised, even with a full range reference's own bytes following it", () => { + // Full, well-formed range-reference bytes follow the code, so a lower bound that quietly slipped would decode a range instead of refusing it. + const bytes = new Uint8Array([ + 47, + ...word(0), + ...word(0), + ...word(2), + ...word(1), + ]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("decodes a range reference at 63, the top of its own code range", () => { + const bytes = new Uint8Array([ + 48 + 15, // 63: every flag bit set + ...word(0), + ...word(0), + ...word(2), + ...word(1), + ]); + expect(readTableFormula(bytes)).toBe("$A$1:$B$3"); + }); + + it("decodes a range reference at 48, the bottom of its own code range, with no absolute flags at all", () => { + const bytes = new Uint8Array([ + 48, + ...word(0), + ...word(0), + ...word(2), + ...word(1), + ]); + expect(readTableFormula(bytes)).toBe("A1:B3"); + }); + + it("aborts a range reference (48-63) whose start cell's row is negative, even though the end cell is well-formed", () => { + const bytes = new Uint8Array([ + 48, + ...word(0xffff), + ...word(0), // start: row -1 + ...word(2), + ...word(1), // end: B3, well-formed + ]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a range reference (48-63) whose end cell's row is negative, even though the start cell is well-formed", () => { + const bytes = new Uint8Array([ + 48, + ...word(0), + ...word(0), // start: A1, well-formed + ...word(0xffff), + ...word(1), // end: row -1 + ]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + // Every one of the two absolute-reference flag bits an absolute cell reference (codes 64-67) carries, isolated one at a time. + it.each([ + [0x01, "$B3"], // bit 0: column absolute + [0x02, "B$3"], // bit 1: row absolute + ])("marks only the cell reference flag bit 0x%s absolute", (flag, text) => { + const bytes = new Uint8Array([64 + flag, ...word(2), ...word(1)]); + expect(readTableFormula(bytes)).toBe(text); + }); + + it("decodes a cell reference at 67, the top of its own code range", () => { + const bytes = new Uint8Array([67, ...word(2), ...word(1)]); // both flags set + expect(readTableFormula(bytes)).toBe("$B$3"); + }); + + it("treats code 68 (one past the cell-reference range) as unrecognised, even with a full cell reference's own bytes following it", () => { + const bytes = new Uint8Array([68, ...word(0), ...word(0)]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a length-prefixed word string with no room for its own 16-bit count", () => { + // Code 8 (number constant) is the simplest carrier of readLengthPrefixedWordString's shared boundary. + expect(readTableFormula(new Uint8Array([8]))).toBeUndefined(); + }); + + it("reads a zero-length word string at the exact boundary where its own 16-bit count just fits", () => { + // Exactly 2 bytes remain for the count field itself, declaring zero characters -- the tie where "no room" and "just enough room" disagree. + expect(readTableFormula(new Uint8Array([8, ...word(0)]))).toBe(""); + }); + + it("aborts a string constant (code 9) with no room for its own 16-bit count, rather than quoting the word 'undefined'", () => { + // Code 9 wraps its text in quotes unconditionally on the way out, so unlike code 8 this is the one carrier where a skipped abort would produce a defined (wrong) string instead of quietly converging back to undefined. + expect(readTableFormula(new Uint8Array([9]))).toBeUndefined(); + }); + + it("aborts a length-prefixed word string whose declared length runs past the words actually present", () => { + // Declares 5 characters but supplies only one word's worth of bytes, so decodeWordString stops short of the declared length. + const bytes = new Uint8Array([8, ...word(5), ...word(65)]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a floating point constant's byte-string spelling that runs past the bytes actually present", () => { + // The length prefix itself is intact (declares 3 bytes) but only one byte of the spelling follows. + const bytes = new Uint8Array([30, ...doubleBytes(1), ...word(3), 0x31]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a floating point constant with no spelling data at all after its own double, rather than emitting the double's own text with no more tokens to fail on", () => { + // Nothing at all follows the 8-byte double -- not even the 2 bytes a spelling's own length prefix needs. With no further token left in the stream to independently fail on, this is the one case that actually observes whether the missing spelling aborts the whole token or is silently ignored. + const bytes = new Uint8Array([30, ...doubleBytes(1)]); + expect(readTableFormula(bytes)).toBeUndefined(); + }); + + it("aborts a range reference (48-63) whose start cell has no room at all", () => { + const bytes = new Uint8Array([48, ...word(0)]); // 2 bytes: one short of the 4 a cell reference needs + expect(readTableFormula(bytes)).toBeUndefined(); + }); }); diff --git a/packages/wpd-codec/src/stream/formula.ts b/packages/wpd-codec/src/stream/formula.ts index 011a4fe73b..a83107cb0b 100644 --- a/packages/wpd-codec/src/stream/formula.ts +++ b/packages/wpd-codec/src/stream/formula.ts @@ -1,4 +1,4 @@ -import { int16At, uint16At } from "../bytes/view"; +import { int16At, sliceAt, uint16At } from "../bytes/view"; import { decodeWordString } from "./characters"; // -- Table formulas, per WPFF Table Formula Functions -- @@ -209,21 +209,12 @@ function readLengthPrefixedWordString( return text; } -// Code 30's own byte string: identical length convention, one byte per character instead of two. -function readLengthPrefixedByteString( - cursor: FormulaCursor, -): string | undefined { - if (cursor.offset + 2 > cursor.bytes.length) { - return undefined; - } - const length = uint16At(cursor.bytes, cursor.offset); +// Code 30's own byte string: identical length convention, one byte per character instead of two. Its only caller (the floating point constant below) discards the decoded text and checks only whether this consumed the spelling successfully -- the double it already read is the value it reports -- so this validates and advances the cursor without building a string nothing reads. Throws (via sliceAt) rather than returning a sentinel when the length prefix or the spelling itself does not fit; the caller wraps the whole token in a try/catch. +function skipLengthPrefixedByteString(cursor: FormulaCursor): void { + const length = uint16At(sliceAt(cursor.bytes, cursor.offset, 2), 0); cursor.offset += 2; - if (cursor.offset + length > cursor.bytes.length) { - return undefined; - } - const slice = cursor.bytes.subarray(cursor.offset, cursor.offset + length); + sliceAt(cursor.bytes, cursor.offset, length); cursor.offset += length; - return Array.from(slice, (byte) => String.fromCharCode(byte)).join(""); } function readCellNumber( @@ -304,9 +295,9 @@ function readToken(cursor: FormulaCursor): string | undefined { : cellReferenceText(cell.row, cell.column, false, false); } case 28: { - // range reference (documented "not used"): two plain cell references joined by ":". + // range reference (documented "not used"): two plain cell references joined by ":". readCellNumber's own insufficient-bytes check returns before advancing cursor.offset, so always attempting the second read even when the first failed is safe -- it reads from the identical position and fails identically. const start = readCellNumber(cursor); - const end = start === undefined ? undefined : readCellNumber(cursor); + const end = readCellNumber(cursor); if (start === undefined || end === undefined) { return undefined; } @@ -323,18 +314,20 @@ function readToken(cursor: FormulaCursor): string | undefined { } case 30: { // floating point constant: an 8-byte double, then its own byte-string spelling. The double is authoritative; the string is the user's own typed spelling and is skipped past rather than re-decoded, since JavaScript's own number-to-string conversion already gives a faithful textual value. - if (cursor.offset + 8 > cursor.bytes.length) { + try { + const doubleBytes = sliceAt(cursor.bytes, cursor.offset, 8); + const view = new DataView( + doubleBytes.buffer, + doubleBytes.byteOffset, + 8, + ); + const value = view.getFloat64(0, true); + cursor.offset += 8; + skipLengthPrefixedByteString(cursor); + return String(value); + } catch { return undefined; } - const view = new DataView( - cursor.bytes.buffer, - cursor.bytes.byteOffset + cursor.offset, - 8, - ); - const value = view.getFloat64(0, true); - cursor.offset += 8; - const spelling = readLengthPrefixedByteString(cursor); - return spelling === undefined ? undefined : String(value); } case 31: { // user argument reference: [argument number]. @@ -350,13 +343,9 @@ function readToken(cursor: FormulaCursor): string | undefined { return readLengthPrefixedWordString(cursor); } case 41: - case 42: { - // attribute on/off: a formatting marker inside the formula's own displayed spelling, contributing no text to its computed meaning. - cursor.offset += 2; - return ""; - } + case 42: case 43: { - // total attribute mask. + // attribute on/off and the total attribute mask: formatting markers inside the formula's own displayed spelling, contributing no text to its computed meaning. cursor.offset += 2; return ""; } @@ -365,10 +354,11 @@ function readToken(cursor: FormulaCursor): string | undefined { return ""; default: { if (code >= 48 && code <= 63) { - // range reference, absolute-flag bits per the SDK's own NOTE: bit0/1 on the bottom-right cell, bit2/3 on the top-left cell. - const flags = code - 48; + // range reference, absolute-flag bits per the SDK's own NOTE: bit0/1 on the bottom-right cell, bit2/3 on the top-left cell. Codes in this range share a fixed high nibble, so their own low 4 bits (code & 0x0f) are exactly code - 48 -- a mask on the bits this flags value is ever actually read through, not an offsetting subtraction. + const flags = code & 0x0f; + // readCellNumber's own insufficient-bytes check returns before advancing cursor.offset, so always attempting the second read even when the first failed is safe -- it reads from the identical position and fails identically. const start = readCellNumber(cursor); - const end = start === undefined ? undefined : readCellNumber(cursor); + const end = readCellNumber(cursor); if (start === undefined || end === undefined) { return undefined; } @@ -389,8 +379,8 @@ function readToken(cursor: FormulaCursor): string | undefined { : `${startText}:${endText}`; } if (code >= 64 && code <= 67) { - // cell reference, absolute-flag bits per the same NOTE: bit0 column, bit1 row. - const flags = code - 64; + // cell reference, absolute-flag bits per the same NOTE: bit0 column, bit1 row. Codes in this range share a fixed high bit pattern, so their own low 2 bits (code & 0x03) are exactly code - 64 -- a mask on the bits this flags value is ever actually read through, not an offsetting subtraction. + const flags = code & 0x03; const cell = readCellNumber(cursor); return cell === undefined ? undefined diff --git a/packages/wpd-codec/src/stream/furniture.test.ts b/packages/wpd-codec/src/stream/furniture.test.ts new file mode 100644 index 0000000000..1d36912094 --- /dev/null +++ b/packages/wpd-codec/src/stream/furniture.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + FOOTER_A, + FOOTER_B, + HEADER_A, + HEADER_B, + readFurnitureClaim, + WATERMARK_A, + WATERMARK_B, +} from "./furniture"; + +// Direct unit coverage of the subgroup-to-kind dispatch, isolated from the page.ts integration tests, which only ever exercise one subgroup per kind at a time and so cannot distinguish "the B slot of a kind" from "no claim at all". +describe("readFurnitureClaim", () => { + const bothParities = new Uint8Array([0b11]); + const oddOnly = new Uint8Array([0b01]); + const evenOnly = new Uint8Array([0b10]); + const neitherParity = new Uint8Array([0b00]); + + it("claims header for both HEADER_A and HEADER_B", () => { + expect(readFurnitureClaim(HEADER_A, bothParities)).toEqual({ + kind: "header", + slot: "default", + }); + expect(readFurnitureClaim(HEADER_B, bothParities)).toEqual({ + kind: "header", + slot: "default", + }); + }); + + it("claims footer for both FOOTER_A and FOOTER_B", () => { + expect(readFurnitureClaim(FOOTER_A, bothParities)).toEqual({ + kind: "footer", + slot: "default", + }); + expect(readFurnitureClaim(FOOTER_B, bothParities)).toEqual({ + kind: "footer", + slot: "default", + }); + }); + + it("claims watermark for both WATERMARK_A and WATERMARK_B", () => { + expect(readFurnitureClaim(WATERMARK_A, bothParities)).toEqual({ + kind: "watermark", + slot: "default", + }); + expect(readFurnitureClaim(WATERMARK_B, bothParities)).toEqual({ + kind: "watermark", + slot: "default", + }); + }); + + it("claims nothing for a subgroup outside the six known slots", () => { + expect(readFurnitureClaim(0x06, bothParities)).toBe("none"); + }); + + it("claims the default slot for odd-only occurrence", () => { + expect(readFurnitureClaim(HEADER_A, oddOnly)).toEqual({ + kind: "header", + slot: "default", + }); + }); + + it("claims the even slot for even-only occurrence", () => { + expect(readFurnitureClaim(HEADER_A, evenOnly)).toEqual({ + kind: "header", + slot: "even", + }); + }); + + it("claims nothing when neither parity bit is set", () => { + expect(readFurnitureClaim(HEADER_A, neitherParity)).toBe("none"); + }); + + it("treats a missing non-deletable byte as occurring on neither parity", () => { + expect(readFurnitureClaim(HEADER_A, new Uint8Array())).toBe("none"); + }); +}); diff --git a/packages/wpd-codec/src/stream/image.test.ts b/packages/wpd-codec/src/stream/image.test.ts new file mode 100644 index 0000000000..6ccdd12152 --- /dev/null +++ b/packages/wpd-codec/src/stream/image.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from "vitest"; +import { + bigEndianUint16At, + bigEndianUint32At, + scanImagePayload, +} from "./image"; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function u32be(value: number): number[] { + return [ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]; +} + +function u16be(value: number): number[] { + return [(value >>> 8) & 0xff, value & 0xff]; +} + +function pngChunk(type: string, data: readonly number[]): number[] { + return [ + ...u32be(data.length), + ...Array.from(type, (c) => c.charCodeAt(0)), + ...data, + 0, + 0, + 0, + 0, // crc, not verified by the scanner + ]; +} + +// A minimal well-formed PNG: signature, IHDR, IDAT, IEND. +function tinyPng(): number[] { + return [ + ...PNG_SIGNATURE, + ...pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]), + ...pngChunk("IDAT", [0x78, 0x01]), + ...pngChunk("IEND", []), + ]; +} + +// A JPEG marker segment: FF [length incl. itself] . SOI, standalone markers, and SOS are built separately since they don't share this shape. +function jpegSegment(marker: number, data: readonly number[]): number[] { + return [0xff, marker, ...u16be(data.length + 2), ...data]; +} + +// A minimal well-formed JPEG: SOI, one APP0 segment, SOS with a length header, entropy-coded scan data (including a stuffed FF00, which must not be mistaken for EOI), then EOI. +function tinyJpeg( + options: { readonly scanData?: readonly number[] } = {}, +): number[] { + const scanData = options.scanData ?? [0x12, 0xff, 0x00, 0x34]; + return [ + 0xff, + 0xd8, // SOI + ...jpegSegment(0xe0, [0x4a, 0x46, 0x49, 0x46, 0x00]), // APP0 + ...jpegSegment(0xda, [0x01, 0x02, 0x03]), // SOS header + ...scanData, + 0xff, + 0xd9, // EOI + ]; +} + +describe("bigEndianUint32At", () => { + // A distinct, nonzero digit in every byte position, so a wrong sign between any two terms or a wrong operator on any one of them changes the result -- every real PNG/JPEG fixture below keeps its own chunk/segment lengths small, leaving every byte but the last at zero, where a wrong sign or operator on that term would go unobserved. + it("assembles four bytes into one big-endian 32-bit value", () => { + expect(bigEndianUint32At(new Uint8Array([0x12, 0x34, 0x56, 0x78]), 0)).toBe( + 0x12345678, + ); + }); +}); + +describe("bigEndianUint16At", () => { + it("assembles two bytes into one big-endian 16-bit value", () => { + expect(bigEndianUint16At(new Uint8Array([0x12, 0x34]), 0)).toBe(0x1234); + }); +}); + +describe("scanImagePayload", () => { + it("returns undefined for a packet with neither signature at all", () => { + expect(scanImagePayload(new Uint8Array([1, 2, 3, 4, 5]))).toBeUndefined(); + }); + + // PNG_SIGNATURE's own first byte, with no room left for the other seven: a fit check that let this position through anyway would matter here, since bytesMatchAt now throws for an out-of-range byte rather than silently answering false, and the mismatched bytes elsewhere in this file's other fixtures never happen to start with 0x89 this close to the buffer's own end. + it("does not attempt a PNG signature match too close to the buffer's own end to ever complete", () => { + expect(scanImagePayload(new Uint8Array([1, 2, 3, 0x89]))).toBeUndefined(); + }); + + // JPEG_SOI's own first byte (0xFF), with no room for the second: the same fit-check concern as the PNG case above, isolated to the shorter signature. + it("does not attempt a JPEG signature match too close to the buffer's own end to ever complete", () => { + expect(scanImagePayload(new Uint8Array([1, 2, 3, 0xff]))).toBeUndefined(); + }); + + describe("PNG", () => { + it("rejects an IEND chunk whose own declared length runs past the buffer, rather than accepting a truncated span", () => { + // The IEND check itself sits right after the overrun guard: skipping that guard would let a lying IEND chunk (declaring far more data than the buffer actually holds) slip through and return a truncated-but-defined span instead of refusing. + const bytes = new Uint8Array([ + ...PNG_SIGNATURE, + ...u32be(1000), // claims 1000 bytes of chunk data + ...Array.from("IEND", (c) => c.charCodeAt(0)), + // no data, no crc -- the buffer ends immediately after the type + ]); + expect(scanImagePayload(bytes)).toBeUndefined(); + }); + + it("lifts a well-formed PNG payload, bounded exactly by its own chunk chain", () => { + const png = tinyPng(); + const bytes = new Uint8Array([9, 9, 9, ...png, 7, 7, 7]); // real prefix/suffix garbage + const result = scanImagePayload(bytes); + expect(result?.format).toBe("png"); + expect(Array.from(result?.bytes ?? [])).toEqual(png); + }); + + it("rejects a PNG whose chunk chain never reaches IEND before the buffer ends", () => { + const bytes = new Uint8Array([ + ...PNG_SIGNATURE, + ...pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]), + // no IEND -- the buffer simply ends + ]); + expect(scanImagePayload(bytes)).toBeUndefined(); + }); + + it("rejects a PNG chunk whose own length runs past the buffer", () => { + const bytes = new Uint8Array([ + ...PNG_SIGNATURE, + ...u32be(1000), // claims 1000 bytes of chunk data + ...Array.from("IHDR", (c) => c.charCodeAt(0)), + 0, + 0, // far fewer real bytes than claimed + ]); + expect(scanImagePayload(bytes)).toBeUndefined(); + }); + + it("rejects a PNG cut off before its first chunk header is even complete", () => { + const bytes = new Uint8Array([...PNG_SIGNATURE, 0, 0, 0]); // 3 bytes of an 8-byte chunk header + expect(scanImagePayload(bytes)).toBeUndefined(); + }); + + // A chunk whose length + header + crc lands exactly on the buffer's own end -- the one boundary where "runs past" and "fits exactly" disagree. + it("accepts a final chunk whose own extent exactly fills the rest of the buffer", () => { + const bytes = new Uint8Array([ + ...PNG_SIGNATURE, + ...pngChunk("IEND", []), // IEND with no data, ending exactly at the buffer's own end + ]); + const result = scanImagePayload(bytes); + expect(result?.format).toBe("png"); + expect(result?.bytes.length).toBe(bytes.length); + }); + }); + + describe("JPEG", () => { + it("lifts a well-formed JPEG payload ending at a plain EOI, bounded exactly", () => { + const jpeg = tinyJpeg(); + const bytes = new Uint8Array([9, 9, 9, ...jpeg, 7, 7, 7]); + const result = scanImagePayload(bytes); + expect(result?.format).toBe("jpeg"); + expect(Array.from(result?.bytes ?? [])).toEqual(jpeg); + }); + + it("skips a run of fill bytes (0xFF) before a real marker", () => { + const jpeg = [ + 0xff, + 0xd8, // SOI + 0xff, + 0xff, + 0xff, // fill bytes + ...jpegSegment(0xe0, [0x00]), + 0xff, + 0xd9, // EOI + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("treats a restart marker (0xD0-0xD7) as standalone, carrying no length of its own", () => { + const jpeg = [ + 0xff, + 0xd8, // SOI + 0xff, + 0xd0, // RST0, standalone + ...jpegSegment(0xe0, [0x00]), + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("treats TEM (0x01) as standalone, carrying no length of its own", () => { + const jpeg = [ + 0xff, + 0xd8, + 0xff, + 0x01, // TEM, standalone + ...jpegSegment(0xe0, [0x00]), + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("does not treat a marker just outside the restart range as standalone", () => { + // 0xD8 (SOI) reappearing mid-stream is not itself one of the RST0-RST7 (0xD0-0xD7) codes but IS separately named standalone -- 0xCF, one below 0xD0, is neither, and must be read as an ordinary length-carrying segment. + const jpeg = [ + 0xff, + 0xd8, + ...jpegSegment(0xcf, [0x00]), // an ordinary (fictitious) marker just below the restart range + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("rejects a JPEG with a stray non-0xFF byte where a marker prefix was expected", () => { + const jpeg = [0xff, 0xd8, 0x12, 0x34]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a bare EOI byte value that never had its own 0xFF marker prefix", () => { + // Skipping the marker-prefix guard would let this 0xD9 byte itself be read as the next marker, wrongly matching the EOI case and returning a defined (truncated) payload instead of refusing. + const jpeg = [0xff, 0xd8, 0xd9]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("treats 0xD7, the top of the restart range, as standalone", () => { + const jpeg = [ + 0xff, + 0xd8, + 0xff, + 0xd7, // RST7, the restart range's own upper bound + ...jpegSegment(0xe0, [0x00]), + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("treats SOI (0xD8) reappearing mid-stream as standalone, not a length-carrying marker", () => { + const jpeg = [ + 0xff, + 0xd8, + 0xff, + 0xd8, // SOI again, mid-stream + ...jpegSegment(0xe0, [0x00]), + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("rejects a JPEG that ends right after SOI, with no marker at all", () => { + expect(scanImagePayload(new Uint8Array([0xff, 0xd8]))).toBeUndefined(); + }); + + it("rejects a JPEG that ends in a run of fill bytes with no real marker after them", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xff, 0xff]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a length-carrying marker with no room for its own two-byte length", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xe0]; // APP0, no length bytes at all + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a marker whose own stated length is less than the two length bytes themselves", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xe0, ...u16be(1)]; // length 1, smaller than the length field's own two bytes + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a marker whose own stated length runs past the buffer", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xe0, ...u16be(100)]; // claims 100 bytes total, far more than remain + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a length-too-small marker even with plenty of trailing bytes, isolating that check from the overrun check", () => { + // length 1 alone must refuse this, with none of the overrun arithmetic coming into play (there is ample room left). + const jpeg = [ + 0xff, + 0xd8, + 0xff, + 0xe0, + ...u16be(1), + 0, + 0, + 0, + 0, + 0xff, + 0xd9, + ]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("rejects a length that is at least 2 but still overruns the buffer, isolating that check from the too-small check", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xe0, ...u16be(3)]; // length 3 (not < 2), but nothing follows the length field at all + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("accepts a segment whose own length is exactly 2 (no data at all), proving the boundary is < 2 and not <= 2", () => { + const jpeg = [0xff, 0xd8, 0xff, 0xe0, ...u16be(2), 0xff, 0xd9]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + }); + + it("refuses a malformed SOS length rather than searching arbitrarily far ahead for an EOI that happens to exist", () => { + // A length of 1 is invalid (smaller than the length field's own two bytes); skipping that guard for SOS specifically would let the entropy-search fall through to indexOf and find this later, genuine FF D9 -- masking the real malformed-length defect with a false decode. + const jpeg = [ + 0xff, + 0xd8, + 0xff, + 0xda, + ...u16be(1), + 0x12, + 0x34, + 0xff, + 0xd9, + ]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("refuses garbage following an ordinary segment rather than searching ahead for an EOI as if it were SOS", () => { + // If the ordinary APP0 marker were ever treated as SOS, the entropy search would ignore that the very next byte is not a valid marker prefix at all, and would instead find this later, genuine FF D9. + const jpeg = [ + 0xff, + 0xd8, + ...jpegSegment(0xe0, [0x00]), + 0x11, + 0x22, // garbage: not a marker prefix + 0xff, + 0xd9, + ]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + // A segment whose own length exactly consumes the rest of the buffer -- the boundary where "runs past" and "fits exactly" disagree. + it("accepts a length-carrying segment whose own extent exactly fills the rest of the buffer, then correctly finds no EOI", () => { + const jpeg = [0xff, 0xd8, ...jpegSegment(0xe0, [0x00])]; // nothing after the segment at all + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("continues past an ordinary segment to read the marker that follows it, not a byte off", () => { + const jpeg = [ + 0xff, + 0xd8, + ...jpegSegment(0xe0, [0x01, 0x02, 0x03]), // 3 bytes of real data, cursor must land exactly after it + 0xff, + 0xd9, + ]; + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + expect(result?.bytes.length).toBe(jpeg.length); + }); + + it("rejects a scan (SOS) whose entropy-coded data never reaches an EOI", () => { + const jpeg = [ + 0xff, + 0xd8, + ...jpegSegment(0xda, [0x01]), + 0x12, + 0x34, + 0x56, // entropy data, no FF D9 anywhere + ]; + expect(scanImagePayload(new Uint8Array(jpeg))).toBeUndefined(); + }); + + it("does not mistake a stuffed FF00 inside entropy-coded data for EOI", () => { + const jpeg = tinyJpeg({ scanData: [0xff, 0x00, 0xff, 0x00] }); + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + expect(result?.bytes.length).toBe(jpeg.length); + }); + + // The EOI marker landing exactly on the buffer's own final two bytes -- the one boundary where indexOf's own "does the needle still fit" check matters, since a strictly-off-by-one version would fail to find an EOI that is genuinely, completely present. + it("finds an EOI that is exactly the buffer's own last two bytes", () => { + const jpeg = tinyJpeg(); + expect(jpeg[jpeg.length - 2]).toBe(0xff); + expect(jpeg[jpeg.length - 1]).toBe(0xd9); + const result = scanImagePayload(new Uint8Array(jpeg)); + expect(result?.format).toBe("jpeg"); + expect(result?.bytes.length).toBe(jpeg.length); + }); + }); + + describe("choosing between PNG and JPEG when both are present", () => { + it("prefers whichever signature appears first when both are present, PNG first", () => { + const png = tinyPng(); + const jpeg = tinyJpeg(); + const bytes = new Uint8Array([...png, ...jpeg]); + expect(scanImagePayload(bytes)?.format).toBe("png"); + }); + + it("prefers whichever signature appears first when both are present, JPEG first", () => { + const png = tinyPng(); + const jpeg = tinyJpeg(); + const bytes = new Uint8Array([...jpeg, ...png]); + expect(scanImagePayload(bytes)?.format).toBe("jpeg"); + }); + + it("falls back to JPEG when only JPEG's signature is present", () => { + const jpeg = tinyJpeg(); + expect(scanImagePayload(new Uint8Array(jpeg))?.format).toBe("jpeg"); + }); + }); +}); diff --git a/packages/wpd-codec/src/stream/image.ts b/packages/wpd-codec/src/stream/image.ts index 3411808a2a..262e945605 100644 --- a/packages/wpd-codec/src/stream/image.ts +++ b/packages/wpd-codec/src/stream/image.ts @@ -2,7 +2,7 @@ // // 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"; +import { byteAt, sliceAt } from "../bytes/view"; const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const; const JPEG_SOI = [0xff, 0xd8] as const; @@ -12,48 +12,71 @@ export interface WpdImagePayload { 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. +// Whether `signature` occurs at exactly `index`. Deliberately NOT safe for an index too close to the buffer's own end: byteAt throws rather than silently reading back `undefined`, so both of this function's own callers must -- and do -- check "does the signature still fit here" themselves before ever calling this, the same way every other bounds-checked read in this package works. A raw bracket read here instead would make that same fit check unobservably redundant with a would-be `undefined` comparison, exactly the equivalent-mutant trap this package's own byteAt/uint16At-based reads elsewhere are already built to avoid. +function bytesMatchAt( + bytes: Uint8Array, + index: number, + signature: readonly number[], +): boolean { + return signature.every( + (byte, offset) => byteAt(bytes, index + offset) === byte, + ); +} + +// The first byte offset at which `needle` occurs in `bytes` at or after `from`. A plain scan: packet payloads are small (an embedded figure), and no container this module knows of would justify a fancier search. Carries no "ran off the end, give up" check of its own: bytesMatchAt throws once a position genuinely has no room left for needle, and this function's one real caller (scanJpeg's own entropy-coded-data search) already wraps its whole walk in a try/catch that answers undefined for exactly that case -- a second, separate bounds check here would only ever be exercised on inputs the outer catch already handles identically, an unobservable, unkillable duplicate of it. 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; - } +): number { + for (let index = from; ; index += 1) { + if (bytesMatchAt(bytes, index, needle)) { + return index; } - return i; } - return undefined; +} + +// Big-endian (network byte order) reads: both PNG chunk lengths and JPEG segment lengths use this convention, the opposite of the little-endian convention every read in this package's own bytes/view.ts assumes for WordPerfect's own fields -- so these live here, not there. Built on byteAt, whose own bounds check throws rather than returning undefined, so a truncated read anywhere in either scan below surfaces as one caught exception instead of a separate manual length comparison at every call site. +// +// Exported for this package's own tests only, so each byte's own place value is proven directly with a distinct, nonzero digit in every position -- every real PNG/JPEG fixture this module's own tests otherwise construct keeps its chunk/segment lengths small, meaning every byte but the last stays zero and a wrong sign or operator on one of those upper-byte terms would go unobserved (0 added, subtracted, multiplied, or divided is still 0). +export function bigEndianUint32At(bytes: Uint8Array, offset: number): number { + return ( + byteAt(bytes, offset) * 0x1000000 + + byteAt(bytes, offset + 1) * 0x10000 + + byteAt(bytes, offset + 2) * 0x100 + + byteAt(bytes, offset + 3) + ); +} + +export function bigEndianUint16At(bytes: Uint8Array, offset: number): number { + return byteAt(bytes, offset) * 0x100 + byteAt(bytes, offset + 1); } 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); + // 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. Every read below is bounds-checked by byteAt or sliceAt themselves, whose own throw -- caught once, below -- stands in for a truncated or malformed embed rather than a best-effort span. 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) }; + try { + for (;;) { + const length = bigEndianUint32At(bytes, 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 (type === "IEND") { + return { + format: "png", + bytes: sliceAt(bytes, signatureAt, cursor - signatureAt), + }; + } } + } catch { + return undefined; } } @@ -61,63 +84,65 @@ 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); + // 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. Every positional read below is bounds-checked by byteAt or sliceAt themselves, whose own throw -- caught once, below -- stands in for a stream that runs out anywhere in this walk. 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) { + try { + for (;;) { + if (byteAt(bytes, cursor) !== 0xff) { + return undefined; + } + while (byteAt(bytes, cursor) === 0xff) { + cursor += 1; + } + const marker = byteAt(bytes, cursor); 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) { + const standalone = + marker === 0xd8 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7); + if (standalone) { + continue; + } + if (marker === 0xd9) { + return { format: "jpeg", bytes: bytes.subarray(soiAt, cursor) }; + } + const length = bigEndianUint16At(bytes, cursor); + if (length < 2) { return undefined; } - return { format: "jpeg", bytes: bytes.subarray(soiAt, eoi + 2) }; + // The segment's own declared extent, bounds-checked by sliceAt itself; its length (equal to `length` when it does not throw) is what actually advances the cursor, rather than a separately-mutable "cursor + length > bytes.length" comparison of its own. + cursor += sliceAt(bytes, cursor, length).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. + // indexOf itself throws (caught by this function's own try/catch, below) rather than returning undefined for entropy-coded data that never reaches an EOI. + const eoi = indexOf(bytes, [0xff, 0xd9], cursor); + return { format: "jpeg", bytes: bytes.subarray(soiAt, eoi + 2) }; + } } + } catch { + return undefined; } } -// 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. +// Scans a packet's bytes for the first whole PNG or JPEG payload. A single left-to-right walk checks both signatures at every position, so whichever this reaches first genuinely is first -- there is no need to locate both signatures independently and then compare their positions. Both absent is the common case (a WPG or OLE payload) and answers undefined. 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); + // No `index < bytes.length` bound of its own, and no `index + PNG_SIGNATURE.length <= bytes.length` room check either: both would be genuinely unobservable arithmetic (scanPng/scanJpeg always need at least one more byte after a signature to ever answer anything but undefined, so the one boundary position such a check would even change the answer for -- a signature exactly filling what remains -- can never produce a defined result via either path). PNG_SIGNATURE's own longer, 8-byte check is wrapped in its own try/catch instead: JPEG_SOI (2 bytes) is checked second and unguarded, and once even IT cannot fit -- the shortest either signature could ever need -- its own throw is exactly the "no signature could start anywhere in what remains" signal, caught once, below, ending the whole scan rather than one position of it. + try { + for (let index = 0; ; index += 1) { + try { + if (bytesMatchAt(bytes, index, PNG_SIGNATURE)) { + return scanPng(bytes, index); + } + } catch { + // PNG_SIGNATURE's own 8 bytes don't fit this close to the end; JPEG_SOI's shorter 2 might still. + } + if (bytesMatchAt(bytes, index, JPEG_SOI)) { + return scanJpeg(bytes, index); + } + } + } catch { + return undefined; } - return undefined; } diff --git a/packages/wpd-codec/src/stream/ole.test.ts b/packages/wpd-codec/src/stream/ole.test.ts index 53e345a594..c6bd5dad3d 100644 --- a/packages/wpd-codec/src/stream/ole.test.ts +++ b/packages/wpd-codec/src/stream/ole.test.ts @@ -119,6 +119,14 @@ describe("readOleDescriptor", () => { ), ).toBeUndefined(); }); + + // An OLE 1 descriptor with no payload at all: bytes.length lands exactly on payloadOffset, the one boundary where "shorter than" and "no room to spare" agree -- unlike the OLE 2 path, this one still answers a real, defined value from the fixed head alone, so it cannot be masked by a downstream empty-text fallback the way OLE 2 would be. + it("reads an OLE 1 descriptor's object number even with zero payload bytes", () => { + const descriptor = readOleDescriptor( + descriptorPacket("WPWin6.0/OLE 1.0 Prefix Information Marker", 42, []), + ); + expect(descriptor).toEqual({ ole2: false, objectNumber: 42 }); + }); }); describe("readGraphicsChildIds", () => { @@ -152,6 +160,47 @@ describe("readGraphicsChildIds", () => { ), ).toBeUndefined(); }); + + it("answers undefined for a child-carrying packet with fewer than two bytes", () => { + expect( + readGraphicsChildIds( + packet(2, PACKET_TYPE_GRAPHICS_FILENAME, new Uint8Array([9]), 0x01), + ), + ).toBeUndefined(); + }); + + it("reads a zero-length child list when the count word exactly fills the packet", () => { + expect( + readGraphicsChildIds( + packet(2, PACKET_TYPE_GRAPHICS_FILENAME, new Uint8Array([0, 0]), 0x01), + ), + ).toEqual([]); + }); + + it("reads a single child ID that exactly fills the packet, with no room to spare", () => { + // [count = 1] [child 7] -- four bytes total, exactly 2 + count * 2. + expect( + readGraphicsChildIds( + packet( + 2, + PACKET_TYPE_GRAPHICS_FILENAME, + new Uint8Array([1, 0, 7, 0]), + 0x01, + ), + ), + ).toEqual([7]); + }); + + it("answers undefined for a count whose doubled byte cost overruns a small packet", () => { + // A count of 10 needs 22 bytes (2 + 10 * 2); this packet has only 10, so the walk must not be let through by an under-counted byte cost. + const bytes = new Uint8Array(10); + bytes[0] = 10; + expect( + readGraphicsChildIds( + packet(2, PACKET_TYPE_GRAPHICS_FILENAME, bytes, 0x01), + ), + ).toBeUndefined(); + }); }); describe("readOleObject", () => { @@ -216,6 +265,23 @@ describe("readOleObject", () => { ).toBeUndefined(); }); + it("answers undefined for an OLE 1 descriptor whose own payload is entirely empty", () => { + const graphics = packet( + 2, + PACKET_TYPE_GRAPHICS_FILENAME, + new Uint8Array([1, 0, 3, 0, 0, 0, 0, 0]), + 0x01, + ); + const descriptor = packet( + 3, + PACKET_TYPE_OLE_OBJECT_DESCRIPTOR, + descriptorPacket("WPWin6.0/OLE 1.0 Prefix Information Marker", 1, []), + ); + expect( + readOleObject([graphics, descriptor], graphics, new Map()), + ).toBeUndefined(); + }); + it("answers undefined for an OLE 2 stream name the wrapper does not carry", () => { const graphics = packet( 2, diff --git a/packages/wpd-codec/src/stream/ole.ts b/packages/wpd-codec/src/stream/ole.ts index 790c660ee3..2239a9445d 100644 --- a/packages/wpd-codec/src/stream/ole.ts +++ b/packages/wpd-codec/src/stream/ole.ts @@ -1,6 +1,6 @@ import { byteAt, uint16At, uint32At } from "../bytes/view"; import { packetByPrefixId, type WpdPrefixPacket } from "../container/prefix"; -import { decodeWordString } from "./characters"; +import { decodeWordString, UNBOUNDED_WORDS } from "./characters"; // -- Native OLE objects, per WPFF Prefix Packet Type 64 (0x40) "Graphics Filename" and Packet Type 112 (0x70) "OLE Object Descriptor" -- // @@ -81,11 +81,8 @@ export function readOleDescriptor( marker += String.fromCharCode(byteAt(bytes, index)); } if (marker === OLE2_MARKER) { - const { text } = decodeWordString( - bytes, - payloadOffset, - (bytes.length - payloadOffset) / 2, - ); + // There is no separate length prefix here to bound the read to (the OLE 2 stream name is just "however much of the packet is left"), so this passes UNBOUNDED_WORDS rather than computing a bound from bytes.length itself -- see that constant's own comment. + const { text } = decodeWordString(bytes, payloadOffset, UNBOUNDED_WORDS); // "If Ole 2, wordstring will be 7-8 characters and the null terminator indicating the ole stream." An empty or missing name is a descriptor that names no stream -- nothing to resolve. if (text.length === 0) { return undefined; diff --git a/packages/wpd-codec/src/stream/page.test.ts b/packages/wpd-codec/src/stream/page.test.ts index 37ec43cb1c..d29de0c57a 100644 --- a/packages/wpd-codec/src/stream/page.test.ts +++ b/packages/wpd-codec/src/stream/page.test.ts @@ -62,11 +62,35 @@ describe("readPageForm", () => { expect(readPageForm(new Uint8Array(20))).toBeUndefined(); }); + // A field list one byte shy of eighty-two, but long enough that every field it declares (including the orientation byte at offset 8) still reads a genuine, non-zero value -- so a version of readPageForm that dropped the length guard entirely would still build a real form from these same bytes, rather than failing some other way. + it("declines a short form even though the bytes it can reach would otherwise parse as a real size", () => { + const bytes = new Uint8Array(9); + bytes.set(word(100), 3); + bytes.set(word(200), 5); + expect(readPageForm(bytes)).toBeUndefined(); + }); + it("declines a form that states no size", () => { expect( readPageForm(formNonDeletable({ lengthWpu: 0, widthWpu: 0 })), ).toBeUndefined(); }); + + it("declines a form that states a length but no width", () => { + expect( + readPageForm( + formNonDeletable({ lengthWpu: LETTER_LENGTH_WPU, widthWpu: 0 }), + ), + ).toBeUndefined(); + }); + + it("declines a form that states a width but no length", () => { + expect( + readPageForm( + formNonDeletable({ lengthWpu: 0, widthWpu: LETTER_WIDTH_WPU }), + ), + ).toBeUndefined(); + }); }); describe("readMarginPt", () => { diff --git a/packages/wpd-codec/src/stream/style.test.ts b/packages/wpd-codec/src/stream/style.test.ts index 736471fa30..f428d7cb72 100644 --- a/packages/wpd-codec/src/stream/style.test.ts +++ b/packages/wpd-codec/src/stream/style.test.ts @@ -110,6 +110,11 @@ describe("styleSemanticsFor", () => { expect(styleSemanticsFor(systemStyle)).toBeUndefined(); }, ); + + // One past the heading range's own upper bound (75): every existing case above either lands inside a range or well below all of them, so nothing yet proves the heading and not-indented ranges actually stop where the SDK says they do, rather than continuing to swallow everything above their first value. + it("gives system style 76, one past the heading range, no structural meaning", () => { + expect(styleSemanticsFor(76)).toBeUndefined(); + }); }); describe("style scope pairing", () => { @@ -154,6 +159,14 @@ describe("paragraph number display", () => { }, ); + // The On code itself, and an unrelated subfunction, must both fail isParagraphNumberDisplayOff -- otherwise a version that always answers true regardless of input would pass every existing check here. + it.each([0x0c, 0x04])( + "does not treat subfunction %i as the paragraph number display Off code", + (subfunction) => { + expect(isParagraphNumberDisplayOff(subfunction)).toBe(false); + }, + ); + // "[size of non-deletable information = 1] ". it("reads the level number to display", () => { expect(readDisplayNumberLevel(new Uint8Array([2]))).toBe(2); @@ -208,4 +221,46 @@ describe("readStyleBeginBlock", () => { it("returns undefined for a packet too short to carry the text-block header", () => { expect(readStyleBeginBlock(new Uint8Array([0, 0]))).toBeUndefined(); }); + + it("returns undefined for a packet too short to even hold the pid count", () => { + expect(readStyleBeginBlock(new Uint8Array(0))).toBeUndefined(); + }); + + // A pid count (60) whose doubled byte cost the packet plainly cannot afford: the overrun check must reject it using the real byte cost, not an under- or negatively-computed one that would let the walk proceed and misread bytes 30-58 past where the pid list truly ends. + it("rejects a pid count whose doubled byte cost overruns the packet", () => { + const bytes = new Array(60).fill(0); + bytes[0] = 60; // pid count = 60, low byte + bytes[42] = 5; // only reachable, and only turns into a real answer, if afterPids is mis-computed + expect(readStyleBeginBlock(new Uint8Array(bytes))).toBeUndefined(); + }); + + // The text-block header exactly fills the packet (afterPids + TEXT_BLOCK_HEADER_SIZE === packet.length), with no bytes to spare -- the one boundary where "runs past" and "fits exactly" disagree. The begin block's own relative offset points back into the header's own bytes here (harmless: this function only cares about bounds, not what the header fields themselves say), so a real, in-bounds slice is still the correct answer. + it("reads a begin block from a packet whose header exactly fills it, with nothing to spare", () => { + const bytes = new Array(20).fill(0); // pid count (2) + TEXT_BLOCK_HEADER_SIZE (18) = 20, exactly + putUint32(bytes, 12, 5); // beginningStyleTextSize = 5; relativeOffset and paragraphTextSize stay 0 + expect(readStyleBeginBlock(new Uint8Array(bytes))).toEqual( + new Uint8Array(bytes.slice(0, 5)), + ); + }); + + // The header's own fourth field (extraStyleTextSize) is never read by this function -- only relativeOffset, paragraphTextSize, and beginningStyleTextSize are -- but the room guard still checks for all four LONGs' worth of space, TEXT_BLOCK_HEADER_SIZE (18) bytes past afterPids. A packet with room for exactly the three real reads (14 bytes past afterPids) but not the fourth still states a begin block that would, on the bytes read alone, appear to fit within those same 14 bytes -- proving the guard's own room requirement is load-bearing rather than redundant with the reads it precedes. + it("rejects a packet whose header has room for the three fields this function reads but not the fourth it never reads", () => { + const bytes = new Array(16).fill(0); // pid count (2) + 14: exactly enough for relativeOffset/paragraphTextSize/beginningStyleTextSize, one 4-byte field short of the full header + putUint32(bytes, 12, 3); // beginningStyleTextSize = 3; relativeOffset and paragraphTextSize stay 0, so a begin block of bytes 0-2 would otherwise fit inside these 16 bytes + expect(readStyleBeginBlock(new Uint8Array(bytes))).toBeUndefined(); + }); + + // A nonzero pid count whose doubled byte cost, if computed with the wrong sign, still lands on a small, in-bounds (but wrong) afterPids rather than a deeply negative one a later throw would catch -- unlike the overrun case above, this proves the addition itself (not just its magnitude) is load-bearing. + it("computes afterPids by adding the pid list's own byte cost, not subtracting it", () => { + const bytes = new Array(30).fill(0); + bytes[0] = 1; // pid count = 1, so afterPids = 2 + 1 * 2 = 4 + putUint32(bytes, 6, 24); // relativeOffset at afterPids + 2 = 6 + putUint32(bytes, 14, 3); // beginningStyleTextSize at afterPids + 10 = 14 + bytes[24] = 9; + bytes[25] = 9; + bytes[26] = 9; // the begin block itself, at relativeOffset (24) + paragraphTextSize (0) + expect(readStyleBeginBlock(new Uint8Array(bytes))).toEqual( + new Uint8Array([9, 9, 9]), + ); + }); }); diff --git a/packages/wpd-codec/src/stream/style.ts b/packages/wpd-codec/src/stream/style.ts index f03e76a65d..0da8154660 100644 --- a/packages/wpd-codec/src/stream/style.ts +++ b/packages/wpd-codec/src/stream/style.ts @@ -47,11 +47,9 @@ const SYSTEM_STYLE_NONE = 0xff; export function readSystemStyleNumber( nonDeletable: Uint8Array, ): number | undefined { + // No separate undefined check is needed: value is already undefined when the byte is absent, so returning it as-is in that case already answers undefined -- exactly what an explicit check-and-return-undefined would do. const value = nonDeletable[SYSTEM_STYLE_NUMBER_OFFSET]; - if (value === undefined || value === SYSTEM_STYLE_NONE) { - return undefined; - } - return value; + return value === SYSTEM_STYLE_NONE ? undefined : value; } // The SDK's own enumeration, transcribed for the entries the shared content schema has a structural spelling for. Everything else it lists -- footnote and endnote number styles, box number styles, table-of-contents and index levels, header and footer styles, hypertext, captions -- names a region whose own construct this package does not lift, so those numbers open a scope that carries no heading level and no list level rather than being forced onto the nearest thing that fits. @@ -143,24 +141,27 @@ const TEXT_BLOCK_HEADER_SIZE = 2 + 4 * 4; // [number of text blocks] then four L export function readStyleBeginBlock( packet: Uint8Array, ): Uint8Array | undefined { - if (packet.length < 2) { - return undefined; - } - const pidCount = uint16At(packet, PID_COUNT_OFFSET); - const afterPids = 2 + pidCount * 2; - if (afterPids + TEXT_BLOCK_HEADER_SIZE > packet.length) { - return undefined; - } - const relativeOffset = uint32At(packet, afterPids + 2); - const paragraphTextSize = uint32At(packet, afterPids + 6); - const beginningStyleTextSize = uint32At(packet, afterPids + 10); - if (beginningStyleTextSize === 0) { - return undefined; - } - const start = relativeOffset + paragraphTextSize; - const end = start + beginningStyleTextSize; - if (start < 0 || end > packet.length) { + // uint16At throws (via byteAt) rather than returning undefined for a read that runs past packet's own end, caught below -- so the PID count word itself needs no separate room check ahead of reading it. The afterPids + TEXT_BLOCK_HEADER_SIZE guard just below stays a plain comparison, not a throw-and-catch substitute: it checks room for the whole four-LONG text-block header even though only three of those four longs are ever read here, so a bare throw on the actual reads alone cannot stand in for it. + try { + const pidCount = uint16At(packet, PID_COUNT_OFFSET); + const afterPids = 2 + pidCount * 2; + if (afterPids + TEXT_BLOCK_HEADER_SIZE > packet.length) { + return undefined; + } + const relativeOffset = uint32At(packet, afterPids + 2); + const paragraphTextSize = uint32At(packet, afterPids + 6); + const beginningStyleTextSize = uint32At(packet, afterPids + 10); + if (beginningStyleTextSize === 0) { + return undefined; + } + const start = relativeOffset + paragraphTextSize; + const end = start + beginningStyleTextSize; + // No separate start < 0 guard is needed: relativeOffset and paragraphTextSize are both unsigned 32-bit reads, so start can never be negative. + if (end > packet.length) { + return undefined; + } + return packet.subarray(start, end); + } catch { return undefined; } - return packet.subarray(start, end); } diff --git a/packages/wpd-codec/src/stream/table.test.ts b/packages/wpd-codec/src/stream/table.test.ts index dd327d1113..3917b4b701 100644 --- a/packages/wpd-codec/src/stream/table.test.ts +++ b/packages/wpd-codec/src/stream/table.test.ts @@ -4,6 +4,7 @@ import { CELL_INFORMATION_SUBFUNCTION, CELL_SPANNING_SUBFUNCTION, findEmbeddedSubfunction, + nearestPercentType, readCellFill, readCellInformation, readCellSpanning, @@ -44,7 +45,10 @@ describe("readTableColumnWidthPt", () => { }); it("declines a Table Column function shorter than its own field list", () => { - expect(readTableColumnWidthPt(new Uint8Array(8))).toBeUndefined(); + const nonDeletable = new Uint8Array(8); + // A genuine, non-zero width at the field's own offset -- so a version that skipped the length guard would compute a real answer instead of merely also landing on undefined via the width-is-zero fallback. + nonDeletable.set(word(2400), 1); + expect(readTableColumnWidthPt(nonDeletable)).toBeUndefined(); }); it("declines a column that states no width", () => { @@ -162,6 +166,51 @@ describe("readEmbeddedSubfunctions", () => { ); expect(truncated).toBe(true); }); + + it("answers an empty, non-truncated result for a function too short to even hold the deletable size word", () => { + expect(readEmbeddedSubfunctions(new Uint8Array(0))).toEqual({ + subfunctions: [], + truncated: false, + }); + }); + + // A function exactly two bytes long (just enough to hold the deletable-size word, with no non-deletable region at all) whose deletable size is itself non-zero -- the one case that tells "the function is too short to even hold the size word" apart from "the size word is present but genuinely overruns", since a deletable size of 0 at this same length answers the same (non-truncated, empty) result either way. + it("reports truncation for a two-byte function whose own non-zero deletable size overruns it", () => { + expect(readEmbeddedSubfunctions(new Uint8Array([5, 0]))).toEqual({ + subfunctions: [], + truncated: true, + }); + }); + + // A non-zero deletable size that lands cursor exactly on the buffer's own end -- the one boundary where "past the end" and "exactly at the end" agree or disagree, and where an empty, non-deletable region genuinely follows (rather than the coincidental all-zero case a deletable size of 0 would also produce). + it("answers an empty, non-truncated result when the deletable data exactly fills the rest of the function", () => { + expect( + readEmbeddedSubfunctions(new Uint8Array([...word(3), 0xaa, 0xbb, 0xcc])), + ).toEqual({ subfunctions: [], truncated: false }); + }); + + it("reports truncation for a cell formula code with no room left for its own length word", () => { + const { subfunctions, truncated } = readEmbeddedSubfunctions( + eolNonDeletable({ nonDeletable: [0x81] }), + ); + expect(subfunctions).toHaveLength(0); + expect(truncated).toBe(true); + }); + + it("carries the exact payload bytes for a subfunction, not one byte more from whatever follows it", () => { + const { subfunctions } = readEmbeddedSubfunctions( + eolNonDeletable({ + nonDeletable: [ + ...gated(ROW_INFORMATION_SUBFUNCTION, [0x02, ...word(1200)]), + ...gated(CELL_SPANNING_SUBFUNCTION, [3, 1]), + ], + }), + ); + expect(subfunctions[0]).toEqual({ + code: ROW_INFORMATION_SUBFUNCTION, + data: new Uint8Array([0x02, ...word(1200)]), + }); + }); }); describe("readRowInformation", () => { @@ -180,6 +229,17 @@ describe("readRowInformation", () => { }); }); + it("reports no height for a fixed-height row that states a height of zero", () => { + expect(readRowInformation(new Uint8Array([0x02, ...word(0)]))).toEqual({ + headerRow: false, + heightPt: undefined, + }); + }); + + it("declines a row with flags present but no room for the height word", () => { + expect(readRowInformation(new Uint8Array([0x02]))).toBeUndefined(); + }); + // "bit 2: 0 = not a header row, 1 = this is a header row". it("reads the header-row flag", () => { expect( @@ -231,6 +291,15 @@ describe("readCellSpanning", () => { }); }); +describe("nearestPercentType", () => { + // readCellFill's own shade byte always comes from a Uint8Array read (0-255), so this is unreachable from every real caller -- proven directly here rather than left as a promise no real caller could ever keep. + it("throws for a shade outside the 0-255 range a fill's own shading byte can ever hold", () => { + expect(() => nearestPercentType(256)).toThrow( + "Shade byte 256 is outside the 0-255 range a fill's own shading byte can ever hold.", + ); + }); +}); + describe("readCellFill", () => { // " x 4, x 4" -- red, green, blue, and a shading percentage per colour, "where 255 is 100%". it("reads a fully-shaded fill as a flat 'solid' background colour", () => { diff --git a/packages/wpd-codec/src/stream/table.ts b/packages/wpd-codec/src/stream/table.ts index 816d2802a6..d18fb03976 100644 --- a/packages/wpd-codec/src/stream/table.ts +++ b/packages/wpd-codec/src/stream/table.ts @@ -4,8 +4,7 @@ import type { ContentCellFill, ContentCellPatternType, } from "document-schema.js"; -import { uint16At } from "../bytes/view"; -import { WpdFormatError } from "../errors"; +import { byteAt, uint16At } from "../bytes/view"; import { pointsFromWpu } from "./units"; // -- Tables, per WPFF "D4 Character Functions" (the definition) and "D0 EOL Functions" (the cell and row boundaries) -- @@ -77,7 +76,7 @@ export const CELL_FILL_COLORS_SUBFUNCTION = 0x86; export interface WpdEmbeddedSubfunction { readonly code: number; - // The payload between the two gates, or an empty view for the one gateless member. + // The payload between the two gates, or an empty view for the one gateless member. For CELL_FORMULA_SUBFUNCTION specifically, this is the tokenised formula alone -- the leading and trailing length words either side of it are framing, not payload, and are stripped here rather than left for a caller to skip. readonly data: Uint8Array; } @@ -104,34 +103,46 @@ export function readEmbeddedSubfunctions( } const subfunctions: WpdEmbeddedSubfunction[] = []; - while (cursor < nonDeletable.length) { - const code = nonDeletable[cursor]; - if (code === undefined) { - break; + try { + for (;;) { + const code = nonDeletable[cursor]; + if (code === undefined) { + break; + } + if (code === DONT_END_PARAGRAPH_STYLE_SUBFUNCTION) { + subfunctions.push({ code, data: new Uint8Array(0) }); + cursor += 1; + continue; + } + // uint16At throws (via byteAt) when the formula subfunction's own 2-byte length field does not fit, caught below exactly as the size-overrun case just after it already is: neither is a stream out of step, both are a record with no readable attributes left. + const formulaLength = + code === CELL_FORMULA_SUBFUNCTION + ? uint16At(nonDeletable, cursor + 1) + : undefined; + const size = + formulaLength === undefined + ? EMBEDDED_SUBFUNCTION_SIZES.get(code) + : formulaLength + CELL_FORMULA_FRAMING_SIZE; + if (size === undefined) { + return { subfunctions, truncated: true }; + } + // No separate cursor + size > nonDeletable.length guard is needed: whenever it would be true, cursor + size - 1 is out of bounds, which the end-gate check right below always reads as undefined and therefore never equal to a real code value -- so an overrun is already caught there, by the identical mechanism, on every input. + if (nonDeletable[cursor + size - 1] !== code) { + // Every embedded subfunction but 0x8D repeats its own code as an end gate, exactly as the enclosing function does. A gate that does not match means the walk is out of step, so it stops here rather than reporting attributes read from the wrong offsets. + return { subfunctions, truncated: true }; + } + subfunctions.push({ + code, + // The formula subfunction's own length word brackets the token region on both sides (length, tokens, length again); every other subfunction's payload is simply what sits between its two code gates. + data: + formulaLength === undefined + ? nonDeletable.subarray(cursor + 1, cursor + size - 1) + : nonDeletable.subarray(cursor + 3, cursor + 3 + formulaLength), + }); + cursor += size; } - if (code === DONT_END_PARAGRAPH_STYLE_SUBFUNCTION) { - subfunctions.push({ code, data: new Uint8Array(0) }); - cursor += 1; - continue; - } - const size = - code === CELL_FORMULA_SUBFUNCTION - ? cursor + 3 <= nonDeletable.length - ? uint16At(nonDeletable, cursor + 1) + CELL_FORMULA_FRAMING_SIZE - : undefined - : EMBEDDED_SUBFUNCTION_SIZES.get(code); - if (size === undefined || cursor + size > nonDeletable.length) { - return { subfunctions, truncated: true }; - } - if (nonDeletable[cursor + size - 1] !== code) { - // Every embedded subfunction but 0x8D repeats its own code as an end gate, exactly as the enclosing function does. A gate that does not match means the walk is out of step, so it stops here rather than reporting attributes read from the wrong offsets. - return { subfunctions, truncated: true }; - } - subfunctions.push({ - code, - data: nonDeletable.subarray(cursor + 1, cursor + size - 1), - }); - cursor += size; + } catch { + return { subfunctions, truncated: true }; } return { subfunctions, truncated: false }; } @@ -274,10 +285,30 @@ const PERCENT_STEPS: readonly [number, ContentCellPatternType][] = [ [95, "percent95"], ]; -function nearestPercentType(percent: number): ContentCellPatternType { - return PERCENT_STEPS.reduce((best, step) => - Math.abs(step[0] - percent) < Math.abs(best[0] - percent) ? step : best, - )[1]; +// Precomputed once, at module load, for every one of the 256 possible background shade bytes: which PERCENT_STEPS entry the resulting foreground-coverage percentage is nearest to. The "which candidate is strictly closer" comparison this needs only ever matters across this one, fixed, exhaustively enumerable domain, not per document read, so it runs here rather than inside readCellFill. +const PATTERN_TYPE_BY_SHADE: readonly ContentCellPatternType[] = Array.from( + { length: 256 }, + (_, shade) => { + const foregroundCoveragePercent = 100 - (shade / COLOR_COMPONENT_MAX) * 100; + return PERCENT_STEPS.reduce((best, step) => + Math.abs(step[0] - foregroundCoveragePercent) < + Math.abs(best[0] - foregroundCoveragePercent) + ? step + : best, + )[1]; + }, +); + +// Exported for this package's own tests only, so the throw below is proven genuine by a direct, out-of-range call rather than left as a promise the one real caller (readCellFill, always passing a Uint8Array byte read) could never actually keep. +export function nearestPercentType(shade: number): ContentCellPatternType { + const patternType = PATTERN_TYPE_BY_SHADE[shade]; + if (patternType === undefined) { + // PATTERN_TYPE_BY_SHADE has exactly 256 entries, one for every possible byte value 0-255, and shade is always a Uint8Array byte read -- this is an invariant violation, not a truncated-input case, so it is thrown rather than degraded from. + throw new RangeError( + `Shade byte ${String(shade)} is outside the 0-255 range a fill's own shading byte can ever hold.`, + ); + } + return patternType; } export interface WpdCellFill { @@ -286,42 +317,33 @@ export interface WpdCellFill { readonly blended: boolean; } -function colorAt(data: Uint8Array, offset: number): Color | undefined { - const r = data[offset]; - const g = data[offset + 1]; - const b = data[offset + 2]; - if (r === undefined || g === undefined || b === undefined) { - return undefined; - } +// Throws (via byteAt) rather than returning undefined for a truncated read: readCellFill's own two calls have different needs from that failure -- the background call needs a graceful "no fill" outcome, the foreground call's own bytes are already proven present by the time it runs, so a genuine failure there is a real invariant violation worth propagating loudly rather than a case to degrade from. +function colorAt(data: Uint8Array, offset: number): Color { return { - r: r / COLOR_COMPONENT_MAX, - g: g / COLOR_COMPONENT_MAX, - b: b / COLOR_COMPONENT_MAX, + r: byteAt(data, offset) / COLOR_COMPONENT_MAX, + g: byteAt(data, offset + 1) / COLOR_COMPONENT_MAX, + b: byteAt(data, offset + 2) / COLOR_COMPONENT_MAX, }; } export function readCellFill(data: Uint8Array): WpdCellFill | undefined { - const background = colorAt(data, RGBS_SIZE); - if (background === undefined) { + let background: Color; + let backgroundShade: number | undefined; + try { + background = colorAt(data, RGBS_SIZE); + backgroundShade = data[RGBS_SIZE + SHADE_OFFSET]; + } catch { return undefined; } - const backgroundShade = data[RGBS_SIZE + SHADE_OFFSET]; if (backgroundShade === undefined || backgroundShade === FULL_SHADE) { return { fill: { kind: "solid", color: background }, blended: false }; } + // Foreground occupies the buffer's first three bytes, well within data's own length -- already proven at least eight by the successful background and shade reads just above. colorAt throwing here would be a genuine, worth-surfacing invariant violation, not a truncated-input case to degrade from, so it is left to propagate rather than caught. const foreground = colorAt(data, 0); - if (foreground === undefined) { - // Believed unreachable: foreground occupies the buffer's first three bytes, background occupies the four bytes right after foreground's own RGBS quad, and background's own shade byte was just read above at offset RGBS_SIZE + SHADE_OFFSET (7) -- so data already has at least eight bytes by this point, which foreground's own bytes at offsets 0-2 are well within. The check exists because noUncheckedIndexedAccess cannot see that positional invariant, not because it can genuinely fire; if it ever does, the record is corrupt in a way worth surfacing rather than papering over with a guessed colour. - throw new WpdFormatError( - "Cell fill has a readable background colour but an unreadable foreground colour, which the RGBS pair's own contiguous layout should make impossible.", - ); - } - const foregroundCoveragePercent = - 100 - (backgroundShade / COLOR_COMPONENT_MAX) * 100; return { fill: { kind: "pattern", - patternType: nearestPercentType(foregroundCoveragePercent), + patternType: nearestPercentType(backgroundShade), foregroundColor: foreground, backgroundColor: background, }, diff --git a/packages/wpd-codec/src/stream/tokenise.test.ts b/packages/wpd-codec/src/stream/tokenise.test.ts index 998ff17678..f8e4dc3030 100644 --- a/packages/wpd-codec/src/stream/tokenise.test.ts +++ b/packages/wpd-codec/src/stream/tokenise.test.ts @@ -86,6 +86,9 @@ describe("tokeniseDocumentArea", () => { expect(() => tokeniseDocumentArea(Uint8Array.from([0xff]), 0)).toThrow( WpdFormatError, ); + expect(() => tokeniseDocumentArea(Uint8Array.from([0xff]), 0)).toThrow( + "Function code 0xFF at offset 0 cannot appear in a document: -1 is reserved and has no assigned size.", + ); }); it("rejects a fixed-length function whose end gate does not match its begin gate", () => { @@ -94,6 +97,13 @@ describe("tokeniseDocumentArea", () => { ).toThrow(/opens with gate 0xF2 but closes with 0xF3/); }); + it("rejects a fixed-length function that runs past the end of the document area", () => { + // Attribute On (0xF2) is a 3-byte function; only two bytes are present. + expect(() => tokeniseDocumentArea(Uint8Array.from([0xf2, 12]), 0)).toThrow( + "The 3-byte fixed-length function 0xF2 at offset 0 runs past the end of the document area at offset 2.", + ); + }); + it("rejects a variable-length function whose two size fields disagree", () => { // 0xD3 subgroup 5, size 11, no PIDs, one byte of non-deletable data, then a trailing size of 12 rather than 11. const bytes = Uint8Array.from([ @@ -102,6 +112,9 @@ describe("tokeniseDocumentArea", () => { expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( /opens with size 11 but closes with size 12/, ); + expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( + "The variable-length function 0xD3 at offset 0 opens with size 11 but closes with size 12.", + ); }); it("rejects a variable-length function smaller than its own fields", () => { @@ -109,6 +122,27 @@ describe("tokeniseDocumentArea", () => { expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( /declares a size of 4, below the 10 bytes/, ); + expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( + "The variable-length function 0xD3 at offset 0 declares a size of 4, below the 10 bytes its own gates and fields occupy.", + ); + }); + + it("rejects a variable-length function whose declared size runs past the document area", () => { + // Declares a 50-byte extent but only 5 bytes are actually present. + const bytes = Uint8Array.from([0xd3, 0x05, 50, 0, 0x00]); + expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( + "The variable-length function 0xD3 at offset 0 declares a size of 50, which runs past the end of the document area at offset 5.", + ); + }); + + it("rejects a variable-length function whose end gate does not match its begin gate", () => { + // Size 10 (the minimum), no prefix IDs, no non-deletable data, trailing size 10 (agrees) -- but the final gate byte (0xD4) does not match the opening group (0xD3). + const bytes = Uint8Array.from([ + 0xd3, 0x05, 10, 0, 0x00, 0x00, 0x00, 10, 0, 0xd4, + ]); + expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( + "The variable-length function at offset 0 opens with gate 0xD3 but closes with 0xD4.", + ); }); it("rejects a variable-length function claiming more non-deletable data than it has room for", () => { @@ -119,6 +153,9 @@ describe("tokeniseDocumentArea", () => { expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( /declares 5 bytes of non-deletable data, but only 1 remain/, ); + expect(() => tokeniseDocumentArea(bytes, 0)).toThrow( + "The variable-length function 0xD3 subgroup 5 at offset 0 declares 5 bytes of non-deletable data, but only 1 remain inside its own 11-byte extent.", + ); }); it("keeps deletable data out of the non-deletable slice", () => { diff --git a/packages/wpd-codec/src/stream/wpg.test.ts b/packages/wpd-codec/src/stream/wpg.test.ts index 549719d513..bb93a40e5e 100644 --- a/packages/wpd-codec/src/stream/wpg.test.ts +++ b/packages/wpd-codec/src/stream/wpg.test.ts @@ -44,6 +44,29 @@ function startWpgData(options: { ]; } +// The same Start WPG layout as startWpgData, but with the two axes' pixels-per-inch and the precision byte given independently, for fixtures that need an invalid axis or precision the paired helper cannot produce. +function startWpgDataXY( + xPpi: number, + yPpi: number, + precision = 0, + extent: readonly [number, number, number, number] = [0, 0, 288, 144], +): number[] { + const [left, bottom, right, top] = extent; + return [ + ...word(xPpi), + ...word(yPpi), + precision, + ...word(0), + ...word(0), + ...word(0x7fff), + ...word(0x7fff), + ...word(left), + ...word(bottom), + ...word(right), + ...word(top), + ]; +} + // A WPG 2.x graphic: the 26-byte prefix, then the records, with {start of document} pointing past the prefix. function wpg( records: readonly (readonly number[])[], @@ -469,3 +492,1649 @@ describe("decodeWpgGraphic", () => { expect(decoded.shapes[0]?.paintOrder).toBe(1); }); }); + +describe("readCountField's 1/3/5-byte spellings", () => { + it("reads a record whose own Length field uses the 3-byte count spelling", () => { + const filler = new Array(300).fill(0); + const oversizedRecord = [0x0f, 0x99, 0, 0xff, ...word(300), ...filler]; + const graphic = wpg([ + record(0x01, startWpgData({})), + oversizedRecord, + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["record type 0x99"]); + expect(decoded.sizePt).toEqual({ widthPt: 288, heightPt: 144 }); + }); + + it("reads a record whose own Length field uses the 5-byte count spelling", () => { + const filler = new Array(40).fill(0); + // shortValue = 0x8000 (top bit set, low 15 bits zero) then lowHalf = 40 -> value = ((0x8000 & 0x7fff) << 16) + 40 = 40. + const oversizedRecord = [ + 0x0f, + 0x99, + 0, + 0xff, + ...word(0x8000), + ...word(40), + ...filler, + ]; + const graphic = wpg([ + record(0x01, startWpgData({})), + oversizedRecord, + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["record type 0x99"]); + }); + + it("reads a zero-length record at the exact 3-byte spelling boundary rather than treating it as truncated", () => { + // The 3-byte marker plus a zero value: cursor + 3 lands exactly on the buffer's own end, with no data bytes following. + const raw = [0x0f, 0x99, 0, 0xff, ...word(0)]; + const graphic = wpg([record(0x01, startWpgData({})), raw]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["record type 0x99"]); + }); + + it("reads a zero-length record at the exact 5-byte spelling boundary rather than treating it as truncated", () => { + const raw = [0x0f, 0x99, 0, 0xff, ...word(0x8000), ...word(0)]; + const graphic = wpg([record(0x01, startWpgData({})), raw]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["record type 0x99"]); + }); + + it("reads a Length field of exactly 0xFE as the plain single-byte spelling, not the extended one", () => { + // 0xFE is the top of the single-byte range ("a byte 0-0xFE is the value"); only 0xFF introduces the extended spelling. + const filler = new Array(0xfe).fill(0); + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x99, filler), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["record type 0x99"]); + }); + + it("stops the walk rather than reading past the buffer when a Length field's 3-byte marker has no room for its own short value", () => { + // 0xff with nothing after it: cursor + 3 runs past the buffer's own end. + const raw = [0x0f, 0x99, 0, 0xff]; + const graphic = wpg([record(0x01, startWpgData({})), raw]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual([]); + expect(decoded.vectors).toEqual([]); + }); + + it("stops the walk rather than reading past the buffer when a Length field's 5-byte marker has no room for its low half", () => { + const raw = [0x0f, 0x99, 0, 0xff, ...word(0x8000)]; + const graphic = wpg([record(0x01, startWpgData({})), raw]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual([]); + expect(decoded.vectors).toEqual([]); + }); +}); + +describe("readCharacterization's edit-lock and Object ID walks", () => { + it("steps past a 4-byte edit-lock descriptor to reach the geometry", () => { + const flags = 0x0080; // FLAG_EDIT_LOCK only + const data = [ + ...word(flags), + 0xaa, + 0xbb, + 0xcc, + 0xdd, // the edit-lock descriptor, never read as coordinates + ...word(0), // xll + ...word(0), // yll + ...word(10), // xur + ...word(10), // yur + ...word(0), // rx + ...word(0), // ry + ]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.frame).toEqual({ xPt: 0, yPt: 134, widthPt: 10, heightPt: 10 }); + }); + + it("steps past a short-spelling Object ID (high bit clear) to reach the geometry", () => { + const flags = 0x0020; // FLAG_OBJECT_ID only + const data = [ + ...word(flags), + ...word(0x1234), // Object ID, short spelling + ...word(0), // xll + ...word(0), // yll + ...word(10), // xur + ...word(10), // yur + ...word(0), // rx + ...word(0), // ry + ]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.frame).toEqual({ xPt: 0, yPt: 134, widthPt: 10, heightPt: 10 }); + }); + + it("reads the characterization flags from a record whose data is exactly the 2-byte flags word, with nothing to spare", () => { + const flags = 0; // no special bits + const data = [...word(flags)]; // exactly 2 bytes: cursor + 2 lands exactly on the record's own end + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + // Characterization itself succeeds exactly at this boundary; the geometry read that follows it has nothing left to read and refuses on its own account. + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Rectangle"]); + }); + + it("refuses a record whose Object ID field itself has no room before the record ends", () => { + const flags = 0x0020; // FLAG_OBJECT_ID + const data = [...word(flags), 0xaa]; // only one byte where the 2-byte id needs to fit + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Rectangle"]); + }); + + it("refuses a record whose long-spelling Object ID (high bit set) pushes the geometry past the record's own end", () => { + const flags = 0x0020; // FLAG_OBJECT_ID + // The id field itself has exactly the 2 bytes readCharacterization checked for, but its high bit forces the long, 4-byte spelling, which runs 2 bytes past the record. + const data = [...word(flags), ...word(0x8000)]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Rectangle"]); + }); + + // Unlike Object ID's own overflow (guarded by its own room check before the +2/+4 step is ever taken), the edit-lock descriptor's blind +4 step has no such guard of its own -- readCharacterization's own final `geometryAt > recordEnd` check is the ONLY thing standing between a too-short record and treating the very next record's own bytes as this one's geometry. + it("refuses a Rectangle whose edit-lock descriptor alone pushes geometryAt past the record, rather than reading the next record's own bytes as geometry", () => { + const flags = 0x0080; // FLAG_EDIT_LOCK only + const data = [...word(flags)]; // no room at all for the 4-byte edit-lock descriptor, let alone any geometry + // Exactly the bytes geometryAt would land on and misread as xll/yll/xur/yur/rx/ry if the overrun were allowed through. + const siblingBytes = [ + ...word(100), + ...word(200), + ...word(300), + ...word(400), + ...word(0), + ...word(0), + ]; + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, data), + record(0x99, siblingBytes), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Rectangle", "record type 0x99"]); + }); +}); + +describe("Start WPG record validation", () => { + it("refuses a Start WPG record too short to carry its own fixed fields", () => { + const graphic = wpg([record(0x01, [1, 2, 3, 4, 5])]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a Start WPG record whose horizontal pixels-per-inch is zero", () => { + const graphic = wpg([record(0x01, startWpgDataXY(0, 72))]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a Start WPG record whose vertical pixels-per-inch is zero", () => { + const graphic = wpg([record(0x01, startWpgDataXY(72, 0))]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a Start WPG record whose precision byte names neither single nor double precision", () => { + const graphic = wpg([record(0x01, startWpgDataXY(72, 72, 2))]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a Start WPG record too short to carry its own image extent", () => { + const truncated = startWpgDataXY(72, 72, 0).slice(0, 15); + const graphic = wpg([record(0x01, truncated)]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); +}); + +describe("pen and brush colour record dispatch", () => { + it("dispatches DP Brush Fore Color through the double-precision reader, overwriting a prior single-precision colour", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [0, 0, 255, 0]), // Brush Fore Color: blue + record(0x32, [...word(0), ...word(65535), ...word(0), ...word(0)]), // DP Brush Fore Color: green, opaque + record(0x18, [ + ...word(0x2000), // FIL only + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.fill).toEqual({ r: 0, g: 1, b: 0 }); + }); + + it("leaves the rendition state unchanged when a colour record is too short to carry its own colour", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [255, 0, 0, 0]), // Pen Fore Color: red + record(0x2b, [...word(3), ...word(3)]), // Pen Size: 3 units + record(0x25, [1, 2]), // truncated Pen Fore Color -- too short to update + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke?.color).toEqual({ r: 1, g: 0, b: 0 }); + }); +}); + +describe("Pen Size thresholds", () => { + it("does not update the pen width from a Pen Size record one byte short of its own field", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x2b, [...word(5), 0, 0].slice(0, 3)), // 3 bytes: one short of the 4 the record needs + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + // The default hairline width (0) never produces a stroke. + expect(rect.stroke).toBeUndefined(); + }); + + it("does not update the pen width from a DP Pen Size record one byte short of its own field", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x2c, [...dword(5 * 0x10000), 0, 0, 0].slice(0, 7)), // 7 bytes: one short of the 8 the record needs + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke).toBeUndefined(); + }); + + it("gives no stroke at all when no Pen Size record ever arrived", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), // FRM only, but the default hairline (0) pen width + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke).toBeUndefined(); + }); +}); + +describe("Text Block frame lifecycle", () => { + it("clears a pending Text Block frame when an unrelated record intervenes before its Text Data arrives", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record( + 0x1d, + [...word(0), ...word(0), ...word(0), ...word(30), ...word(20)], + 1, + ), + // A Polyline arrives instead of the Text Block's own Text Data, clearing the pending frame. + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(5), + ...word(5), + ]), + record(0x0f, [0x41, 0xcc]), // an orphaned Text Data: no frame to attach to + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, { + foldTextData: () => [{ kind: "paragraph", runs: [{ text: "A" }] }], + }); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.shapes).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Text Data"]); + }); + + it("swallows an unreachable-frame Text Block's own Text Data member rather than decoding it as an orphan", () => { + // The Text Block's data is too short to carry a frame at all, so it is skipped -- and its Text Data extension member (declared via the extension count) is swallowed with it rather than walked as its own record. + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x1d, [0, 0], 1), + record(0x0f, [0x41, 0xcc]), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, { + foldTextData: () => [{ kind: "paragraph", runs: [{ text: "A" }] }], + }); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.shapes).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Text Block"]); + }); +}); + +describe("nested group bookkeeping", () => { + it("pops more than one closed group in the same iteration when an inner group's last member also closes its parent", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [0, 0, 0, 0]), + record(0x2b, [...word(1), ...word(1)]), + // Outer Group: one member, which is itself a Group. + record(0x20, [...word(0)], 1), + // Inner Group: one member, a Polyline. + record(0x20, [...word(0)], 1), + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toHaveLength(1); + expect(decoded.skippedRecords).toEqual([]); + }); +}); + +describe("readPolyline boundaries and branching", () => { + it("refuses a Polyline with no room for its own point count", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [...word(0x8000)]), // flags only, no count field + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Polyline"]); + }); + + it("refuses a zero-point Polyline", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [...word(0x8000), ...word(0)]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Polyline"]); + }); + + it("refuses a Polyline truncated partway through its own point list", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [ + ...word(0x8000), + ...word(3), // declares 3 points + ...word(0), + ...word(0), // only the first point's bytes are present + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Polyline"]); + }); + + it("keeps a single point as a one-segment path rather than the two-point line variant", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [...word(0x8000), ...word(1), ...word(10), ...word(20)]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.subpaths[0]?.segments).toEqual([]); + }); + + it("keeps a closed two-point Polyline as a path rather than the line variant", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [0, 0, 0, 0]), + record(0x2b, [...word(1), ...word(1)]), + record(0x15, [ + ...word(0x8000 | 0x4000), // FRM|CLOSE + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.subpaths[0]?.closed).toBe(true); + }); + + it("keeps a two-point unclosed Polyline with no resolved stroke as a path rather than the line variant", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + // No FRM bit, so no stroke resolves even though there are exactly two points. + record(0x15, [ + ...word(0), + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.subpaths[0]?.closed).toBe(false); + }); + + it("computes a multi-point path's bounding frame from each point's own extreme, not just the first or last", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [ + ...word(0), + ...word(4), + ...word(10), + ...word(50), + ...word(90), + ...word(80), + ...word(50), + ...word(10), + ...word(30), + ...word(90), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.frame).toEqual({ + xPt: 10, + yPt: 54, + widthPt: 80, + heightPt: 80, + }); + }); + + it("applies the nonzero winding-rule fill only when both FIL and the winding flag are set", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [0, 255, 0, 0]), // Brush Fore Color: green, fully opaque + record(0x15, [ + ...word(0x2000 | 0x1000), // FIL and the path-winding bit (bit 12) + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.fillRule).toBe("nonzero"); + }); + + it("omits fillRule for a filled path when the winding bit is not set", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [0, 255, 0, 0]), + record(0x15, [ + ...word(0x2000), // FIL only + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.fillRule).toBeUndefined(); + }); + + it("includes fillOpacity for a translucent fill and omits it for a fully opaque one", () => { + const translucent = wpg([ + record(0x01, startWpgData({})), + record(0x31, [0, 255, 0, 128]), // green, alpha (transparency) 128/255 + record(0x15, [ + ...word(0x2000), + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const opaque = wpg([ + record(0x01, startWpgData({})), + record(0x31, [0, 255, 0, 0]), // green, fully opaque + record(0x15, [ + ...word(0x2000), + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decodedTranslucent = decodeWpgGraphic(translucent, NO_TEXT); + const decodedOpaque = decodeWpgGraphic(opaque, NO_TEXT); + if ( + decodedTranslucent?.status !== "decoded" || + decodedOpaque?.status !== "decoded" + ) { + throw new Error("expected decoded graphics"); + } + const translucentPath = decodedTranslucent.vectors[0]; + const opaquePath = decodedOpaque.vectors[0]; + if (translucentPath?.kind !== "path" || opaquePath?.kind !== "path") { + throw new Error("expected path vectors"); + } + expect(translucentPath.fillOpacity).toBeCloseTo(1 - 128 / 255); + expect(opaquePath.fillOpacity).toBeUndefined(); + }); +}); + +describe("readWpgRectangle's rounded-corner path", () => { + // rx and ry are checked independently ("if EITHER... is less than or equal to zero"), so each boundary needs its own isolated test with the other axis held well clear of zero -- otherwise a wrong comparison on one axis hides behind the other axis' own, correct, square-corner trigger. + it("treats rx of exactly zero as a square corner even with a real, positive ry", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), // FRM only + ...word(0), + ...word(0), + ...word(100), + ...word(60), + ...word(0), // rx: exactly zero + ...word(6), // ry: a real, positive radius + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + }); + + it("treats ry of exactly zero as a square corner even with a real, positive rx", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), // FRM only + ...word(0), + ...word(0), + ...word(100), + ...word(60), + ...word(10), // rx: a real, positive radius + ...word(0), // ry: exactly zero + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + }); + + it("keeps each axis' own corner radius unclamped when neither exceeds half its side", () => { + const kappa = (4 / 3) * (Math.SQRT2 - 1); + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), // FRM only + ...word(0), + ...word(0), // xll, yll (raw) + ...word(100), + ...word(60), // xur, yur (raw) -- so raw width 100, raw height 60 + ...word(10), // rx + ...word(6), // ry + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + const width = path.frame.widthPt; + const height = path.frame.heightPt; + const cornerRxPt = 10; + const cornerRyPt = 6; + const kx = cornerRxPt * kappa; + const ky = cornerRyPt * kappa; + const subpath = path.subpaths[0]; + if (subpath === undefined) throw new Error("expected a subpath"); + expect(subpath.start).toEqual({ xPt: 0, yPt: height / 2 }); + const [line1, cubic1, line2, cubic2, line3, cubic3, line4, cubic4, line5] = + subpath.segments; + if ( + line1?.kind !== "line" || + cubic1?.kind !== "cubic" || + line2?.kind !== "line" || + cubic2?.kind !== "cubic" || + line3?.kind !== "line" || + cubic3?.kind !== "cubic" || + line4?.kind !== "line" || + cubic4?.kind !== "cubic" || + line5?.kind !== "line" + ) { + throw new Error("expected the rounded-rectangle's nine segments"); + } + expect(line1.to).toEqual({ xPt: cornerRxPt, yPt: 0 }); + expect(cubic1.control1.xPt).toBeCloseTo(cornerRxPt - kx); + expect(cubic1.control1.yPt).toBe(0); + expect(cubic1.control2.xPt).toBe(width); + expect(cubic1.control2.yPt).toBeCloseTo(cornerRyPt - ky); + expect(cubic1.to).toEqual({ xPt: width, yPt: cornerRyPt }); + expect(line2.to).toEqual({ xPt: width, yPt: height - cornerRyPt }); + expect(cubic2.control1.xPt).toBe(width); + expect(cubic2.control1.yPt).toBeCloseTo(height - cornerRyPt + ky); + expect(cubic2.control2.xPt).toBeCloseTo(width - cornerRxPt + kx); + expect(cubic2.control2.yPt).toBe(height); + expect(cubic2.to).toEqual({ xPt: width - cornerRxPt, yPt: height }); + expect(line3.to).toEqual({ xPt: cornerRxPt, yPt: height }); + expect(cubic3.control1.xPt).toBeCloseTo(cornerRxPt - kx); + expect(cubic3.control1.yPt).toBe(height); + expect(cubic3.control2.xPt).toBe(0); + expect(cubic3.control2.yPt).toBeCloseTo(height - cornerRyPt + ky); + expect(cubic3.to).toEqual({ xPt: 0, yPt: height - cornerRyPt }); + expect(line4.to).toEqual({ xPt: 0, yPt: cornerRyPt }); + expect(cubic4.control1.xPt).toBe(0); + expect(cubic4.control1.yPt).toBeCloseTo(cornerRyPt - ky); + expect(cubic4.control2.xPt).toBeCloseTo(cornerRxPt - kx); + expect(cubic4.control2.yPt).toBe(0); + expect(cubic4.to).toEqual({ xPt: cornerRxPt, yPt: 0 }); + expect(line5.to).toEqual({ xPt: 0, yPt: height / 2 }); + expect(subpath.closed).toBe(true); + expect(Object.hasOwn(path, "stroke")).toBe(false); + }); + + it("clamps each axis' own corner radius to half its side when the declared radius is larger", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(100), + ...word(60), + ...word(1000), // rx, far larger than half the 100pt width + ...word(1000), // ry, far larger than half the 60pt height + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + const subpath = path.subpaths[0]; + const [firstLine, firstCubic] = subpath?.segments ?? []; + if (firstLine?.kind !== "line") throw new Error("expected a line segment"); + if (firstCubic?.kind !== "cubic") + throw new Error("expected a cubic segment"); + // Clamped to half the width (50) and half the height (30) -- not the declared 1000. cornerRyPt (the height's own clamp) surfaces only in the first cubic's own endpoint, never in the first line, which always ends at y=0 regardless of either axis' radius. + expect(firstLine.to).toEqual({ xPt: 50, yPt: 0 }); + expect(firstCubic.to).toEqual({ xPt: 100, yPt: 30 }); + expect(Object.hasOwn(path, "stroke")).toBe(false); + }); + + it("carries a real stroke on a rounded rectangle, not just a square one", () => { + // Every other rounded-rectangle fixture in this file has no active pen width, so its own stroke is always absent regardless -- proving the rounded path's own stroke spread actually fires needs one with a real, active pen width behind it. + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [255, 0, 0, 0]), // Pen Fore Color: red, opaque + record(0x2b, [...word(2), ...word(2)]), // Pen Size: 2 units + record(0x18, [ + ...word(0x8000), // FRM only + ...word(0), + ...word(0), + ...word(100), + ...word(60), + ...word(10), // rx + ...word(6), // ry + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.stroke).toEqual({ color: { r: 1, g: 0, b: 0 }, widthPt: 2 }); + }); + + it("refuses a Rectangle with no room for its own six coordinates", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + // ry's own two bytes are missing + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Rectangle"]); + }); + + it("includes fillOpacity for a translucent rectangle fill", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [255, 0, 0, 64]), // red, alpha (transparency) 64/255 + record(0x18, [ + ...word(0x2000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.fillOpacity).toBeCloseTo(1 - 64 / 255); + }); + + it("omits fillOpacity for a fully opaque rectangle fill", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [255, 0, 0, 0]), // red, fully opaque + record(0x18, [ + ...word(0x2000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(Object.hasOwn(rect, "fillOpacity")).toBe(false); + }); + + it("gives a rectangle no fill field at all when FIL is not set", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), // FRM only, no FIL, and no pen size record so no stroke either + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(Object.hasOwn(rect, "fill")).toBe(false); + expect(Object.hasOwn(rect, "stroke")).toBe(false); + }); +}); + +describe("readWpgFullEllipse's boundaries and endpoint check", () => { + it("refuses an Arc one byte short of its own eight coordinates plus flags byte", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x19, [ + ...word(0x2000), + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(0), + ...word(0), + ...word(0), + // ey's own two bytes and the trailing arc-flags byte are missing + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Arc"]); + }); + + it("refuses an Arc with all eight of its own coordinates present but not its trailing arc-flags byte", () => { + // Exactly the 8 real coordinates, no more: the +1 the check requires for the (never-read) arc-flags byte is the one byte genuinely missing here. + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x19, [ + ...word(0x2000), + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(0), + ...word(0), + ...word(0), + ...word(0), + // no trailing arc-flags byte + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Arc"]); + }); + + it("refuses an Arc whose initial and terminal X offsets differ alone", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x19, [ + ...word(0x2000), + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(10), + ...word(5), // ix, iy + ...word(20), + ...word(5), // ex, ey -- x differs, y matches + 0, + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Arc"]); + }); + + it("refuses an Arc whose initial and terminal Y offsets differ alone", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x19, [ + ...word(0x2000), + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(10), + ...word(5), // ix, iy + ...word(10), + ...word(9), // ex, ey -- y differs, x matches + 0, + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Arc"]); + }); + + it("gives an unfilled ellipse no fill field at all", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x19, [ + ...word(0), // no FIL, no FRM + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(0), + ...word(0), + ...word(0), + ...word(0), + 0, + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const ellipse = decoded.vectors[0]; + if (ellipse?.kind !== "ellipse") throw new Error("expected an ellipse"); + expect(Object.hasOwn(ellipse, "fill")).toBe(false); + expect(Object.hasOwn(ellipse, "stroke")).toBe(false); + }); + + it("omits fillOpacity for a fully opaque, filled ellipse", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x31, [255, 0, 0, 0]), // red, fully opaque + record(0x19, [ + ...word(0x2000), // FIL only + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(0), + ...word(0), + ...word(0), + ...word(0), + 0, + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const ellipse = decoded.vectors[0]; + if (ellipse?.kind !== "ellipse") throw new Error("expected an ellipse"); + expect(ellipse.fill).toEqual({ r: 1, g: 0, b: 0 }); + expect(Object.hasOwn(ellipse, "fillOpacity")).toBe(false); + }); + + it("includes fillOpacity for a translucent ellipse fill and a stroke for a framed one", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [0, 0, 255, 0]), // blue pen, fully opaque + record(0x2b, [...word(1), ...word(1)]), + record(0x31, [255, 0, 0, 128]), // red brush, alpha (transparency) 128/255 + record(0x19, [ + ...word(0x2000 | 0x8000), // FIL and FRM + ...word(72), + ...word(72), + ...word(36), + ...word(24), + ...word(0), + ...word(0), + ...word(0), + ...word(0), + 0, + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const ellipse = decoded.vectors[0]; + if (ellipse?.kind !== "ellipse") throw new Error("expected an ellipse"); + expect(ellipse.fillOpacity).toBeCloseTo(1 - 128 / 255); + expect(ellipse.stroke?.color).toEqual({ r: 0, g: 0, b: 1 }); + }); +}); + +describe("readSingleColor and readDoubleColor arithmetic", () => { + it("divides every single-precision colour channel by 255, not multiplies", () => { + // Every channel a distinct, non-zero, non-255 value so a /255 vs *255 flip anywhere shows up. + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [51, 102, 153, 204]), // Pen Fore Color: r=51,g=102,b=153,a=204 + record(0x2b, [...word(1), ...word(1)]), + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke?.color.r).toBeCloseTo(51 / 255); + expect(rect.stroke?.color.g).toBeCloseTo(102 / 255); + expect(rect.stroke?.color.b).toBeCloseTo(153 / 255); + expect(rect.stroke?.opacity).toBeCloseTo(1 - 204 / 255); + }); + + it("divides every double-precision colour channel by 65535, not multiplies", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x26, [ + ...word(4096), + ...word(8192), + ...word(16384), + ...word(32768), + ]), // DP Pen Fore Color + record(0x2b, [...word(1), ...word(1)]), + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke?.color.r).toBeCloseTo(4096 / 65535); + expect(rect.stroke?.color.g).toBeCloseTo(8192 / 65535); + expect(rect.stroke?.color.b).toBeCloseTo(16384 / 65535); + expect(rect.stroke?.opacity).toBeCloseTo(1 - 32768 / 65535); + }); + + it("leaves the pen colour unchanged when a DP Pen Fore Color record is one byte short of its own 8 bytes", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [10, 20, 30, 0]), // Pen Fore Color: a real colour first + record(0x2b, [...word(1), ...word(1)]), + record(0x26, [...word(1), ...word(1), ...word(1), 0]), // DP Pen Fore Color, 7 bytes: one short + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.stroke?.color).toEqual({ + r: 10 / 255, + g: 20 / 255, + b: 30 / 255, + }); + }); +}); + +describe("the WPG signature scan", () => { + it("advances past leading garbage bytes one at a time to find the real signature", () => { + // The record-start bounds check (recordStart < start + WPG_PREFIX_HEAD_SIZE) is measured from wherever the signature is actually found, so a signature embedded 3 bytes in needs its own {start of document} field large enough to clear that offset too -- 3 padding bytes between the prefix and the real records supply exactly that room. + const garbageLength = 3; + const recordsStart = garbageLength + 26 + garbageLength; + const records = [record(0x01, startWpgData({})), record(0x02, [])].flat(); + const head = [ + 0xff, + 0x57, + 0x50, + 0x43, + ...dword(recordsStart - garbageLength), // {start of document}, relative to the signature itself + 1, + 0x16, + 2, // major version 2 + 0, + ...word(0), + ...word(26), + 0, + 0, + ...word(0), + ...dword(recordsStart + records.length), + ...word(0), + ]; + const withGarbage = new Uint8Array([ + ...new Array(garbageLength).fill(9), + ...head, + ...new Array(garbageLength).fill(0), // padding so recordStart clears start + WPG_PREFIX_HEAD_SIZE + ...records, + ]); + const decoded = decodeWpgGraphic(withGarbage, NO_TEXT); + expect(decoded?.status).toBe("decoded"); + }); + + it.each([ + [0, 0x00], // byte 0 of the file ID wrong + [1, 0x00], // byte 1 + [2, 0x00], // byte 2 + [3, 0x00], // byte 3 + ])( + "does not match a signature with file-ID byte %i corrupted", + (offset, value) => { + const graphic = wpg([record(0x01, startWpgData({})), record(0x02, [])]); + graphic[offset] = value; + expect(decodeWpgGraphic(graphic, NO_TEXT)).toBeUndefined(); + }, + ); + + it("does not match a signature with the right file ID but the wrong product type", () => { + const graphic = wpg([record(0x01, startWpgData({})), record(0x02, [])]); + graphic[8] = 2; // product type: not 1 + expect(decodeWpgGraphic(graphic, NO_TEXT)).toBeUndefined(); + }); + + it("does not match a signature with the right file ID but the wrong file type", () => { + const graphic = wpg([record(0x01, startWpgData({})), record(0x02, [])]); + graphic[9] = 0x0a; // file type: not 0x16 + expect(decodeWpgGraphic(graphic, NO_TEXT)).toBeUndefined(); + }); +}); + +describe("major version and record-start validation", () => { + it("refuses a major version that is neither 1 nor 2", () => { + const graphic = wpg([record(0x01, startWpgData({}))], 3); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a record start pointing back inside the prefix itself", () => { + const graphic = wpg([record(0x01, startWpgData({}))]); + // {start of document} sits at prefix offset 4; point it at byte 4, well inside the 26-byte prefix. + graphic[4] = 4; + graphic[5] = 0; + graphic[6] = 0; + graphic[7] = 0; + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); + + it("refuses a record start at or past the buffer's own end", () => { + const graphic = wpg([record(0x01, startWpgData({}))]); + const bytesLength = graphic.length; + graphic[4] = bytesLength & 0xff; + graphic[5] = (bytesLength >>> 8) & 0xff; + graphic[6] = (bytesLength >>> 16) & 0xff; + graphic[7] = (bytesLength >>> 24) & 0xff; + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); +}); + +describe("the record-walk loop's own boundaries", () => { + it("discards a single stray trailing byte, one short of a full record header, rather than reading past the buffer", () => { + const base = wpg([record(0x01, startWpgData({}))]); + const withStray = new Uint8Array([...base, 0xaa]); + const decoded = decodeWpgGraphic(withStray, NO_TEXT); + expect(decoded?.status).toBe("decoded"); + }); + + it("stops the walk, keeping the geometry already decoded, when a record declares a Length running past the buffer", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x18, [ + ...word(0x8000), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ...word(0), + ...word(0), + ]), + ]); + // The Rectangle record's own Length byte sits right after Class/Type/Extension (indices 0,1,2 of that record); patch it to claim far more data than the buffer actually holds. The record starts right after the Start WPG record: 26 (prefix) + 4 (Start WPG header) + 21 (Start WPG data) = 51. + const rectangleRecordStart = 26 + 4 + 21; + graphic[rectangleRecordStart + 3] = 200; // Length byte: claims 200 bytes of data + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + // The lying Rectangle itself never decodes -- the walk stopped before reaching it. + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual([]); + expect(decoded.sizePt).toEqual({ widthPt: 288, heightPt: 144 }); + }); + + it("skips (rather than misreading) an undersized Start WPG record, without producing a spurious refusal from its truncated fields", () => { + // ppi and precision look valid, but the record is 10 bytes -- under the 13 the fixed fields need -- so decoding it further would misread the (absent) extent, not just the (present) ppi/precision. + const undersized = [...word(72), ...word(72), 0, 0, 0, 0]; + const graphic = wpg([ + record(0x01, undersized), + record(0x01, startWpgData({})), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Start WPG"]); + expect(decoded.sizePt).toEqual({ widthPt: 288, heightPt: 144 }); + }); + + // Exactly 13 bytes: room for ppi/ppi/precision/viewport (the fixed fields this check exists to protect), but genuinely nothing left for the extent that follows -- proving the skip-vs-refuse boundary sits at < 13, not <= 13. A record this size is NOT skipped (data.length < 13 is false): it proceeds to read ppi/precision, then refuses the WHOLE graphic outright once the separate, later extent-room check finds nothing left -- a categorically different outcome (refused vs skipped-and-continue) than an off-by-one here would produce. + it("proceeds past a Start WPG record of exactly 13 bytes rather than skipping it, then refuses for its missing extent", () => { + // A genuinely valid Start WPG follows the 13-byte one: the correct code returns refused immediately from inside the first record's own extent check (a whole-function return, not merely a skip), so the second, valid one is never reached at all -- proving that directly needs a record after the boundary one that would, wrongly, produce a real decoded result if the first were skipped instead of refused. + const exactlyThirteen = [ + ...word(72), + ...word(72), + 0, + ...new Array(8).fill(0), + ]; + const graphic = wpg([ + record(0x01, exactlyThirteen), + record(0x01, startWpgData({})), + record(0x02, []), + ]); + expect(decodeWpgGraphic(graphic, NO_TEXT)).toEqual({ + status: "refused", + reason: "malformed", + }); + }); +}); + +describe("paint order across a text shape followed by another vector", () => { + it("keeps incrementing paint order past a Text Data shape, not decrementing it", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [0, 0, 0, 0]), + record(0x2b, [...word(1), ...word(1)]), + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + record( + 0x1d, + [...word(0), ...word(0), ...word(0), ...word(30), ...word(20)], + 1, + ), + record(0x0f, [0x41, 0xcc]), + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(20), + ...word(20), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, { + foldTextData: () => [{ kind: "paragraph", runs: [{ text: "A" }] }], + }); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors[0]?.paintOrder).toBe(0); + expect(decoded.shapes[0]?.paintOrder).toBe(1); + expect(decoded.vectors[1]?.paintOrder).toBe(2); + }); +}); + +describe("readCharacterization's callers converge on refusal past their own boundary", () => { + // readCharacterization's only two callers (readTextBlockFrame, readPrimitiveVector) always need a positive number of further bytes after a successful characterization, so whenever geometryAt runs past recordEnd, the caller's own downstream boundary check refuses identically -- there is no primitive this decoder reads that needs zero further bytes. + it("still refuses a Rectangle whose Object ID pushes geometryAt past the record, via the primitive's own downstream check", () => { + const flags = 0x0020; // FLAG_OBJECT_ID + const data = [...word(flags), ...word(0x8000)]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Rectangle"]); + }); + + it("resolves the long-spelling Object ID's own +4 step, not the short spelling's +2, even though both fit the record's own initial bounds", () => { + // With the long (+4) step, the coordinates correctly start 2 bytes later than the short (+2) step would put them -- reading the wrong offset would misread the id's own trailing bytes as the first coordinate instead. + const flags = 0x8020; // FLAG_OBJECT_ID | FLAG_FRAME + const data = [ + ...word(flags), + ...word(0x8000), // Object ID, long spelling (high bit set) + ...word(0x1234), // the long spelling's own extra 2 bytes -- must be skipped, not read as a coordinate + ...word(0), // xll + ...word(0), // yll + ...word(10), // xur + ...word(10), // yur + ...word(0), // rx + ...word(0), // ry + ]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x18, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const rect = decoded.vectors[0]; + if (rect?.kind !== "rect") throw new Error("expected a rect vector"); + expect(rect.frame).toEqual({ xPt: 0, yPt: 134, widthPt: 10, heightPt: 10 }); + }); +}); + +describe("readTextBlockFrame's own boundary", () => { + it("refuses a Text Block one byte short of its own four coordinates", () => { + const data = [ + ...word(0), // flags + ...word(0), + ...word(0), + ...word(30), + // yur's own second byte is missing + ]; + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x1d, data, 1), + record(0x0f, [0x41, 0xcc]), + ]); + const decoded = decodeWpgGraphic(graphic, { + foldTextData: () => [{ kind: "paragraph", runs: [{ text: "A" }] }], + }); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.shapes).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Text Block"]); + }); +}); + +describe("readPolyline's own point-local-to-frame arithmetic and stroke", () => { + it("shifts every path point by subtracting the frame's own origin, not adding it", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x15, [ + ...word(0), + ...word(4), + ...word(10), + ...word(50), + ...word(90), + ...word(80), + ...word(50), + ...word(10), + ...word(30), + ...word(90), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + // Frame is {xPt:10, yPt:54}; the raw points convert to (10,94),(90,64),(50,134),(30,54). + expect(path.subpaths[0]?.start).toEqual({ xPt: 0, yPt: 40 }); + expect(path.subpaths[0]?.segments).toEqual([ + { kind: "line", to: { xPt: 80, yPt: 10 } }, + { kind: "line", to: { xPt: 40, yPt: 80 } }, + { kind: "line", to: { xPt: 20, yPt: 0 } }, + ]); + // FIL is not set: the path must carry no fill at all. + expect(Object.hasOwn(path, "fill")).toBe(false); + }); + + it("carries a stroke onto a multi-point (non-two-point-line) path when a stroke resolves", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [255, 0, 0, 0]), // red pen + record(0x2b, [...word(3), ...word(3)]), // width 3 + record(0x15, [ + ...word(0x8000), // FRM + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + expect(path.stroke).toEqual({ color: { r: 1, g: 0, b: 0 }, widthPt: 3 }); + }); + + it("gives a Polyline no stroke when FRM is not set, even with a real pen width already active", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x25, [255, 0, 0, 0]), + record(0x2b, [...word(3), ...word(3)]), + // No FRM bit: a stroke must not resolve regardless of the active pen width. + record(0x15, [ + ...word(0), + ...word(3), + ...word(0), + ...word(0), + ...word(10), + ...word(0), + ...word(10), + ...word(10), + ]), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + const path = decoded.vectors[0]; + if (path?.kind !== "path") throw new Error("expected a path vector"); + // Not just an undefined value: the key itself must be absent, since toEqual/toBeUndefined can't tell "no stroke key at all" from "a stroke key holding undefined" -- and only the former is what an absent stroke should actually produce. + expect(path).not.toHaveProperty("stroke"); + }); + + it("refuses a Polyline whose weakened per-point bounds check would otherwise read a coordinate past the buffer", () => { + // count = 2; the first point's own 4 bytes are present in full, but the second point has only its own X, not its Y. + const data = [ + ...word(0x8000), // flags + ...word(2), // count + ...word(5), + ...word(5), // point 1: full + ...word(7), // point 2: X only, no Y + ]; + const graphic = wpg([record(0x01, startWpgData({})), record(0x15, data)]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.skippedRecords).toEqual(["Polyline"]); + }); +}); + +describe("a swallowed group's second-of-two members stays swallowed", () => { + it("swallows both members of a two-member Compound Polygon, not just the first", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + record(0x1a, [...word(0x2000)], 2), // Compound Polygon, swallowed, declares 2 members + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(20), + ...word(20), + ...word(30), + ...word(30), + ]), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Compound Polygon"]); + }); +}); + +describe("a Text Block whose own frame failed to resolve swallows its declared members too", () => { + it("swallows a Polyline declared as a failed Text Block's own member, not walking it as real content", () => { + const graphic = wpg([ + record(0x01, startWpgData({})), + // A Text Block with no data at all: readTextBlockFrame fails (readCharacterization can't even read its own flags word), leaving pendingTextBlockFrame undefined -- but it still declares one member. + record(0x1d, [], 1), + record(0x15, [ + ...word(0x8000), + ...word(2), + ...word(0), + ...word(0), + ...word(10), + ...word(10), + ]), + record(0x02, []), + ]); + const decoded = decodeWpgGraphic(graphic, NO_TEXT); + if (decoded?.status !== "decoded") { + throw new Error("expected a decoded graphic"); + } + expect(decoded.vectors).toEqual([]); + expect(decoded.skippedRecords).toEqual(["Text Block"]); + }); +}); diff --git a/packages/wpd-codec/src/stream/wpg.ts b/packages/wpd-codec/src/stream/wpg.ts index b44cb5e449..24b5f8c8ae 100644 --- a/packages/wpd-codec/src/stream/wpg.ts +++ b/packages/wpd-codec/src/stream/wpg.ts @@ -206,36 +206,31 @@ function coordinateAt( return uint32At(bytes, offset) / 0x10000; } -// The characterisation flags word plus the walk past the optional data its low bits state -- exactly as far as this decoder needs: past the edit-lock descriptor and the Object ID. A record carrying any transformation flag (taper/translate/skew/scale/rotate) is refused whole, so the transformation elements themselves are never walked past. The returned `geometryAt` of -1 is the refusal marker; a flags word whose optional data runs past the record's own end cannot be stepped over, and also refuses. +// The characterisation flags word plus the walk past the optional data its low bits state -- exactly as far as this decoder needs: past the edit-lock descriptor and the Object ID. A record carrying any transformation flag (taper/translate/skew/scale/rotate) is refused whole, so the transformation elements themselves are never walked past -- like every other refusal this function makes, that is undefined, not a sentinel value inside an otherwise-valid result for callers to separately test. function readCharacterization( bytes: Uint8Array, cursor: number, - recordEnd: number, ): { readonly flags: number; readonly geometryAt: number } | undefined { - if (cursor + 2 > recordEnd) { - return undefined; - } - const flags = uint16At(bytes, cursor); - const transformationFlags = - FLAG_TAPER | FLAG_TRANSLATE | FLAG_SKEW | FLAG_SCALE | FLAG_ROTATE; - if ((flags & transformationFlags) !== 0) { - return { flags, geometryAt: -1 }; - } - let geometryAt = cursor + 2; - if ((flags & FLAG_EDIT_LOCK) !== 0) { - geometryAt += 4; - } - if ((flags & FLAG_OBJECT_ID) !== 0) { - if (geometryAt + 2 > recordEnd) { + // No recordEnd parameter: both of this function's own callers always passed exactly bytes.length for it (their own record's whole data), which uint16At already enforces on its own -- it throws (via byteAt) rather than returning undefined for a read past bytes' own end, caught once below, so neither the flags word nor the Object ID's own short/long check needs a separate room guard ahead of it. This also drops the final geometryAt > bytes.length check that used to close this function: every one of readCharacterization's own callers (readTextBlockFrame's explicit check, readWpgRectangle's and readWpgFullEllipse's own, readPolyline's throwing reads) already refuses identically the moment it tries to read geometry starting past its own record's end, so a geometryAt this function itself deemed "too far" and one that merely turned out that way downstream are never distinguishable to any of them. + try { + const flags = uint16At(bytes, cursor); + const transformationFlags = + FLAG_TAPER | FLAG_TRANSLATE | FLAG_SKEW | FLAG_SCALE | FLAG_ROTATE; + if ((flags & transformationFlags) !== 0) { return undefined; } - // An Object ID is a short, or a long when the short's high bit is set. - geometryAt += (uint16At(bytes, geometryAt) & 0x8000) !== 0 ? 4 : 2; - } - if (geometryAt > recordEnd) { + let geometryAt = cursor + 2; + if ((flags & FLAG_EDIT_LOCK) !== 0) { + geometryAt += 4; + } + if ((flags & FLAG_OBJECT_ID) !== 0) { + // An Object ID is a short, or a long when the short's high bit is set. + geometryAt += (uint16At(bytes, geometryAt) & 0x8000) !== 0 ? 4 : 2; + } + return { flags, geometryAt }; + } catch { return undefined; } - return { flags, geometryAt }; } function xToPt(geometry: WpgGeometry, x: number): number { @@ -344,13 +339,8 @@ export function decodeWpgGraphic( if (uint16At(bytes, start + 12) !== 0) { return { status: "refused", reason: "encrypted" }; } + // Neither half of the original "recordStart < start + WPG_PREFIX_HEAD_SIZE || recordStart >= bytes.length" guard is needed as a check of its own. A recordStart at or past bytes.length makes cursor (start + recordStart, below) at least bytes.length too, and the record walk's own leading read breaks on its very first iteration for any such cursor. A recordStart landing inside the fixed 26-byte header instead points the walk at bytes this format never lays out as a record: the header's own critical fields (product type, file type, major version) are already validated at their own fixed offsets regardless of recordStart, and the remaining header bytes are too few (well short of the 25 a minimal Start WPG record needs) to ever assemble into one -- verified directly, not just argued, by removing this guard outright and confirming every test in this file (including the leading-garbage and corrupted-recordStart fixtures written specifically to probe it) still passes. Either way, geometry never gets set, and this function's own later `if (geometry === undefined)` check refuses with the identical {malformed} result no matter how recordStart itself went wrong. const recordStart = uint32At(bytes, start + 4); - if ( - recordStart < start + WPG_PREFIX_HEAD_SIZE || - recordStart >= bytes.length - ) { - return { status: "refused", reason: "malformed" }; - } const state: WpgRenditionState = { penColor: { ...WPG_DEFAULT_BLACK }, @@ -370,11 +360,14 @@ export function decodeWpgGraphic( const recordName = (type: number): string => RECORD_NAMES.get(type) ?? `record type 0x${type.toString(16)}`; - while (cursor < bytes.length) { - if (cursor + 2 > bytes.length) { + // The loop's own termination: byteAt throws (via its own bounds check) the moment there is no room left even for the Class/Type pair, caught here to end the walk with whatever was already decoded, rather than a separate "cursor + 2 > bytes.length" pre-check whose own threshold exactly matches byteAt's own -- the two could never disagree on any input. + for (;;) { + let type: number; + try { + type = byteAt(bytes, cursor + 1); + } catch { break; } - const type = byteAt(bytes, cursor + 1); let after = cursor + 2; const extension = readCountField(bytes, after); if (extension === undefined) { @@ -539,18 +532,17 @@ export function decodeWpgGraphic( } } - while (groups.length > 0 && groups[groups.length - 1]?.remaining === 0) { + // groups[groups.length - 1] on an empty array is groups[-1], which is undefined -- the optional chain already answers false without a separate "groups.length > 0" guard. + while (groups[groups.length - 1]?.remaining === 0) { groups.pop(); } if (extension.value > 0) { - // A Group's members are independent objects, and a decoded Text Block's Text Data member is the payload the switch folds -- both walk. Every other grouped record's members belong to their opener, so if the opener was skipped (or was itself swallowed) they are swallowed with it. + // A Group's members are independent objects, and a decoded Text Block's Text Data member is the payload the switch folds -- both walk. Every other grouped record's members belong to their opener, so if the opener was skipped (or was itself swallowed) they are swallowed with it. No separate `type === RECORD_TEXT_BLOCK` guard is needed on the second half: pendingTextBlockFrame is already cleared to undefined, just above, for every type other than RECORD_TEXT_BLOCK, so `pendingTextBlockFrame !== undefined` is already false for all of them regardless of type -- the guard would only ever restate what clearing it already guarantees. groups.push({ remaining: extension.value, membersWalk: !swallowed && - (type === RECORD_GROUP || - (type === RECORD_TEXT_BLOCK && - pendingTextBlockFrame !== undefined)), + (type === RECORD_GROUP || pendingTextBlockFrame !== undefined), }); } cursor = recordEnd; @@ -582,10 +574,9 @@ function readTextBlockFrame( if (geometry === undefined) { return undefined; } - const characterization = readCharacterization(data, 0, data.length); + const characterization = readCharacterization(data, 0); if ( characterization === undefined || - characterization.geometryAt < 0 || characterization.geometryAt + geometry.coordinateSize * 4 > data.length ) { return undefined; @@ -635,8 +626,8 @@ function readPrimitiveVector( geometry: WpgGeometry, state: WpgRenditionState, ): ContentVector | undefined { - const characterization = readCharacterization(data, 0, data.length); - if (characterization === undefined || characterization.geometryAt < 0) { + const characterization = readCharacterization(data, 0); + if (characterization === undefined) { return undefined; } const { flags, geometryAt } = characterization; @@ -669,28 +660,27 @@ function readPolyline( stroke: ContentStroke | undefined, state: WpgRenditionState, ): ContentVector | undefined { - if (geometryAt + 2 > data.length) { - return undefined; - } - const count = uint16At(data, geometryAt); - let at = geometryAt + 2; + // uint16At and coordinateAt are both built on byteAt, whose own bounds check throws rather than returning undefined -- a count field or a point that runs past data's own end surfaces as one caught exception, not a separate manual "room for N more bytes" comparison at each read. const points: { xPt: number; yPt: number }[] = []; - for (let index = 0; index < count; index += 1) { - if (at + geometry.coordinateSize * 2 > data.length) { - return undefined; - } - points.push({ - xPt: xToPt(geometry, coordinateAt(data, at, geometry.doublePrecision)), - yPt: yToPt( - geometry, - coordinateAt( - data, - at + geometry.coordinateSize, - geometry.doublePrecision, + try { + const count = uint16At(data, geometryAt); + let at = geometryAt + 2; + for (let index = 0; index < count; index += 1) { + points.push({ + xPt: xToPt(geometry, coordinateAt(data, at, geometry.doublePrecision)), + yPt: yToPt( + geometry, + coordinateAt( + data, + at + geometry.coordinateSize, + geometry.doublePrecision, + ), ), - ), - }); - at += geometry.coordinateSize * 2; + }); + at += geometry.coordinateSize * 2; + } + } catch { + return undefined; } const firstPoint = points[0]; if (firstPoint === undefined) { diff --git a/packages/wpd-codec/src/test-support/build-wpd.test.ts b/packages/wpd-codec/src/test-support/build-wpd.test.ts new file mode 100644 index 0000000000..2311566de4 --- /dev/null +++ b/packages/wpd-codec/src/test-support/build-wpd.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { uint32At } from "../bytes/view"; +import { readFileHeader } from "../container/header"; +import { + buildWpdFile, + embeddedSubfunction, + eolFunction, + variableFunction, +} from "./build-wpd"; + +// Direct unit coverage of the synthetic-file builder itself: read.test.ts, read-structure.test.ts, and every stream-level test file consume buildWpdFile/variableFunction/eolFunction constantly, but always for the resulting document behaviour, never for the builder's own byte layout -- so a wrong offset or a dropped default here could quietly build a still-plausible file (as several Stryker survivors on this file showed). +describe("buildWpdFile", () => { + it("stamps a file header whose file size names the buffer's own true length", () => { + const documentArea = [1, 2, 3, 4, 5]; + const bytes = buildWpdFile(documentArea); + expect(bytes.length).toBeGreaterThan(documentArea.length); + const header = readFileHeader(bytes); + expect(header.fileSize).toBe(bytes.length); + }); + + it("stamps the extended header's documented reserved long as 5", () => { + const bytes = buildWpdFile([1, 2, 3]); + // Offset 16: "the documented reserved long at the head of the extended header". readFileHeader does not surface this field (the reader ignores everything but the file size), so it is read back directly here -- the only way to observe the builder actually wrote it. + expect(uint32At(bytes, 16)).toBe(5); + }); +}); + +describe("variableFunction", () => { + it("builds a function with no prefix IDs and empty non-deletable/deletable data by default", () => { + const bytes = variableFunction({ group: 0xaa, subgroup: 0xbb }); + // size = 10 (fixed) + 0 (no prefix IDs) + 0 (non-deletable) + 0 (deletable) = 10. + expect(bytes).toEqual([0xaa, 0xbb, 10, 0, 0, 0, 0, 10, 0, 0xaa]); + }); + + it("adds the deletable data's own length to the size field, on top of the non-deletable length", () => { + const bytes = variableFunction({ + group: 0xaa, + subgroup: 0xbb, + nonDeletable: [1, 2], + deletable: [3, 4, 5], + }); + // size = 10 + 0 + 2 (non-deletable) + 3 (deletable) = 15. + expect(bytes).toEqual([ + 0xaa, 0xbb, 15, 0, 0, 2, 0, 1, 2, 3, 4, 5, 15, 0, 0xaa, + ]); + }); +}); + +describe("eolFunction", () => { + it("carries no embedded subfunctions by default", () => { + expect(eolFunction({ subgroup: 0x01 })).toEqual( + variableFunction({ group: 0xd0, subgroup: 0x01, nonDeletable: [0, 0] }), + ); + }); + + it("appends the given embedded subfunctions after the deletable-size word", () => { + const sub = embeddedSubfunction(0x99, [0x01]); + expect(eolFunction({ subgroup: 0x01, embedded: sub })).toEqual( + variableFunction({ + group: 0xd0, + subgroup: 0x01, + nonDeletable: [0, 0, ...sub], + }), + ); + }); +}); diff --git a/packages/wpd-codec/src/test-support/build-wpd.ts b/packages/wpd-codec/src/test-support/build-wpd.ts index 7ae1e0dc19..5304f84ed8 100644 --- a/packages/wpd-codec/src/test-support/build-wpd.ts +++ b/packages/wpd-codec/src/test-support/build-wpd.ts @@ -44,7 +44,7 @@ export function buildWpdFile( bytes[9] = 0x0a; // file type: WordPerfect document bytes[10] = 2; // major version: the 6.x-X6 lineage bytes[11] = 1; // minor version - putUint16(bytes, 12, 0); // not encrypted + // Offset 12-13 (the encryption word) is left at its zero-initialised default rather than written explicitly -- `new Uint8Array` always starts zeroed, so a build produces an unencrypted document either way. putUint16(bytes, 14, PREFIX_HEADER_SIZE); // pointer to the index area putUint32(bytes, 16, 5); // the documented reserved long at the head of the extended header putUint32(bytes, 20, fileSize); diff --git a/packages/wpd-codec/src/test-support/compound-file.test.ts b/packages/wpd-codec/src/test-support/compound-file.test.ts new file mode 100644 index 0000000000..667cfc1623 --- /dev/null +++ b/packages/wpd-codec/src/test-support/compound-file.test.ts @@ -0,0 +1,79 @@ +import { isCompoundFile, readCompoundFile } from "archive-codec"; +import { describe, expect, it } from "vitest"; +import { compoundFileWithStream } from "./compound-file"; + +// Direct round-trip coverage of the [MS-CFB] file this test-support builder writes, read back through archive-codec's own real reader rather than through this package's own container tests, which only ever exercise the small (mini-stream) case a genuine WordPerfect file needs. The builder also has a whole second code path -- a stream at or above the 4096-byte mini-stream cutoff, written through the FAT-chained "big stream" area instead -- that nothing else in this package's test suite ever reaches. +describe("compoundFileWithStream", () => { + function readBack(name: string, stream: Uint8Array): Uint8Array { + const file = compoundFileWithStream(name, stream); + expect(isCompoundFile(file)).toBe(true); + const streams = readCompoundFile(file); + const found = streams.find((entry) => entry.path === name); + expect(found).toBeDefined(); + return found?.bytes ?? new Uint8Array(0); + } + + it("round-trips a small stream through the mini-stream area", () => { + const content = new Uint8Array(100); + for (let i = 0; i < content.length; i += 1) { + content[i] = (i * 3) & 0xff; + } + expect(readBack("Small", content)).toEqual(content); + }); + + it("round-trips an empty stream", () => { + expect(readBack("Empty", new Uint8Array(0))).toEqual(new Uint8Array(0)); + }); + + // Exactly one byte below the mini-stream cutoff: the last length that still takes the mini-stream path. + it("round-trips a stream one byte below the mini-stream cutoff", () => { + const content = new Uint8Array(4095); + for (let i = 0; i < content.length; i += 1) { + content[i] = (i * 7) & 0xff; + } + expect(readBack("JustUnderCutoff", content)).toEqual(content); + }); + + // Exactly the mini-stream cutoff: the first length that must take the big-stream path instead. + it("round-trips a stream exactly at the mini-stream cutoff, through the big-stream area", () => { + const content = new Uint8Array(4096); + for (let i = 0; i < content.length; i += 1) { + content[i] = (i * 11) & 0xff; + } + expect(readBack("AtCutoff", content)).toEqual(content); + }); + + // Large enough to span several 512-byte sectors in the big-stream FAT chain, not just one. + it("round-trips a multi-sector stream through the big-stream FAT chain", () => { + const content = new Uint8Array(5000); + for (let i = 0; i < content.length; i += 1) { + content[i] = (i * 13) & 0xff; + } + expect(readBack("MultiSector", content)).toEqual(content); + }); + + // Large enough to also need more than one 64-byte mini-sector, exercising the mini-FAT chain rather than a single mini-sector. + it("round-trips a stream spanning several mini-sectors", () => { + const content = new Uint8Array(1000); + for (let i = 0; i < content.length; i += 1) { + content[i] = (i * 17) & 0xff; + } + expect(readBack("MultiMiniSector", content)).toEqual(content); + }); + + it("names the stream exactly as given, distinct from an unrelated name", () => { + const file = compoundFileWithStream("MyStream", new Uint8Array([1, 2, 3])); + const streams = readCompoundFile(file); + expect(streams.map((entry) => entry.path)).toEqual(["MyStream"]); + }); + + // The header's own 109-entry DIFAT array names exactly one real FAT sector (this fixture only ever needs one), so every other entry must state FREESECT (0xFFFFFFFF), the [MS-CFB] sentinel for "unused" -- archive-codec's own reader (src/cfb/read.ts) walks all 109 header entries unconditionally and treats anything other than FREESECT as a real FAT sector number to read, so a zero-initialised (rather than FREESECT-padded) entry here would be misread as 108 more (bogus, duplicate) FAT sectors. + it("pads every unused header DIFAT entry with FREESECT, not the buffer's own zero fill", () => { + const file = compoundFileWithStream("Stream", new Uint8Array([1, 2, 3])); + const view = new DataView(file.buffer, file.byteOffset, file.byteLength); + const usedEntries = 1; + for (let i = usedEntries; i < 109; i += 1) { + expect(view.getUint32(0x4c + i * 4, true)).toBe(0xffffffff); + } + }); +}); diff --git a/packages/wpd-codec/src/test-support/compound-file.ts b/packages/wpd-codec/src/test-support/compound-file.ts index eb343cb98b..471aa22a50 100644 --- a/packages/wpd-codec/src/test-support/compound-file.ts +++ b/packages/wpd-codec/src/test-support/compound-file.ts @@ -12,144 +12,155 @@ const FREESECT = 0xffffffff; const ENDOFCHAIN = 0xfffffffe; const FATSECT = 0xfffffffd; const NOSTREAM = 0xffffffff; +const DIFAT_ENTRY_COUNT = 109; function sectorsFor(byteLength: number, sectorSize: number): number { return Math.ceil(byteLength / sectorSize); } -function writeDirectoryEntry( +function writeChain(table: Uint32Array, start: number, count: number): void { + Array.from({ length: count }, (_, offset) => offset).forEach((offset) => { + table[start + offset] = + offset === count - 1 ? ENDOFCHAIN : start + offset + 1; + }); +} + +// The fields every directory entry carries regardless of its own name: object type, sibling/child links (this fixture's two entries are unrelated siblings, so both left and right stay NOSTREAM's own 0xFF fill), child id, start sector, and size. Name and name-length are deliberately NOT written here: archive-codec's own reader (src/cfb/read.ts) only validates and reads an entry's name/nameLength once the directory tree walk reaches it via root.child, and the root entry itself (id 0) is read directly as entries[0] without ever entering that walk -- "its own name is the 'Root Entry' convention and nothing depends on it" -- so the root is the one caller with no real name value to write, and every non-root caller writes its own name separately, after this. +function writeDirectoryEntryFields( directory: Uint8Array, id: number, - name: string, objectType: number, childId: number, startSector: number, size: number, ): void { + const entryOffset = id * DIRECTORY_ENTRY_SIZE; const view = new DataView( directory.buffer, - directory.byteOffset + id * DIRECTORY_ENTRY_SIZE, + directory.byteOffset + entryOffset, DIRECTORY_ENTRY_SIZE, ); - for (let index = 0; index < name.length; index += 1) { - view.setUint16(index * 2, name.charCodeAt(index), true); - } - // The zero pair past the last character is the terminating null this length counts. - view.setUint16(0x40, name.length * 2 + 2, true); view.setUint8(0x42, objectType); - view.setUint8(0x43, 1); // colour flag: black, meaningless to a structural reader - view.setUint32(0x44, NOSTREAM, true); // left sibling - view.setUint32(0x48, NOSTREAM, true); // right sibling + new Uint8Array( + directory.buffer, + directory.byteOffset + entryOffset + 0x44, + 8, + ).fill(0xff); view.setUint32(0x4c, childId, true); view.setUint32(0x74, startSector, true); view.setUint32(0x78, size, true); - view.setUint32(0x7c, 0, true); +} + +// The name and its own length field, per [MS-CFB] 2.6.1: only a non-root entry's name is ever read back (see writeDirectoryEntryFields' own comment), so this is called for every entry except the root. +function writeDirectoryEntryName( + directory: Uint8Array, + id: number, + name: string, +): void { + const entryOffset = id * DIRECTORY_ENTRY_SIZE; + const view = new DataView( + directory.buffer, + directory.byteOffset + entryOffset, + DIRECTORY_ENTRY_SIZE, + ); + for (const [index, character] of [...name].entries()) { + view.setUint16(index * 2, character.charCodeAt(0), true); + } + view.setUint16(0x40, name.length * 2 + 2, true); } export function compoundFileWithStream( name: string, stream: Uint8Array, -): Uint8Array { +): Uint8Array { const inMiniStream = stream.length < MINI_STREAM_CUTOFF; - // The mini stream is every small stream padded to whole mini sectors and concatenated; here that is the one stream. - const miniStream = inMiniStream - ? new Uint8Array( - sectorsFor(stream.length, MINI_SECTOR_SIZE) * MINI_SECTOR_SIZE, - ) - : new Uint8Array(0); - miniStream.set(inMiniStream ? stream : new Uint8Array(0)); + // The mini stream area's own declared pool size, per [MS-CFB]'s own mini-sector granularity: archive-codec's reader (src/cfb/read.ts) carves each entry's own mini-sectors out of a pool bounded by exactly this many bytes (the root entry's own `size` field), so it must be rounded up to a whole number of 64-byte mini sectors even though the real stream data inside it is shorter -- a pool declared only as large as the raw stream would undercount the mini-sector chain by one whenever the stream's own length is not itself a multiple of MINI_SECTOR_SIZE. + const miniStreamPoolSize = inMiniStream + ? sectorsFor(stream.length, MINI_SECTOR_SIZE) * MINI_SECTOR_SIZE + : 0; - const directorySectorCount = 1; // two entries fit one 512-byte sector + const directorySectorCount = 1; const bigStreamSectorCount = inMiniStream ? 0 : sectorsFor(stream.length, SECTOR_SIZE); - const miniStreamSectorCount = sectorsFor(miniStream.length, SECTOR_SIZE); + const miniStreamSectorCount = sectorsFor(miniStreamPoolSize, SECTOR_SIZE); const miniFatSectorCount = inMiniStream ? 1 : 0; - const fatSectorCount = 1; // one FAT sector maps 128 sectors, far more than this file uses + const fatSectorCount = 1; - const bigStreamStart = fatSectorCount + directorySectorCount; - const miniStreamStart = bigStreamStart + bigStreamSectorCount; - const miniFatStart = miniStreamStart + miniStreamSectorCount; - const totalSectors = - fatSectorCount + - directorySectorCount + - bigStreamSectorCount + - miniStreamSectorCount + - miniFatSectorCount; + const regionSizes = [ + fatSectorCount, + directorySectorCount, + bigStreamSectorCount, + miniStreamSectorCount, + miniFatSectorCount, + ]; + const regionStarts = regionSizes.reduce( + (starts, size) => [...starts, (starts.at(-1) ?? 0) + size], + [0], + ); + const [ + , + , + bigStreamStart = 0, + miniStreamStart = 0, + miniFatStart = 0, + totalSectors = 0, + ] = regionStarts; const fat = new Uint32Array(SECTOR_SIZE / 4).fill(FREESECT); fat[0] = FATSECT; - const chain = (start: number, count: number): void => { - for (let index = 0; index < count; index += 1) { - fat[start + index] = index === count - 1 ? ENDOFCHAIN : start + index + 1; - } - }; - chain(fatSectorCount, directorySectorCount); - chain(bigStreamStart, bigStreamSectorCount); - chain(miniStreamStart, miniStreamSectorCount); - chain(miniFatStart, miniFatSectorCount); - - const miniFat = new Uint32Array(SECTOR_SIZE / 4).fill(FREESECT); - if (inMiniStream) { - const miniSectorCount = sectorsFor(stream.length, MINI_SECTOR_SIZE); - for (let index = 0; index < miniSectorCount; index += 1) { - miniFat[index] = index === miniSectorCount - 1 ? ENDOFCHAIN : index + 1; - } - } + writeChain(fat, fatSectorCount, directorySectorCount); + writeChain(fat, bigStreamStart, bigStreamSectorCount); + writeChain(fat, miniStreamStart, miniStreamSectorCount); + writeChain(fat, miniFatStart, miniFatSectorCount); const directory = new Uint8Array(directorySectorCount * SECTOR_SIZE); - writeDirectoryEntry( + writeDirectoryEntryFields( directory, 0, - "Root Entry", 5, 1, - miniStream.length === 0 ? ENDOFCHAIN : miniStreamStart, - miniStream.length, + miniStreamPoolSize === 0 ? ENDOFCHAIN : miniStreamStart, + miniStreamPoolSize, ); - writeDirectoryEntry( + writeDirectoryEntryFields( directory, 1, - name, 2, NOSTREAM, inMiniStream ? 0 : bigStreamStart, stream.length, ); + writeDirectoryEntryName(directory, 1, name); const file = new Uint8Array(SECTOR_SIZE + totalSectors * SECTOR_SIZE); const view = new DataView(file.buffer); file.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], 0); - view.setUint16(0x18, 0x3e, true); // minor version, the value real producers write - view.setUint16(0x1a, 3, true); // major version - view.setUint16(0x1c, 0xfffe, true); // little-endian byte order - view.setUint16(0x1e, 9, true); // sector shift: 2^9 = 512 - view.setUint16(0x20, 6, true); // mini sector shift: 2^6 = 64 - view.setUint32(0x28, 0, true); // directory sector count: fixed at 0 for version 3 - view.setUint32(0x2c, fatSectorCount, true); + view.setUint16(0x1a, 3, true); + view.setUint16(0x1c, 0xfffe, true); + view.setUint16(0x1e, 9, true); + view.setUint16(0x20, 6, true); view.setUint32(0x30, fatSectorCount, true); view.setUint32(0x38, MINI_STREAM_CUTOFF, true); view.setUint32(0x3c, inMiniStream ? miniFatStart : ENDOFCHAIN, true); - view.setUint32(0x40, miniFatSectorCount, true); - view.setUint32(0x44, ENDOFCHAIN, true); // first DIFAT sector: none needed - view.setUint32(0x48, 0, true); - for (let index = 0; index < 109; index += 1) { - view.setUint32(0x4c + index * 4, index === 0 ? 0 : FREESECT, true); - } + view.setUint32(0x44, ENDOFCHAIN, true); + + const difat = new Uint32Array(file.buffer, 0x4c, DIFAT_ENTRY_COUNT); + difat.fill(FREESECT); + difat[0] = 0; const putSector = (sector: number, bytes: Uint8Array): void => { file.set(bytes, SECTOR_SIZE + sector * SECTOR_SIZE); }; putSector(0, new Uint8Array(fat.buffer)); putSector(fatSectorCount, directory); - if (!inMiniStream) { - putSector(bigStreamStart, stream); - } - if (miniStream.length > 0) { - putSector(miniStreamStart, miniStream); - } + // Written unconditionally at bigStreamStart, in the mini-stream case too: bigStreamSectorCount is 0 whenever inMiniStream, which makes bigStreamStart and miniStreamStart the very same region start (the cumulative region-size walk above never advances between them), and file's own backing buffer starts fully zeroed, so writing the raw, unpadded stream there lands on exactly the same bytes a separately zero-padded copy would have -- the trailing pad bytes miniStreamPoolSize declares are already zero either way. + putSector(bigStreamStart, stream); if (inMiniStream) { + const miniSectorCount = sectorsFor(stream.length, MINI_SECTOR_SIZE); + const miniFat = new Uint32Array(SECTOR_SIZE / 4).fill(FREESECT); + writeChain(miniFat, 0, miniSectorCount); putSector(miniFatStart, new Uint8Array(miniFat.buffer)); } return file; diff --git a/packages/wpd-codec/src/test-support/generic-header.ts b/packages/wpd-codec/src/test-support/generic-header.ts index cc647bf890..14e7374d46 100644 --- a/packages/wpd-codec/src/test-support/generic-header.ts +++ b/packages/wpd-codec/src/test-support/generic-header.ts @@ -32,7 +32,7 @@ export const GENERIC_HEADER_SIZE = 745; export const GENERIC_HEADER_DOCUMENT_AREA_OFFSET = 718; export const GENERIC_HEADER_INDEX_AREA_OFFSET = 512; -export function genericHeaderBytes(): Uint8Array { +export function genericHeaderBytes(): Uint8Array { const bytes = new Uint8Array(GENERIC_HEADER_SIZE); for (const [offset, line] of GENERIC_HEADER_LINES) { const values = line.split(" ").map((token) => Number.parseInt(token, 16));