diff --git a/packages/document-cli/CHANGELOG.md b/packages/document-cli/CHANGELOG.md index 218bbf2b4..8c0be847b 100644 --- a/packages/document-cli/CHANGELOG.md +++ b/packages/document-cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## [5.11.12](https://github.com/ExaDev/documents.js/compare/document-cli%405.11.11...document-cli%405.11.12) (2026-09-15) + + +### Dependencies + +- Updated documents.js to 7.20.13 + ## [5.11.11](https://github.com/ExaDev/documents.js/compare/document-cli%405.11.10...document-cli%405.11.11) (2026-09-14) diff --git a/packages/document-cli/package.json b/packages/document-cli/package.json index 304ad61ea..2af36f576 100644 --- a/packages/document-cli/package.json +++ b/packages/document-cli/package.json @@ -1,6 +1,6 @@ { "name": "document-cli", - "version": "5.11.11", + "version": "5.11.12", "description": "CLI and interactive Ink TUI for documents.js: every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown/rtf conversion, bridge, and editor as a scriptable command or a terminal app.", "type": "module", "repository": { @@ -94,7 +94,7 @@ "commander": "15.0.0", "document-outline.js": "3.9.1", "document-schema.js": "7.11.3", - "documents.js": "7.20.12", + "documents.js": "7.20.13", "ink": "7.1.1", "ink-text-input": "6.0.0", "react": "19.2.8" diff --git a/packages/document-compute.js/CHANGELOG.md b/packages/document-compute.js/CHANGELOG.md index 669c22b1f..745e9be87 100644 --- a/packages/document-compute.js/CHANGELOG.md +++ b/packages/document-compute.js/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.5.16](https://github.com/ExaDev/documents.js/compare/document-compute.js%401.5.15...document-compute.js%401.5.16) (2026-09-15) + + +### Dependencies + +- Updated documents.js to 7.20.13 + ## [1.5.15](https://github.com/ExaDev/documents.js/compare/document-compute.js%401.5.14...document-compute.js%401.5.15) (2026-09-14) diff --git a/packages/document-compute.js/package.json b/packages/document-compute.js/package.json index ff402811d..4a2ab1ef6 100644 --- a/packages/document-compute.js/package.json +++ b/packages/document-compute.js/package.json @@ -1,6 +1,6 @@ { "name": "document-compute.js", - "version": "1.5.15", + "version": "1.5.16", "description": "Units-typed, tree-walking evaluator for document-schema.js's MathExpression -- exact-rational unit conversion, interval arithmetic, and bisection/Newton numeric solve-for, the compute package for the documents.js family.", "type": "module", "repository": { @@ -79,7 +79,7 @@ "@stryker-mutator/typescript-checker": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/node": "26.2.0", - "documents.js": "7.20.12", + "documents.js": "7.20.13", "eslint": "10.8.1", "husky": "9.1.7", "markdown-codec": "6.7.3", diff --git a/packages/document-mcp/CHANGELOG.md b/packages/document-mcp/CHANGELOG.md index 0d2212e95..3586bf6e8 100644 --- a/packages/document-mcp/CHANGELOG.md +++ b/packages/document-mcp/CHANGELOG.md @@ -1,3 +1,11 @@ +## [4.14.14](https://github.com/ExaDev/documents.js/compare/document-mcp%404.14.13...document-mcp%404.14.14) (2026-09-15) + + +### Dependencies + +- Updated documents.js to 7.20.13 +- Updated document-operations to 1.1.13 + ## [4.14.13](https://github.com/ExaDev/documents.js/compare/document-mcp%404.14.12...document-mcp%404.14.13) (2026-09-14) diff --git a/packages/document-mcp/package.json b/packages/document-mcp/package.json index 0be5cbe79..b4003919d 100644 --- a/packages/document-mcp/package.json +++ b/packages/document-mcp/package.json @@ -1,6 +1,6 @@ { "name": "document-mcp", - "version": "4.14.13", + "version": "4.14.14", "description": "MCP (Model Context Protocol) server exposing documents.js's document-conversion, .odb, metadata, and font tooling as MCP tools.", "type": "module", "repository": { @@ -90,8 +90,8 @@ "dependencies": { "@modelcontextprotocol/node": "2.0.0", "@modelcontextprotocol/server": "2.0.0", - "document-operations": "1.1.12", - "documents.js": "7.20.12", + "document-operations": "1.1.13", + "documents.js": "7.20.13", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/document-operations/CHANGELOG.md b/packages/document-operations/CHANGELOG.md index 9d1b4fe94..8cfcb477d 100644 --- a/packages/document-operations/CHANGELOG.md +++ b/packages/document-operations/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.1.13](https://github.com/ExaDev/documents.js/compare/document-operations%401.1.12...document-operations%401.1.13) (2026-09-15) + + +### Dependencies + +- Updated documents.js to 7.20.13 +- Updated document-compute.js to 1.5.16 + ## [1.1.12](https://github.com/ExaDev/documents.js/compare/document-operations%401.1.11...document-operations%401.1.12) (2026-09-14) diff --git a/packages/document-operations/package.json b/packages/document-operations/package.json index bf524e1b4..428c4aeaf 100644 --- a/packages/document-operations/package.json +++ b/packages/document-operations/package.json @@ -1,6 +1,6 @@ { "name": "document-operations", - "version": "1.1.12", + "version": "1.1.13", "description": "The canonical set of document-conversion, editing, inspection, and .odb operations documents.js exposes -- one Zod input/output schema and one transport-agnostic run() function per operation, shared by document-mcp, document-cli, and a REST API server rather than each defining its own copy.", "type": "module", "repository": { @@ -82,10 +82,10 @@ "license": "MIT", "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "dependencies": { - "document-compute.js": "1.5.15", + "document-compute.js": "1.5.16", "document-outline.js": "3.9.1", "document-schema.js": "7.11.3", - "documents.js": "7.20.12", + "documents.js": "7.20.13", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/document-rest/CHANGELOG.md b/packages/document-rest/CHANGELOG.md index 626649ace..a6aa4056a 100644 --- a/packages/document-rest/CHANGELOG.md +++ b/packages/document-rest/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.4.14](https://github.com/ExaDev/documents.js/compare/document-rest%401.4.13...document-rest%401.4.14) (2026-09-15) + + +### Dependencies + +- Updated documents.js to 7.20.13 +- Updated document-operations to 1.1.13 + ## [1.4.13](https://github.com/ExaDev/documents.js/compare/document-rest%401.4.12...document-rest%401.4.13) (2026-09-14) diff --git a/packages/document-rest/package.json b/packages/document-rest/package.json index e8471be8a..44099abae 100644 --- a/packages/document-rest/package.json +++ b/packages/document-rest/package.json @@ -1,6 +1,6 @@ { "name": "document-rest", - "version": "1.4.13", + "version": "1.4.14", "description": "A plain REST API server exposing document-operations' conversion, editing, inspection, and .odb operations as JSON-over-HTTP endpoints -- one POST route per operation, for a caller with no MCP client and no Node runtime of their own.", "type": "module", "repository": { @@ -87,8 +87,8 @@ "license": "MIT", "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "dependencies": { - "document-operations": "1.1.12", - "documents.js": "7.20.12", + "document-operations": "1.1.13", + "documents.js": "7.20.13", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/documents.js/CHANGELOG.md b/packages/documents.js/CHANGELOG.md index f2e4405a0..3eee375c6 100644 --- a/packages/documents.js/CHANGELOG.md +++ b/packages/documents.js/CHANGELOG.md @@ -1,3 +1,10 @@ +## [7.20.13](https://github.com/ExaDev/documents.js/compare/documents.js%407.20.12...documents.js%407.20.13) (2026-09-15) + + +### Dependencies + +- Updated rtf-codec to 4.4.5 + ## [7.20.12](https://github.com/ExaDev/documents.js/compare/documents.js%407.20.11...documents.js%407.20.12) (2026-09-14) diff --git a/packages/documents.js/package.json b/packages/documents.js/package.json index f5e5bc38b..f507889fa 100644 --- a/packages/documents.js/package.json +++ b/packages/documents.js/package.json @@ -1,6 +1,6 @@ { "name": "documents.js", - "version": "7.20.12", + "version": "7.20.13", "description": "Bidirectional docx/pptx <-> PDF conversion and a read+write editable OOXML document model, built on ooxml.js and Zod 4 codecs.", "type": "module", "repository": { @@ -113,7 +113,7 @@ "ooxml.js": "8.14.7", "pdf-codec": "4.8.5", "ppt-codec": "1.7.6", - "rtf-codec": "4.4.4", + "rtf-codec": "4.4.5", "temml": "0.13.4", "wpd-codec": "3.5.5", "xls-codec": "4.15.6", diff --git a/packages/odf.js/eslint.config.ts b/packages/odf.js/eslint.config.ts index 2ab706909..ec3cd18e3 100644 --- a/packages/odf.js/eslint.config.ts +++ b/packages/odf.js/eslint.config.ts @@ -22,8 +22,8 @@ export default tseslint.config( preferReadonlyParams: "off", }), { - // fast-xml-parser@5 deprecates the whole XMLBuilder class, not one of its options, and ships no replacement of its own -- it points at a separate `fast-xml-builder` package that is not a declared dependency here. Swapping it is a real dependency decision with round-trip fidelity to re-verify (this builder is what keeps XML byte-faithful), so it is tracked rather than guessed at inside a tooling change. Scoped to the one module that constructs the builder. - files: ["src/xml/build.ts"], + // fast-xml-parser@5 deprecates the whole XMLBuilder class, not one of its options, and ships no replacement of its own — it points at a separate `fast-xml-builder` package that is not a declared dependency here. Swapping it is a real dependency decision with round-trip fidelity to re-verify (this builder is what keeps XML byte-faithful), so it is tracked rather than guessed at inside a tooling change. Scoped to the one module that constructs the builder, plus its own test file, which necessarily references the identical deprecated class to reach BUILDER's shared prototype. + files: ["src/xml/build.ts", "src/xml/build.test.ts"], rules: { "@typescript-eslint/no-deprecated": "off" }, }, ); diff --git a/packages/odf.js/src/image/sniff.test.ts b/packages/odf.js/src/image/sniff.test.ts new file mode 100644 index 000000000..72ee9d647 --- /dev/null +++ b/packages/odf.js/src/image/sniff.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { sniffImageFormat } from "./sniff"; + +function bytesOf(values: number[]): Uint8Array { + return new Uint8Array(values); +} + +function asciiBytes(text: string): number[] { + return Array.from(text, (c) => c.charCodeAt(0)); +} + +describe("sniffImageFormat", () => { + it("detects a PNG from its 8-byte magic signature", () => { + expect( + sniffImageFormat( + bytesOf([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff]), + ), + ).toBe("png"); + }); + + it("detects a JPEG from its 3-byte magic signature", () => { + expect(sniffImageFormat(bytesOf([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg"); + }); + + it("detects a GIF87a header", () => { + expect(sniffImageFormat(bytesOf(asciiBytes("GIF87a").concat([0x00])))).toBe( + "gif", + ); + }); + + it("detects a GIF89a header", () => { + expect(sniffImageFormat(bytesOf(asciiBytes("GIF89a").concat([0x00])))).toBe( + "gif", + ); + }); + + it("returns undefined for bytes shorter than every signature it checks", () => { + expect(sniffImageFormat(bytesOf([0x89, 0x50]))).toBeUndefined(); + }); + + it("returns undefined for an empty byte array", () => { + expect(sniffImageFormat(bytesOf([]))).toBeUndefined(); + }); + + it("detects SVG from an XML prolog, with no ' { + const bytes = bytesOf(asciiBytes('')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("detects SVG from a bare ' { + const bytes = bytesOf( + asciiBytes(''), + ); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("skips leading whitespace before the ' { + const bytes = bytesOf(asciiBytes(' ')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("returns undefined for text that starts with neither ' { + const bytes = bytesOf(asciiBytes("")); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("never finds a root element hidden behind more leading whitespace than the sniff window covers", () => { + // The sniff window is capped at a fixed size specifically so a caller can't be made to scan an unboundedly large file — a real SVG's root element always appears well within it (see sniff.ts's own comment), so padding past the window with plain spaces before the real tag is exactly the case the cap is meant to give up on, not a bug to work around. + const paddingLength = 2000; + const bytes = bytesOf([ + ...Array(paddingLength).fill(0x20), + ...asciiBytes(""), + ]); + expect(bytes.length).toBeGreaterThan(1024); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("does not match bytes that merely end with, rather than start with, an SVG marker", () => { + const bytes = bytesOf( + asciiBytes("embeds a literal "), + ); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/image/sniff.ts b/packages/odf.js/src/image/sniff.ts index 3ae3c4740..14b78208d 100644 --- a/packages/odf.js/src/image/sniff.ts +++ b/packages/odf.js/src/image/sniff.ts @@ -12,13 +12,11 @@ const GIF89A_SIGNATURE: readonly number[] = [ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, ]; +// No separate "bytes too short" guard: when bytes.length < signature.length, some index i in the loop below reads past the end of bytes, and an out-of-bounds array read is `undefined` in JS -- which is never strictly equal to signature[i] (always a real 0-255 byte value), so the loop's own mismatch check already returns false for every too-short input. A dedicated length guard would only ever produce a result the loop already produces on its own. function startsWith( bytes: Uint8Array, signature: readonly number[], ): boolean { - if (bytes.length < signature.length) { - return false; - } for (let i = 0; i < signature.length; i++) { if (bytes[i] !== signature[i]) { return false; diff --git a/packages/odf.js/src/manifest.test.ts b/packages/odf.js/src/manifest.test.ts index 6f5361c47..c8ba1757b 100644 --- a/packages/odf.js/src/manifest.test.ts +++ b/packages/odf.js/src/manifest.test.ts @@ -25,6 +25,12 @@ const JPEG_BYTES: Uint8Array = new Uint8Array([ const WMF_BYTES: Uint8Array = new Uint8Array([ 0xd7, 0xcd, 0xc6, 0x9a, 1, 2, 3, ]); +const GIF_BYTES: Uint8Array = new Uint8Array([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 2, 3, +]); +const SVG_BYTES: Uint8Array = new TextEncoder().encode( + '', +); function binaryPart(bytes: Uint8Array): { kind: "binary"; @@ -104,6 +110,21 @@ describe("buildManifest", () => { ).toBe("image/jpeg"); }); + it("sniffs GIF/SVG bytes for a binary part with no recognised extension", () => { + const pkg = baseOdtPackage(); + pkg.parts["Thumbnails/gifthumb"] = binaryPart(GIF_BYTES); + pkg.parts["Thumbnails/svgthumb"] = binaryPart(SVG_BYTES); + const manifest = buildManifest(pkg); + expect( + manifest.entries.find((e) => e.fullPath === "Thumbnails/gifthumb") + ?.mediaType, + ).toBe("image/gif"); + expect( + manifest.entries.find((e) => e.fullPath === "Thumbnails/svgthumb") + ?.mediaType, + ).toBe("image/svg+xml"); + }); + it('falls back to empty string -- not "application/octet-stream" -- for a part it cannot classify by name or by sniffing', () => { const manifest = buildManifest(baseOdtPackage()); expect( @@ -124,6 +145,17 @@ describe("buildManifest", () => { ).toBe(ODT_MEDIA_TYPE); }); + it("never treats a part with no dot at all as having an extension, even when its whole basename spells a real ODF extension", () => { + const pkg = baseOdtPackage(); + pkg.parts.odt = binaryPart( + new TextEncoder().encode("not xml, not an image"), + ); + const manifest = buildManifest(pkg); + expect(manifest.entries.find((e) => e.fullPath === "odt")?.mediaType).toBe( + "", + ); + }); + it("an explicit mediaTypeOverrides entry wins over every automatic resolution rule", () => { const pkg = baseOdtPackage(); const manifest = buildManifest(pkg, { @@ -174,6 +206,26 @@ describe("writeManifest / readManifest round trip", () => { expect(readManifest(pkg)).toEqual(manifest); }); + it("omits the version property entirely (not just as undefined) for an entry whose file-entry element carries no manifest:version attribute", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "content.xml", + "manifest:media-type": "text/xml", + }), + ]), + ], + }, + }, + }; + const entry = readManifest(pkg).entries[0]; + expect(entry && "version" in entry).toBe(false); + }); + it("serializes manifest:file-entry attributes in full-path, version, media-type order, matching real-world ODF output", () => { const pkg = baseOdtPackage(); writeManifest(pkg, { @@ -192,8 +244,48 @@ describe("writeManifest / readManifest round trip", () => { ); }); - it("readManifest throws for a package with no manifest part", () => { - expect(() => readManifest({ parts: {} })).toThrow(); + it('opens with the standard declaration', () => { + const pkg = baseOdtPackage(); + writeManifest(pkg, { + version: "1.3", + entries: [{ fullPath: "/", mediaType: ODT_MEDIA_TYPE, version: "1.3" }], + }); + const part = pkg.parts[MANIFEST_PART]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const xml = buildXml(part.nodes); + expect(xml.startsWith('')).toBe(true); + }); + + it("readManifest throws for a package with no manifest part, naming the missing part", () => { + expect(() => readManifest({ parts: {} })).toThrow( + new RegExp(MANIFEST_PART.replace(/\//g, "\\/")), + ); + }); + + it("readManifest skips non-file-entry children (text nodes, other elements) while still reading real entries around them", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + txt("\n "), + el("manifest:not-a-file-entry"), + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + ]), + ], + }, + }, + }; + expect(readManifest(pkg)).toEqual({ + version: "1.3", + entries: [{ fullPath: "/", mediaType: ODT_MEDIA_TYPE }], + }); }); it("readManifest throws when the manifest XML has no manifest:manifest root element", () => { @@ -339,7 +431,7 @@ describe("validateManifest", () => { ).toBe(true); }); - it("reports an error when the root entry media type disagrees with the mimetype part", () => { + it("reports an error when the root entry media type disagrees with the mimetype part, naming both media types in the message", () => { const pkg = baseOdtPackage(); syncManifest(pkg); writeManifest(pkg, { @@ -353,9 +445,29 @@ describe("validateManifest", () => { ], }); const problems = validateManifest(pkg); - expect(problems.some((p) => p.severity === "error" && p.path === "/")).toBe( - true, + const problem = problems.find( + (p) => p.severity === "error" && p.path === "/", ); + expect(problem?.message).toContain( + "application/vnd.oasis.opendocument.spreadsheet", + ); + expect(problem?.message).toContain(ODT_MEDIA_TYPE); + }); + + it("does not report a media-type mismatch when the package has no mimetype part at all, regardless of the manifest root entry's own media type", () => { + const pkg: Package = { parts: {} }; + writeManifest(pkg, { + version: "1.3", + entries: [ + { + fullPath: "/", + mediaType: "application/vnd.oasis.opendocument.spreadsheet", + version: "1.3", + }, + ], + }); + const problems = validateManifest(pkg); + expect(problems.some((p) => p.path === "/")).toBe(false); }); it("reports a warning for a manifest entry with no corresponding part", () => { @@ -385,6 +497,62 @@ describe("validateManifest", () => { expect(problem?.message).toContain("settings.xml"); }); + it('never flags a directory entry (fullPath ending in "/") as a ghost part, since a directory has no literal corresponding zip part', () => { + const pkg = baseOdtPackage(); + syncManifest(pkg); + const manifest = readManifest(pkg); + writeManifest(pkg, { + ...manifest, + entries: [...manifest.entries, { fullPath: "Object 1/", mediaType: "" }], + }); + const problems = validateManifest(pkg); + expect(problems.some((p) => p.path === "Object 1/")).toBe(false); + }); + + it("reports no problems for a manifest whose only non-file-entry content is an unrelated sibling element, proving that element is genuinely skipped rather than mistaken for a malformed file-entry", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + el("manifest:unrelated-sibling"), + ]), + ], + }, + }, + }; + expect(validateManifest(pkg)).toEqual([]); + }); + + it("does not mistake an unrelated element sibling of manifest:file-entry, or an unrelated child of one, for manifest:encryption-data", () => { + const pkg = baseOdtPackage(); + const root = el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + el( + "manifest:file-entry", + { + "manifest:full-path": "content.xml", + "manifest:media-type": "text/xml", + }, + [el("manifest:not-encryption-data")], + ), + el("manifest:not-a-file-entry"), + ]); + pkg.parts[MANIFEST_PART] = { kind: "xml", nodes: [root] }; + const problems = validateManifest(pkg); + expect(problems.some((p) => p.message.includes("encryption-data"))).toBe( + false, + ); + }); + it("detects manifest:encryption-data on a file-entry and reports it as a warning", () => { const pkg = baseOdtPackage(); const encryptedEntry = el( @@ -435,6 +603,37 @@ describe("validateManifest", () => { expect(problem?.severity).toBe("warning"); expect(problem?.message).toContain("manifest:encryption-data"); }); + + it("skips a manifest:encryption-data entry with no manifest:full-path attribute, since readManifest's own required-attribute check already surfaces it", () => { + const pkg = baseOdtPackage(); + const encryptedEntryMissingPath = el("manifest:file-entry", {}, [ + el("manifest:encryption-data", { + "manifest:checksum-type": "SHA1/1K", + "manifest:checksum": "abc==", + }), + ]); + const root = el( + "manifest:manifest", + { + "xmlns:manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", + "manifest:version": "1.3", + }, + [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:version": "1.3", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + encryptedEntryMissingPath, + ], + ); + pkg.parts[MANIFEST_PART] = { kind: "xml", nodes: [root] }; + + const problems = validateManifest(pkg); + expect( + problems.some((p) => p.message.includes("manifest:encryption-data")), + ).toBe(false); + }); }); describe("setDocumentMediaType", () => { @@ -447,6 +646,19 @@ describe("setDocumentMediaType", () => { }); }); + it("replaces the existing root entry in place rather than appending a second one, when a root entry already exists", () => { + const pkg = baseOdtPackage(); + syncManifest(pkg); + setDocumentMediaType( + pkg, + "application/vnd.oasis.opendocument.text-template", + ); + const rootEntries = readManifest(pkg).entries.filter( + (e) => e.fullPath === "/", + ); + expect(rootEntries).toHaveLength(1); + }); + it("atomically updates both the mimetype part and the manifest root entry", () => { const pkg = baseOdtPackage(); syncManifest(pkg); @@ -477,6 +689,20 @@ describe("setDocumentMediaType", () => { expect(after).toEqual(before); }); + it("prepends a root entry when an existing manifest has none yet", () => { + const pkg: Package = { parts: {} }; + writeManifest(pkg, { + version: "1.3", + entries: [{ fullPath: "content.xml", mediaType: "text/xml" }], + }); + setDocumentMediaType(pkg, ODT_MEDIA_TYPE); + const manifest = readManifest(pkg); + expect(manifest.entries).toEqual([ + { fullPath: "/", mediaType: ODT_MEDIA_TYPE, version: "1.3" }, + { fullPath: "content.xml", mediaType: "text/xml" }, + ]); + }); + it("keeps manifest:manifest's own version and the root entry's version in step with the version argument", () => { const pkg = baseOdtPackage(); syncManifest(pkg); diff --git a/packages/odf.js/src/manifest.ts b/packages/odf.js/src/manifest.ts index 36e3a59c7..1c764b1a8 100644 --- a/packages/odf.js/src/manifest.ts +++ b/packages/odf.js/src/manifest.ts @@ -60,6 +60,38 @@ function attrValue(element: XmlElement, name: string): string | undefined { return element.attributes.find((attribute) => attribute.name === name)?.value; } +// One parsed manifest:file-entry, still carrying its own source element -- so a caller that also needs the raw XML (validateManifest's own encryption-data scan, below) can inspect it without a second, independent full-path/media-type re-validation of the same element. +interface ParsedFileEntry { + element: XmlElement; + fullPath: string; + mediaType: string; + version: string | undefined; +} + +// The one place that walks a manifest:manifest root's manifest:file-entry children and enforces the ODF spec's required attributes on each -- both readManifest (which only needs the resulting ManifestEntry values) and validateManifest's own encryption-data scan (which additionally needs each element's own children) build on this single parse rather than repeating the required-attribute check a second time. Throws under the identical condition readManifest documents. +function parseFileEntryElements(root: XmlElement): ParsedFileEntry[] { + const result: ParsedFileEntry[] = []; + for (const child of root.children) { + if (child.type !== "element" || child.tag !== "manifest:file-entry") { + continue; + } + const fullPath = attrValue(child, "manifest:full-path"); + const mediaType = attrValue(child, "manifest:media-type"); + if (fullPath === undefined || mediaType === undefined) { + throw new Error( + `${MANIFEST_PART} has a manifest:file-entry missing manifest:full-path or manifest:media-type`, + ); + } + result.push({ + element: child, + fullPath, + mediaType, + version: attrValue(child, "manifest:version"), + }); + } + return result; +} + // Reads META-INF/manifest.xml into a structured Manifest. Throws for a package that has no manifest part, or one whose XML does not carry the elements/attributes the ODF spec requires (no manifest:manifest root, or a manifest:file-entry missing its required manifest:full-path/manifest:media-type) -- unlike validateManifest, this is a strict parse, not a diagnostics collector. export function readManifest(pkg: Package): Manifest { const part = pkg.parts[MANIFEST_PART]; @@ -77,25 +109,12 @@ export function readManifest(pkg: Package): Manifest { ); } - const entries: ManifestEntry[] = []; - for (const child of root.children) { - if (child.type !== "element" || child.tag !== "manifest:file-entry") { - continue; - } - const fullPath = attrValue(child, "manifest:full-path"); - const mediaType = attrValue(child, "manifest:media-type"); - if (fullPath === undefined || mediaType === undefined) { - throw new Error( - `${MANIFEST_PART} has a manifest:file-entry missing manifest:full-path or manifest:media-type`, - ); - } - const entryVersion = attrValue(child, "manifest:version"); - entries.push( + const entries: ManifestEntry[] = parseFileEntryElements(root).map( + ({ fullPath, mediaType, version: entryVersion }) => entryVersion === undefined ? { fullPath, mediaType } : { fullPath, mediaType, version: entryVersion }, - ); - } + ); return { version, entries }; } @@ -126,9 +145,11 @@ function resolvePartMediaType( } const dotIndex = baseName.lastIndexOf("."); - const extension = dotIndex === -1 ? "" : baseName.slice(dotIndex + 1); + // No intermediate "extension === '' ? undefined : ..." fallback: mediaTypeForExtension("") already returns undefined on its own (the empty string is never a key in ODF_MEDIA_TYPES), so that check was always redundant. Skipping the lookup entirely when there is no dot at all -- rather than computing an empty-string placeholder and feeding it through the same lookup -- keeps a nameless part from ever being mistaken for one whose whole basename happens to spell a real ODF extension (e.g. a part literally named "odt" with no dot). const byExtension = - extension === "" ? undefined : mediaTypeForExtension(extension); + dotIndex === -1 + ? undefined + : mediaTypeForExtension(baseName.slice(dotIndex + 1)); if (byExtension !== undefined) { return byExtension; } @@ -322,8 +343,8 @@ export function validateManifest(pkg: Package): ManifestProblem[] { ); for (const entry of manifest.entries) { - // Root and directory entries have no literal corresponding zip part -- "/" is the package itself, and a directory entry describes a prefix, not a physical entry. - if (entry.fullPath === "/" || entry.fullPath.endsWith("/")) { + // Root and directory entries have no literal corresponding zip part -- "/" is the package itself (and, being a single "/" character, trivially satisfies endsWith("/") on its own, so it needs no separate check), and a directory entry describes a prefix, not a physical entry. + if (entry.fullPath.endsWith("/")) { continue; } if (!partPaths.has(entry.fullPath)) { @@ -344,11 +365,9 @@ export function validateManifest(pkg: Package): ManifestProblem[] { } } - for (const child of root.children) { - if (child.type !== "element" || child.tag !== "manifest:file-entry") { - continue; - } - const hasEncryptionData = child.children.some( + // Reusing readManifest's own parseFileEntryElements rather than a second, independent full-path re-check: the try/catch above already proved every manifest:file-entry here has both required attributes, so this parse is guaranteed to succeed identically and each fullPath below is a plain string, not string | undefined. + for (const { element, fullPath } of parseFileEntryElements(root)) { + const hasEncryptionData = element.children.some( (grandchild) => grandchild.type === "element" && grandchild.tag === "manifest:encryption-data", @@ -356,10 +375,6 @@ export function validateManifest(pkg: Package): ManifestProblem[] { if (!hasEncryptionData) { continue; } - const fullPath = attrValue(child, "manifest:full-path"); - if (fullPath === undefined) { - continue; // already surfaced above by readManifest's own required-attribute check - } problems.push({ severity: "warning", message: `entry "${fullPath}" carries manifest:encryption-data -- odf.js does not implement ODF encryption/decryption`, diff --git a/packages/odf.js/src/model/node.test.ts b/packages/odf.js/src/model/node.test.ts new file mode 100644 index 000000000..2cce543d4 --- /dev/null +++ b/packages/odf.js/src/model/node.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { isXmlNode } from "./node"; + +// isXmlNode is a hand-written recursive structural guard (used via z.custom, since a genuinely recursive Zod schema collapses to `unknown` under z.lazy in this pinned version) with no direct unit tests at all -- every place it runs is exercised only as a side effect of parsing a real XML document, which never constructs the malformed shapes below. + +describe("isXmlNode: non-object/malformed input", () => { + it("rejects null, a primitive, and an array outright", () => { + expect(isXmlNode(null)).toBe(false); + expect(isXmlNode("a string")).toBe(false); + expect(isXmlNode(42)).toBe(false); + expect(isXmlNode([])).toBe(false); + }); + + it("rejects a plain object with no type field", () => { + expect(isXmlNode({})).toBe(false); + }); + + it("rejects an unrecognised type value", () => { + expect(isXmlNode({ type: "bogus" })).toBe(false); + }); + + it("rejects an unrecognised type value even when the rest of the object is shaped exactly like a valid element", () => { + expect( + isXmlNode({ type: "bogus", tag: "text:p", attributes: [], children: [] }), + ).toBe(false); + }); + + it('rejects a non-object value whose typeof is not "object" (a function) even when it carries otherwise-valid text-node properties', () => { + const fn = Object.assign(() => {}, { type: "text", value: "hi" }); + expect(isXmlNode(fn)).toBe(false); + }); +}); + +describe("isXmlNode: text/cdata/comment", () => { + it.each(["text", "cdata", "comment"] as const)( + "accepts type %s with a string value", + (type) => { + expect(isXmlNode({ type, value: "hello" })).toBe(true); + }, + ); + + it.each(["text", "cdata", "comment"] as const)( + "rejects type %s when value is not a string", + (type) => { + expect(isXmlNode({ type, value: 5 })).toBe(false); + expect(isXmlNode({ type })).toBe(false); + }, + ); +}); + +describe("isXmlNode: declaration", () => { + it("accepts a declaration with an empty attributes array", () => { + expect(isXmlNode({ type: "declaration", attributes: [] })).toBe(true); + }); + + it("accepts a declaration whose every attribute is a valid {name, value} pair", () => { + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }), + ).toBe(true); + }); + + it("rejects a declaration whose attributes is not an array", () => { + expect(isXmlNode({ type: "declaration", attributes: "nope" })).toBe(false); + }); + + it("rejects a declaration whose attribute has a non-string name but a valid string value", () => { + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: 5, value: "1.0" }], + }), + ).toBe(false); + }); + + it("rejects a declaration with one malformed attribute among otherwise-valid ones", () => { + expect( + isXmlNode({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding" }, // missing value + ], + }), + ).toBe(false); + }); +}); + +describe("isXmlNode: pi", () => { + it("accepts a pi with string target and content", () => { + expect( + isXmlNode({ type: "pi", target: "xml-stylesheet", content: "foo" }), + ).toBe(true); + }); + + it("rejects a pi missing either target or content", () => { + expect(isXmlNode({ type: "pi", target: "x" })).toBe(false); + expect(isXmlNode({ type: "pi", content: "x" })).toBe(false); + }); + + it("rejects a pi whose target or content is not a string", () => { + expect(isXmlNode({ type: "pi", target: 1, content: "x" })).toBe(false); + expect(isXmlNode({ type: "pi", target: "x", content: 1 })).toBe(false); + }); +}); + +describe("isXmlNode: element", () => { + function validElement(overrides: Record = {}) { + return { + type: "element", + tag: "text:p", + attributes: [], + children: [], + ...overrides, + }; + } + + it("accepts a leaf element with no attributes or children", () => { + expect(isXmlNode(validElement())).toBe(true); + }); + + it("rejects an element whose tag is not a string", () => { + expect(isXmlNode(validElement({ tag: 5 }))).toBe(false); + }); + + it("rejects an element whose attributes is not an array", () => { + expect(isXmlNode(validElement({ attributes: "nope" }))).toBe(false); + }); + + it("rejects an element with one malformed attribute", () => { + expect(isXmlNode(validElement({ attributes: [{ name: "x" }] }))).toBe( + false, + ); + }); + + it("rejects an element whose children is not an array", () => { + expect(isXmlNode(validElement({ children: "nope" }))).toBe(false); + }); + + it("accepts an element whose children are all valid nodes, recursively", () => { + const child = { type: "text", value: "hi" }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(true); + }); + + it("rejects an element with one malformed child among otherwise-valid ones", () => { + const goodChild = { type: "text", value: "hi" }; + const badChild = { type: "text", value: 5 }; + expect(isXmlNode(validElement({ children: [goodChild, badChild] }))).toBe( + false, + ); + }); + + it("rejects an element nested two levels deep whose innermost grandchild is malformed", () => { + const malformedGrandchild = { type: "comment", value: 5 }; + const child = { + type: "element", + tag: "text:span", + attributes: [], + children: [malformedGrandchild], + }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(false); + }); + + it("accepts an element nested two levels deep whose every descendant is well-formed", () => { + const grandchild = { type: "text", value: "deep" }; + const child = { + type: "element", + tag: "text:span", + attributes: [], + children: [grandchild], + }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(true); + }); +}); diff --git a/packages/odf.js/src/ooo1/ns.test.ts b/packages/odf.js/src/ooo1/ns.test.ts index e90bce876..0c798e3ca 100644 --- a/packages/odf.js/src/ooo1/ns.test.ts +++ b/packages/odf.js/src/ooo1/ns.test.ts @@ -140,6 +140,21 @@ describe("isOoo1Package", () => { expect(isOoo1Package(pkg)).toBe(true); }); + it("recognises a bare, default (non-prefixed) xmlns declaration too", () => { + const pkg = packageOf({ + "content.xml": ``, + }); + expect(isOoo1Package(pkg)).toBe(true); + }); + + it("does not treat an ordinary attribute whose value happens to equal an OOo1 URI as a namespace declaration", () => { + // office:version here is neither "xmlns" nor "xmlns:"-prefixed -- only its VALUE coincides with a real OOo1 namespace URI, which must not be enough on its own. + const pkg = packageOf({ + "content.xml": ``, + }); + expect(isOoo1Package(pkg)).toBe(false); + }); + it("rejects a real ODF package", () => { const pkg = packageOf({ "content.xml": `hi`, diff --git a/packages/odf.js/src/ooo1/properties.test.ts b/packages/odf.js/src/ooo1/properties.test.ts new file mode 100644 index 000000000..a1d245029 --- /dev/null +++ b/packages/odf.js/src/ooo1/properties.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; +import type { XmlElement } from "../model/node"; +import { el, txt } from "../xml/fragment"; +import { + propertyTypesForContainer, + splitStyleProperties, + mergeStyleProperties, +} from "./properties"; + +// This module had no direct unit tests at all -- every reader/writer that touches it only exercises it indirectly through a whole-document round trip. Direct coverage below targets propertyTypesForContainer's own routing branches, splitStyleProperties' first-match-wins/fallback-to-first-candidate routing (including the two OpenOffice.org compound-attribute expansions), and mergeStyleProperties' found/not-found and multi-child concatenation behaviour. + +describe("propertyTypesForContainer", () => { + it("resolves a container tag with no style:family attribute at all, e.g. style:page-master", () => { + expect(propertyTypesForContainer(el("style:page-master"))).toEqual([ + "page-layout", + ]); + }); + + it("resolves a number:*-style container tag to ['text']", () => { + expect(propertyTypesForContainer(el("number:date-style"))).toEqual([ + "text", + ]); + }); + + it("resolves style:style by its own style:family attribute", () => { + expect( + propertyTypesForContainer( + el("style:style", { "style:family": "table-cell" }), + ), + ).toEqual(["table-cell", "paragraph", "text"]); + }); + + it("resolves style:default-style by its own style:family attribute exactly like style:style", () => { + expect( + propertyTypesForContainer( + el("style:default-style", { "style:family": "paragraph" }), + ), + ).toEqual(["paragraph", "text"]); + }); + + it("returns undefined for style:style with no style:family attribute at all", () => { + expect(propertyTypesForContainer(el("style:style"))).toBeUndefined(); + }); + + it("returns undefined for style:style whose style:family this module does not recognise", () => { + expect( + propertyTypesForContainer( + el("style:style", { "style:family": "unknown-family" }), + ), + ).toBeUndefined(); + }); + + it("returns undefined for a container tag this module recognises neither by tag nor as a family-bearing style element", () => { + expect(propertyTypesForContainer(el("office:styles"))).toBeUndefined(); + }); + + it("returns undefined for a non-style:style/default-style tag even when it happens to carry a recognised style:family attribute, since only style:style/default-style consult it", () => { + expect( + propertyTypesForContainer( + el("office:styles", { "style:family": "paragraph" }), + ), + ).toBeUndefined(); + }); +}); + +describe("splitStyleProperties: attribute routing", () => { + it("routes an attribute to the first candidate whose vocabulary claims it", () => { + // fo:background-color is claimed by table-cell, paragraph, and graphic alike; with table-cell first in the candidate list it must win, not paragraph. + const properties = el("style:properties", { + "fo:background-color": "#ff0000", + }); + const split = splitStyleProperties(properties, [ + "table-cell", + "paragraph", + "text", + ]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:table-cell-properties"); + }); + + it("routes the identical attribute to paragraph instead, when paragraph is first in the candidate list", () => { + const properties = el("style:properties", { + "fo:background-color": "#ff0000", + }); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:paragraph-properties"); + }); + + it("falls back to the first candidate for an attribute no candidate's vocabulary claims", () => { + const properties = el("style:properties", { + "draw:some-unlisted-attribute": "x", + }); + const split = splitStyleProperties(properties, ["graphic", "paragraph"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:graphic-properties"); + }); + + it("emits one typed element per candidate that actually received something, in candidate order, and none for a candidate that received nothing", () => { + const properties = el("style:properties", { + "fo:font-family": "Arial", // text-only + "fo:margin-top": "2pt", // paragraph-only + }); + const split = splitStyleProperties(properties, [ + "table-cell", + "paragraph", + "text", + ]); + expect(split.map((s) => s.tag)).toEqual([ + "style:paragraph-properties", + "style:text-properties", + ]); + }); + + it("skips non-element children (whitespace/comment text nodes) without filing them into any family", () => { + const properties = el("style:properties", {}, [txt(" ")]); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(0); + }); + + it("routes a style:properties child element the identical first-match way attributes are routed", () => { + const tabStops = el("style:tab-stops"); + const properties = el("style:properties", {}, [tabStops]); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:paragraph-properties"); + expect(split[0]?.children).toEqual([tabStops]); + }); + + it("throws rather than silently dropping an attribute when the candidate list is empty, since a family with zero property types is a caller programming error, not a routable input", () => { + const properties = el("style:properties", { "fo:color": "#000000" }); + expect(() => splitStyleProperties(properties, [])).toThrow( + "a style family must have at least one property type", + ); + }); +}); + +describe("splitStyleProperties: style:text-underline expansion", () => { + function underlineStyleOf(value: string): XmlElement | undefined { + const properties = el("style:properties", { + "style:text-underline": value, + }); + const split = splitStyleProperties(properties, ["text"]); + return split[0]; + } + + it('"single" expands to style: "solid" alone, no width or type attribute', () => { + const result = underlineStyleOf("single"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + ]); + }); + + it('"double" expands to style: "solid" plus type: "double"', () => { + const result = underlineStyleOf("double"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + { name: "style:text-underline-type", value: "double" }, + ]); + }); + + it('"bold" expands to style: "solid" plus width: "bold"', () => { + const result = underlineStyleOf("bold"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + { name: "style:text-underline-width", value: "bold" }, + ]); + }); + + it("a value with no listed expansion passes straight through as the style itself", () => { + const result = underlineStyleOf("dotted"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "dotted" }, + ]); + }); +}); + +describe("splitStyleProperties: style:text-crossing-out expansion", () => { + function lineThroughStyleOf(value: string): XmlElement | undefined { + const properties = el("style:properties", { + "style:text-crossing-out": value, + }); + const split = splitStyleProperties(properties, ["text"]); + return split[0]; + } + + it('"single-line" expands to style: "solid" alone', () => { + expect(lineThroughStyleOf("single-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + ]); + }); + + it('"slash" expands to style: "solid" plus text: "/"', () => { + expect(lineThroughStyleOf("slash")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-text", value: "/" }, + ]); + }); + + it('"X" expands to style: "solid" plus text: "X"', () => { + expect(lineThroughStyleOf("X")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-text", value: "X" }, + ]); + }); + + it('"double-line" expands to style: "solid" plus type: "double"', () => { + expect(lineThroughStyleOf("double-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-type", value: "double" }, + ]); + }); + + it('"thick-line" expands to style: "solid" plus width: "bold"', () => { + expect(lineThroughStyleOf("thick-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-width", value: "bold" }, + ]); + }); + + it("a value with no listed expansion passes straight through as the style itself", () => { + expect(lineThroughStyleOf("dash")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "dash" }, + ]); + }); +}); + +describe("splitStyleProperties: fo:keep-with-next boolean-to-keyword rewrite", () => { + it('"true" becomes "always"', () => { + const properties = el("style:properties", { "fo:keep-with-next": "true" }); + const split = splitStyleProperties(properties, ["paragraph"]); + expect(split[0]?.attributes).toEqual([ + { name: "fo:keep-with-next", value: "always" }, + ]); + }); + + it('anything other than "true" (e.g. "false") becomes "auto"', () => { + const properties = el("style:properties", { + "fo:keep-with-next": "false", + }); + const split = splitStyleProperties(properties, ["paragraph"]); + expect(split[0]?.attributes).toEqual([ + { name: "fo:keep-with-next", value: "auto" }, + ]); + }); +}); + +describe("mergeStyleProperties", () => { + it("returns merged: undefined and the input untouched when no typed properties child is present", () => { + const other = el("style:map"); + const result = mergeStyleProperties([other]); + expect(result.merged).toBeUndefined(); + expect(result.rest).toEqual([other]); + }); + + it("concatenates a single typed properties element's own attributes and children into one style:properties", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }, [ + el("style:some-child"), + ]); + const result = mergeStyleProperties([textProps]); + expect(result.merged).toMatchObject({ + tag: "style:properties", + attributes: [{ name: "fo:color", value: "#000000" }], + }); + expect(result.merged?.children).toEqual([el("style:some-child")]); + }); + + it("concatenates MULTIPLE typed properties elements' own attributes/children together, in encounter order", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }); + const paraProps = el("style:paragraph-properties", { + "fo:text-align": "center", + }); + const result = mergeStyleProperties([textProps, paraProps]); + expect(result.merged?.attributes).toEqual([ + { name: "fo:color", value: "#000000" }, + { name: "fo:text-align", value: "center" }, + ]); + }); + + it("keeps a non-properties child in rest, in its original relative position", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }); + const map = el("style:map"); + const result = mergeStyleProperties([map, textProps]); + expect(result.rest).toEqual([map]); + }); +}); diff --git a/packages/odf.js/src/ooo1/transform.test.ts b/packages/odf.js/src/ooo1/transform.test.ts index ef7e0d0d1..dd023adef 100644 --- a/packages/odf.js/src/ooo1/transform.test.ts +++ b/packages/odf.js/src/ooo1/transform.test.ts @@ -96,11 +96,54 @@ describe("transformOoo1Package: namespaces", () => { it("normalises a non-conventional prefix binding onto the canonical prefix", () => { // Nothing forces a producer to use the conventional prefixes; the URI is what binds. A document binding the text vocabulary to "t:" must still read, because every reader in this package matches on the canonical prefix. const out = transformWhole( - `hi`, + `hi`, ); expect(out).toContain("`); + expect(out).toContain(``); expect(out).not.toContain("`); + // "tz" carries no colon for renameQName to act on and must survive untouched -- chosen so that a broken "no colon" early-return would slice it down to "t" (a real prefix bound above to "text") and wrongly rewrite it to "text:tz". + expect(out).toContain(`tz="unchanged"`); + }); + + it("never resolves office:class through a decoy prefix bound to something other than the office namespace", () => { + // "t" is bound to the text namespace, never office -- classAttributeName must stay undefined, so a decoy "t:class" attribute placed before the real office:class is never mistaken for it. + const out = transformWhole( + `hi`, + ); + expect(out).toContain(``); + }); + + it("resolves office:class from its own literal attribute name when no prefix is aliased to the office namespace at all", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(``); + }); + + it("never treats a coincidentally 'undefined:class'-named attribute as office:class when no alias prefix is bound", () => { + const out = transformWhole( + `hi`, + ); + expect(out).not.toContain("office:spreadsheet"); + expect(out).toContain(`hi`); + }); + + it("rewrites a default (unprefixed) xmlns declaration to its OASIS successor, keeping it unprefixed", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`xmlns="${ODF_NAMESPACES.office}"`); + }); + + it("does not treat a non-xmlns attribute as a namespace declaration even when its value matches a known namespace URI", () => { + // "aaaaaatext" is deliberately not a namespace declaration (no "xmlns:" prefix) but is long enough that a broken skip-check would slice it down to "text" and, seeing its value resolve to the table URI, silently redirect every text:-prefixed name in the document onto table: instead. + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`hi`); + expect(out).not.toContain("table:p"); }); it("leaves a package that is not OpenOffice.org 1.x completely alone", () => { @@ -148,6 +191,39 @@ describe("transformOoo1Package: document structure", () => { ); expect(out).toContain(" { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`office:version="1.0"`); + expect(out).not.toMatch(/]*office:class=/); + expect(out).toContain(`office:class="not-a-root"`); + }); + + it("leaves office:body unwrapped when there is no office:class to derive a genre from", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`hi`); + }); + + it("leaves an XML part with no root element completely alone", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + "stray.xml": { kind: "xml", nodes: [{ type: "text", value: "stray" }] }, + }, + }; + const out = transformOoo1Package(pkg); + expect(out.parts["stray.xml"]).toEqual({ + kind: "xml", + nodes: [{ type: "text", value: "stray" }], + }); + }); }); describe("transformOoo1Package: style:properties splitting", () => { @@ -248,6 +324,13 @@ describe("transformOoo1Package: style:properties splitting", () => { expect(out).toContain(`style:family="presentation"`); expect(out).toContain(`style:family="drawing-page"`); }); + + it("leaves a style's own style:properties unsplit when its family is not one this codec classifies", () => { + const out = automaticStyles( + ``, + ); + expect(out).toContain(``); + }); }); describe("transformOoo1Package: text vocabulary", () => { @@ -260,6 +343,15 @@ describe("transformOoo1Package: text vocabulary", () => { ); }); + it("renames a paragraph's text:level to text:outline-level too, not only a heading's", () => { + const out = transformContent( + `Body`, + ); + expect(out).toContain( + `Body`, + ); + }); + it("leaves text:level alone on a list level style, where ODF kept the name", () => { const out = transformWhole( ``, @@ -323,6 +415,78 @@ describe("transformOoo1Package: table and drawing vocabulary", () => { ); }); + it("renames a cell's own validation-name to the content-validation-name ODF spells it with", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`table:content-validation-name="V1"`); + expect(out).not.toContain("table:validation-name"); + }); + + it("leaves a table:value-type-shaped attribute alone outside a real cell element", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(``); + }); + + it("moves a text field's value-carrying attributes to the office namespace", () => { + const out = transformContent( + `42`, + ); + expect(out).toContain( + `42`, + ); + }); + + it("leaves a text:value-type-shaped attribute alone outside a real text-value element", () => { + const out = transformContent(`hi`); + expect(out).toContain(`hi`); + }); + + it("renames a multi-column layout's own margin attributes to the indent pair, leaving any other attribute alone", () => { + const out = transformContent( + ``, + ); + expect(out).toContain( + ``, + ); + }); + + it("renames table:sub-table to table:table with the sub-table flag", () => { + const out = transformContent( + ``, + ); + expect(out).toContain( + ``, + ); + expect(out).not.toContain("table:sub-table"); + }); + + it("drops the boolean form:property-is-list flag while keeping the property's own other attributes", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(``); + expect(out).not.toContain("form:property-is-list"); + }); + + it("never rewrites an inch-shaped token inside a name-suffixed or xlink:href attribute", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`draw:name="Logo 2inch"`); + expect(out).toContain(`xlink:href="note 5inch"`); + }); + + it("only rewrites a package-internal xlink:href with a leading '#' on a frame-eligible element, leaving other attributes and non-matching hrefs alone", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`draw:name="#weird"`); + expect(out).toContain(`xlink:href="Pictures/a.png"`); + }); + it("wraps a bare drawing shape in the draw:frame ODF introduced, moving the frame-level attributes onto it", () => { const out = transformContent( ``, @@ -389,6 +553,126 @@ describe("transformOoo1Package: metadata and package parts", () => { expect(new TextDecoder().decode(base64ToBytes(mimetype.base64))).toBe( "application/vnd.oasis.opendocument.text", ); + // The second, non-root entry's own media type is not a manifest concern of this rewrite -- it must survive untouched. + expect(xml).toContain( + `manifest:media-type="text/xml" manifest:full-path="content.xml"`, + ); + }); + + it("only rewrites manifest:file-entry children when writing the synthesised media type, leaving a same-shaped decoy tag alone", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg); + const manifest = out.parts["META-INF/manifest.xml"]; + if (manifest?.kind !== "xml") { + throw new Error("manifest did not survive as an XML part"); + } + const xml = selfCloseEmpty(buildXml(manifest.nodes)); + expect(xml).toContain( + ``, + ); + expect(xml).toContain( + ``, + ); + }); + + it("resolves the manifest's own media type from its root entry alone, skipping a decoy element, a non-root entry, and a decoy attribute", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + `` + + `` + + `` + + `` + + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg); + const mimetype = out.parts.mimetype; + if (mimetype?.kind !== "binary") { + throw new Error("mimetype part was not written as a binary part"); + } + expect(new TextDecoder().decode(base64ToBytes(mimetype.base64))).toBe( + "application/vnd.oasis.opendocument.text", + ); + }); + + it("never treats a same-shaped element elsewhere in the package as a manifest entry, even when odfMediaType resolves", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + "content.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg).parts["content.xml"]; + if (out?.kind !== "xml") { + throw new Error("content.xml did not survive as an XML part"); + } + const xml = selfCloseEmpty(buildXml(out.nodes)); + expect(xml).toContain( + `manifest:media-type="application/vnd.sun.xml.writer"`, + ); + }); + + it("synthesises no mimetype part when the manifest's own root media type can't be resolved", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + }, + }; + expect(transformOoo1Package(pkg).parts.mimetype).toBeUndefined(); + }); + + it("never overwrites a mimetype part the package already carries", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + mimetype: { kind: "binary", base64: "AAEC" }, + }, + }; + expect(transformOoo1Package(pkg).parts.mimetype).toEqual({ + kind: "binary", + base64: "AAEC", + }); + }); + + it("does not restructure an already-ODF package even where its shapes overlap with an OpenOffice.org 1.x rewrite rule", () => { + const odf = ``; + expect(transformWhole(odf)).toBe(selfCloseEmpty(buildXml(parseXml(odf)))); }); it("carries binary parts through untouched", () => { @@ -504,6 +788,73 @@ describe("transformToOoo1Package: namespaces and package identity", () => { expect(manifestXml).toContain( `manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.sun.xml.writer"`, ); + // The second, non-root entry's own media type is not this rewrite's concern -- it must survive untouched. + expect(manifestXml).toContain( + `manifest:full-path="content.xml" manifest:media-type="text/xml"`, + ); + }); + + it("only rewrites the manifest's own root entry, skipping a same-shaped decoy tag and a decoy attribute placed before the real full-path", () => { + const pkg = odfPackage(``, { + extraParts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }); + const manifestXml = reversePart(pkg, "META-INF/manifest.xml"); + expect(manifestXml).toContain( + ``, + ); + expect(manifestXml).toContain( + `manifest:decoy="nope" manifest:full-path="/" manifest:media-type="application/vnd.sun.xml.writer"`, + ); + }); + + it("reverses a default (unprefixed) xmlns declaration back to its OpenOffice.org 1.x predecessor, keeping it unprefixed", () => { + const pkg = odfPackage(`hi`); + const contentPart = pkg.parts["content.xml"]; + if (contentPart?.kind !== "xml") { + throw new Error("content.xml is not an xml part"); + } + const root = rootElement(contentPart.nodes); + if (root === undefined) { + throw new Error("no root"); + } + root.attributes.push({ name: "xmlns", value: ODF_NAMESPACES.office }); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(`xmlns="http://openoffice.org/2000/office"`); + }); + + it("never treats a same-shaped element elsewhere in the package as a manifest entry when reversing either", () => { + const pkg = odfPackage(``, { + extraParts: { + "other.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }); + const out = reversePart(pkg, "other.xml"); + expect(out).toContain(`manifest:media-type="text/plain"`); + }); + + it("leaves an XML part with no root element completely alone in reverse too", () => { + const pkg = odfPackage(``, { + extraParts: { + "stray.xml": { kind: "xml", nodes: [{ type: "text", value: "stray" }] }, + }, + }); + const out = transformToOoo1Package(pkg); + expect(out.parts["stray.xml"]).toEqual({ + kind: "xml", + nodes: [{ type: "text", value: "stray" }], + }); }); it("leaves a package with no mimetype part -- already OpenOffice.org 1.x-shaped, or not a document this module can identify -- completely alone", () => { @@ -537,6 +888,29 @@ describe("transformToOoo1Package: document structure", () => { expect(out).toContain(`office:class="${documentClass}"`); }); + it("recurses into office:body's own children directly when they are not wrapped in a recognised genre element", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `hi`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(`hi`); + }); + + it("only unwraps a genre child for office:body itself, never for another element whose own first child happens to share a genre tag", () => { + const out = reverseContent( + `trap`, + ); + expect(out).toContain(`trap`); + }); + it("renames the font declaration container and its entries", () => { const pkg = odfPackage(``); const contentPart = pkg.parts["content.xml"]; @@ -627,6 +1001,13 @@ describe("transformToOoo1Package: style:*-properties merging", () => { expect(out).toContain(`fo:keep-with-next="false"`); }); + it("leaves fo:keep-with-next alone when its value is neither always nor auto", () => { + const out = automaticStylesReverse( + ``, + ); + expect(out).toContain(`fo:keep-with-next="page"`); + }); + // The forward rename's own inverse, and the one this direction cannot skip: a real consumer resolves a shape's draw:style-name against the plural spelling alone, so an OpenOffice.org 1.x package whose graphic styles still say "graphic" imports with every fill and stroke silently unbound (confirmed against LibreOffice 26.2 -- see the package README's own .sxd verification section). it("renames a drawing style's style:family back from graphic to graphics", () => { const out = automaticStylesReverse( @@ -657,6 +1038,11 @@ describe("transformToOoo1Package: text vocabulary", () => { ); }); + it("reverses text:outline-level back to text:level only on text:h, leaving it alone elsewhere", () => { + const out = reverseContent(`Body`); + expect(out).toContain(`Body`); + }); + it("renames the inline text:tab back to text:tab-stop", () => { const out = reverseContent(`ab`); expect(out).toContain(`ab`); @@ -675,6 +1061,62 @@ describe("transformToOoo1Package: text vocabulary", () => { expect(out).not.toContain("text:note-class"); }); + it("splits text:note-ref back into footnote-ref/endnote-ref by its own note-class", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { + const out = reverseContent( + `1n`, + ); + expect(out).toContain(``); + expect(out).toContain(`1`); + expect(out).toContain( + `n`, + ); + }); + + it("reverses the plain RENAMED_ATTRIBUTES table by explicit name", () => { + const out = reverseContent( + `hi`, + ); + expect(out).toContain(`style:page-master-name="pm1"`); + expect(out).toContain(`style:leader-char="."`); + expect(out).toContain(`text:count-in-floating-frames="true"`); + }); + + it("reverses form:control-implementation and form:text-style-name by explicit name", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain( + `form:service-name="com.sun.star.form.control.TextField"`, + ); + expect(out).toContain(`form:column-style-name="S1"`); + }); + + it("reverses office:value-type on a form:property back to form:property-type, but leaves it as office:value-type on an ordinary element", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { const out = reverseContent( `Ada2003-10-16T09:22:13comment`, @@ -719,6 +1161,66 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { expect(out).toContain(``); }); + it("only converts table:is-sub-table on a real table:table, never a same-shaped attribute on another element", () => { + const out = reverseContent(`hi`); + expect(out).toContain(`hi`); + }); + + it("reverses a cell's own content-validation-name back to validation-name", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(`table:validation-name="V1"`); + expect(out).not.toContain("table:content-validation-name"); + }); + + it("leaves an office:value-type-shaped attribute alone outside a real cell or text-value element in reverse too", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(``); + }); + + it("reverses a text field's value-carrying attributes back to the text namespace", () => { + const out = reverseContent( + `42`, + ); + expect(out).toContain( + `42`, + ); + }); + + it("reverses a multi-column layout's own indent attributes back to the margin pair, leaving any other attribute alone", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain( + ``, + ); + }); + + it("leaves the fo:start-indent/fo:end-indent attribute name alone outside a real style:column in reverse too", () => { + const out = reverseContent(`hi`); + expect(out).toContain(`hi`); + }); + + it("never reverses an in-shaped token inside a name-suffixed or xlink:href attribute", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(`draw:name="Logo 2in"`); + expect(out).toContain(`xlink:href="#note 5in"`); + }); + + it("only unwraps the first frame-shaped child when a draw:frame somehow carries more than one, nesting the rest as the chosen shape's own trailing children", () => { + const out = reverseContent( + `t`, + ); + expect(out).toContain( + `t`, + ); + }); + it("unwraps a draw:frame back to the bare shape it wraps, moving the frame attributes onto it and reversing the inch unit", () => { const out = reverseContent( ``, @@ -745,6 +1247,12 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { expect(out).toContain(`xlink:href="http://example.invalid/a.png"`); expect(out).toContain(`xlink:href="#bookmark"`); }); + + it("does not double-prefix a package-internal href that already starts with #", () => { + const out = reverseContent(``); + expect(out).toContain(`xlink:href="#Pictures/a.png"`); + expect(out).not.toContain(`xlink:href="##Pictures/a.png"`); + }); }); describe("transformToOoo1Package: lists", () => { @@ -793,6 +1301,40 @@ describe("transformToOoo1Package: lists", () => { expect(out).not.toContain(""); }); + + it("leaves a text:list unrenamed when its referenced list-style can't be resolved to ordered or bullet", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `one`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(``); + }); + + it("only threads listKind down from a genuine enclosing text:list, never from another element that merely resolves a list style", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `one`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(``); + expect(out).not.toContain("text:ordered-list"); + expect(out).not.toContain("text:unordered-list"); + }); }); describe("transformToOoo1Package: metadata", () => { @@ -817,4 +1359,24 @@ describe("transformToOoo1Package: metadata", () => { `Talphabeta`, ); }); + + it("leaves office:meta's children alone when there are no meta:keyword siblings to rewrap", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + "meta.xml": { + kind: "xml", + nodes: parseXml( + `T`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "meta.xml"); + expect(out).toContain(`T`); + }); }); diff --git a/packages/odf.js/src/ooo1/transform.ts b/packages/odf.js/src/ooo1/transform.ts index 79fcbd3c1..9d1020a2b 100644 --- a/packages/odf.js/src/ooo1/transform.ts +++ b/packages/odf.js/src/ooo1/transform.ts @@ -234,7 +234,7 @@ function renameQName( prefixes: ReadonlyMap, ): string { const colon = qname.indexOf(":"); - if (colon < 0) { + if (colon === -1) { return qname; } const canonical = prefixes.get(qname.slice(0, colon)); @@ -571,7 +571,8 @@ function prefixRenames(root: XmlElement): Map { } const declared = attribute.name.slice("xmlns:".length); const canonical = CANONICAL_PREFIX_BY_URI.get(attribute.value); - if (canonical !== undefined && canonical !== declared) { + // No "canonical !== declared" guard: recording declared -> declared here is a genuine no-op (renameQName's own canonical === undefined check is the only branch that reads this map, and a self-mapped entry resolves identically to a missing one), so skipping it would only be an allocation micro-optimisation, not a behavioural difference worth a second condition. + if (canonical !== undefined) { renames.set(declared, canonical); } } @@ -715,6 +716,16 @@ const CLASS_BY_GENRE_ELEMENT: ReadonlyMap = new Map([ ["office:chart", "chart"], ]); +// The first child that is both an element and a recognised genre tag -- shared by reverseTransformElement's own office:body unwrap and documentClassOf below, the two places this package looks for office:body's genre child. A non-element child is never returned: CLASS_BY_GENRE_ELEMENT has no key for a non-element node's undefined tag, so the type check inside this one shared find() only ever needs proving once rather than twice. +function firstGenreElement( + children: readonly XmlNode[], +): XmlElement | undefined { + return children.find( + (child): child is XmlElement => + child.type === "element" && CLASS_BY_GENRE_ELEMENT.has(child.tag), + ); +} + // Simple, unambiguous element renames reversed by a straight lookup -- every RENAMED_ELEMENTS target EXCEPT the three whose forward mapping is many-to-one (text:list, from text:ordered-list AND text:unordered-list; text:note-body and text:note-citation, each from a footnote/endnote pair), which cannot be inverted by name alone and are handled below through the same context (a resolved list kind, an enclosing note's own class) their forward siblings in NOTE_ELEMENTS already need for the identical reason. const REVERSE_RENAMED_ELEMENTS: ReadonlyMap = new Map([ ["office:font-face-decls", "office:font-decls"], @@ -729,11 +740,8 @@ const REVERSE_RENAMED_ELEMENTS: ReadonlyMap = new Map([ ["table:dependency", "table:dependence"], ]); -// The text:note/text:note-ref/text:notes-configuration family's own reverse: each carries its own text:note-class attribute (added by the forward NOTE_ELEMENTS mapping), so -- unlike text:note-body/text:note-citation below -- this one needs no threaded context at all, just the element's own attribute. Defaults to "footnote" for a malformed/absent class, matching this package's general degrade-gracefully reading posture applied to writing. -function reverseNoteTag( - tag: string, - noteClass: string | undefined, -): string | undefined { +// The text:note/text:note-ref/text:notes-configuration family's own reverse: each carries its own text:note-class attribute (added by the forward NOTE_ELEMENTS mapping), so -- unlike text:note-body/text:note-citation below -- this one needs no threaded context at all, just the element's own attribute. Defaults to "footnote" for a malformed/absent class, matching this package's general degrade-gracefully reading posture applied to writing. Only ever called (see reverseTransformElement below) with tag already narrowed to one of the three checked below, so the last of them needs no guard of its own -- by the time text:note and text:note-ref have both failed, tag can only be text:notes-configuration. +function reverseNoteTag(tag: string, noteClass: string | undefined): string { const isEndnote = noteClass === "endnote"; if (tag === "text:note") { return isEndnote ? "text:endnote" : "text:footnote"; @@ -741,27 +749,21 @@ function reverseNoteTag( if (tag === "text:note-ref") { return isEndnote ? "text:endnote-ref" : "text:footnote-ref"; } - if (tag === "text:notes-configuration") { - return isEndnote - ? "text:endnotes-configuration" - : "text:footnotes-configuration"; - } - return undefined; + return isEndnote + ? "text:endnotes-configuration" + : "text:footnotes-configuration"; } -// text:note-body and text:note-citation carry no note-class of their own in ODF -- only their ENCLOSING text:note does -- so reversing them needs the class threaded down through the recursion from the text:note that contains them (ReverseTransformContext.noteClass, set exactly once, the moment a text:note element is entered). +// text:note-body and text:note-citation carry no note-class of their own in ODF -- only their ENCLOSING text:note does -- so reversing them needs the class threaded down through the recursion from the text:note that contains them (ReverseTransformContext.noteClass, set exactly once, the moment a text:note element is entered). Only ever called (see reverseTransformElement below) with tag already narrowed to one of the two checked below, so the second needs no guard of its own -- once text:note-body has failed, tag can only be text:note-citation. function reverseNoteBodyOrCitation( tag: string, noteClass: "footnote" | "endnote" | undefined, -): string | undefined { +): string { const isEndnote = noteClass === "endnote"; if (tag === "text:note-body") { return isEndnote ? "text:endnote-body" : "text:footnote-body"; } - if (tag === "text:note-citation") { - return isEndnote ? "text:endnote-citation" : "text:footnote-citation"; - } - return undefined; + return isEndnote ? "text:endnote-citation" : "text:footnote-citation"; } // A length written in ODF's "in" unit, reversed to OpenOffice.org 1.x's own "inch" spelling -- the exact inverse of INCH_TOKEN above, matched the same way (a whole whitespace-delimited token, so a compound value like a border shorthand keeps its structure). @@ -961,13 +963,11 @@ function reverseMovedChildren( // meta:keywords was a wrapper ODF removed (see transformElement's own meta:keywords case); reversed here by re-wrapping every meta:keyword sibling office:meta carries into one meta:keywords element, positioned at the first keyword's own place among its siblings -- exactly the shape the forward direction unwraps. Safe to run unconditionally over any element's children (not scoped to office:meta specifically): meta:keyword has no legitimate ODF appearance anywhere else, so the check costs nothing when there is nothing to wrap. function wrapMetaKeywords(nodes: readonly XmlNode[]): XmlNode[] { + // No early return for an empty keywords list: the loop below already reproduces `nodes` unchanged in that case (every node fails the meta:keyword check below and is pushed through as-is), so a length-0 guard would only save the loop's own allocation, never change the result. const keywords = nodes.filter( (node): node is XmlElement => node.type === "element" && node.tag === "meta:keyword", ); - if (keywords.length === 0) { - return [...nodes]; - } const out: XmlNode[] = []; let inserted = false; for (const node of nodes) { @@ -1044,17 +1044,13 @@ function reverseTransformElement( context.prefixes, ); - // office:body's genre child (buildBody's own construction) unwraps: recursing into the GENRE element's children rather than office:body's own single child reproduces the flat body OpenOffice.org 1.x wrote. + // office:body's genre child (buildBody's own construction) unwraps: recursing into the GENRE element's children rather than office:body's own single child reproduces the flat body OpenOffice.org 1.x wrote. office:body itself needs no special-cased return below (unlike draw:frame, the note family, and the rest) -- REVERSE_RENAMED_ELEMENTS has no entry for it and it is not a DOCUMENT_ROOT_ELEMENTS member, so the generic tag/attribute handling at the bottom of this function already reproduces element("office:body", attributes, children) exactly. const genreChild = renamedTag === "office:body" - ? source.children.find( - (child): child is XmlElement => child.type === "element", - ) + ? firstGenreElement(source.children) : undefined; const recurseInto = - genreChild !== undefined && CLASS_BY_GENRE_ELEMENT.has(genreChild.tag) - ? genreChild.children - : source.children; + genreChild === undefined ? source.children : genreChild.children; let childContext = context; if (renamedTag === "text:note") { @@ -1073,10 +1069,6 @@ function reverseTransformElement( reverseTransformNodes(recurseInto, childContext), ); - if (renamedTag === "office:body") { - return [element("office:body", attributes, children)]; - } - if (renamedTag === "draw:frame") { return [ unwrapFrame(attributes, children) ?? @@ -1093,13 +1085,12 @@ function reverseTransformElement( const withoutClass = attributes.filter( (attribute) => attribute.name !== "text:note-class", ); - const tag = reverseNoteTag(renamedTag, noteClassRaw) ?? renamedTag; + const tag = reverseNoteTag(renamedTag, noteClassRaw); return [element(tag, withoutClass, children)]; } if (renamedTag === "text:note-body" || renamedTag === "text:note-citation") { - const tag = - reverseNoteBodyOrCitation(renamedTag, context.noteClass) ?? renamedTag; + const tag = reverseNoteBodyOrCitation(renamedTag, context.noteClass); return [element(tag, attributes, children)]; } @@ -1169,9 +1160,8 @@ function documentClassOf(pkg: Package): string | undefined { root === undefined ? undefined : findChildElement(root.children, "office:body"); - const genre = body?.children.find( - (child): child is XmlElement => child.type === "element", - ); + const genre = + body === undefined ? undefined : firstGenreElement(body.children); return genre === undefined ? undefined : CLASS_BY_GENRE_ELEMENT.get(genre.tag); diff --git a/packages/odf.js/src/package-io/read.test.ts b/packages/odf.js/src/package-io/read.test.ts new file mode 100644 index 000000000..1f6ce9062 --- /dev/null +++ b/packages/odf.js/src/package-io/read.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { bytesToBase64 } from "../util/base64"; +import { zipPackage } from "../zip"; +import { hasUtf8Bom, parsePackage } from "./read"; + +// parsePackage's own XML-vs-binary routing (looksLikeXml) is not exported, so every case here drives it indirectly through a real zip part's classification. The function's own contract note explains why a misclassification can only ever go one way: no standard ODF binary part starts with '<', so a false positive here would misparse a binary part as XML, while a false negative just stores an XML part losslessly as base64 instead -- these tests pin both directions and every byte-level boundary the scan's own whitespace/'<' checks depend on. hasUtf8Bom itself is exported and tested directly below, since a wrongly-detected BOM and a correctly-rejected one can otherwise happen to produce the same XML/binary verdict downstream (a too-short array still ends the scan at the same byte either way), making the boundary untestable through parsePackage alone. + +function packageWithOnePart(bytes: Uint8Array) { + const zipBytes = zipPackage([["part", { bytes }]]); + return parsePackage(zipBytes); +} + +describe("hasUtf8Bom", () => { + it("recognises the exact three-byte BOM", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0xbf]))).toBe(true); + }); + + it("recognises a BOM followed by more bytes", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0xbf, 0x00]))).toBe(true); + }); + + it("rejects an empty array", () => { + expect(hasUtf8Bom(new Uint8Array([]))).toBe(false); + }); + + it("rejects an array shorter than the BOM even when every present byte matches", () => { + expect(hasUtf8Bom(new Uint8Array([0xef]))).toBe(false); + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb]))).toBe(false); + }); + + it("rejects a full-length array whose first byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0x00, 0xbb, 0xbf]))).toBe(false); + }); + + it("rejects a full-length array whose second byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0x00, 0xbf]))).toBe(false); + }); + + it("rejects a full-length array whose third byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0x00]))).toBe(false); + }); +}); + +describe("parsePackage: XML vs binary part classification", () => { + it("classifies a part starting directly with '<' as xml", () => { + const pkg = packageWithOnePart(new TextEncoder().encode("")); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("classifies an empty part as binary -- the scan loop never runs at all", () => { + const pkg = packageWithOnePart(new Uint8Array(0)); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("classifies a part that is entirely whitespace as binary -- the loop runs to completion without ever finding a non-whitespace byte", () => { + const pkg = packageWithOnePart(new Uint8Array([0x20, 0x09, 0x0a, 0x0d])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("skips a leading UTF-8 BOM before checking for '<'", () => { + const bomThenXml = new Uint8Array([ + 0xef, + 0xbb, + 0xbf, + ...new TextEncoder().encode(""), + ]); + const pkg = packageWithOnePart(bomThenXml); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("classifies a lone BOM with nothing after it as binary, not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0xef, 0xbb, 0xbf])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("skips mixed leading whitespace (space, tab, LF, CR, in that order) before finding '<'", () => { + const bytes = new TextEncoder().encode(" \t\n\r"); + const pkg = packageWithOnePart(bytes); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("treats a byte immediately adjacent to each whitespace value as non-whitespace, ending the scan on it", () => { + // 0x1f is one below space (0x20); 0x08 is one below tab (0x09); 0x0b is one above LF (0x0a); 0x0e is one above CR (0x0d). None of these may be mistaken for the whitespace byte beside it. + for (const nonWhitespace of [0x1f, 0x08, 0x0b, 0x0e]) { + const pkg = packageWithOnePart(new Uint8Array([nonWhitespace])); + expect(pkg.parts.part?.kind, `byte 0x${nonWhitespace.toString(16)}`).toBe( + "binary", + ); + } + }); + + it("classifies real binary content (a PNG magic number) as binary, storing it losslessly as base64", () => { + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const pkg = packageWithOnePart(bytes); + const part = pkg.parts.part; + expect(part?.kind).toBe("binary"); + if (part?.kind === "binary") { + expect(part.base64).toBe(bytesToBase64(bytes)); + } + }); + + it("treats the byte one above '<' (0x3d, '=') as not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0x3d])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("treats the byte one below '<' (0x3b, ';') as not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0x3b])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("classifies a real XML declaration (not just a bare element) as xml", () => { + const pkg = packageWithOnePart( + new TextEncoder().encode(''), + ); + expect(pkg.parts.part?.kind).toBe("xml"); + }); +}); + +describe("parsePackage: routes multiple parts independently", () => { + it("classifies each part in a multi-part package on its own merits, keyed by its own path", () => { + const zipBytes = zipPackage([ + ["a.xml", { bytes: new TextEncoder().encode("") }], + ["b.bin", { bytes: new Uint8Array([1, 2, 3]) }], + ]); + const pkg = parsePackage(zipBytes); + expect(pkg.parts["a.xml"]?.kind).toBe("xml"); + expect(pkg.parts["b.bin"]?.kind).toBe("binary"); + }); +}); diff --git a/packages/odf.js/src/package-io/read.ts b/packages/odf.js/src/package-io/read.ts index 1c7d052e5..29c527a3d 100644 --- a/packages/odf.js/src/package-io/read.ts +++ b/packages/odf.js/src/package-io/read.ts @@ -17,21 +17,21 @@ export function parsePackage(bytes: Uint8Array): Package { return { parts }; } +const UTF8_BOM = [0xef, 0xbb, 0xbf] as const; + +// Bytes that are insignificant XML whitespace ahead of a document's root element -- space, tab, LF, CR. A Set rather than a chain of `===` comparisons: a byte either belongs to this fixed set or it doesn't, so membership is the one fact worth testing directly, not a boolean tree with its own sub-clauses to pin separately. +const XML_LEADING_WHITESPACE = new Set([0x20, 0x09, 0x0a, 0x0d]); + +// `bytes` opens with a literal UTF-8 BOM (EF BB BF), exported for direct testing of its own boundary (a too-short array, a partial match on one or two of the three bytes) rather than only through looksLikeXml's downstream classification, where a wrongly-detected BOM and a correctly-rejected one can otherwise happen to produce the same XML/binary verdict. +export function hasUtf8Bom(bytes: Uint8Array): boolean { + return UTF8_BOM.every((byte, index) => bytes[index] === byte); +} + // An XML part (after any BOM/whitespace) starts with '<'; no standard ODF binary part (png, jpeg, embedded font, embedded object, thumbnail, ...) starts with '<', so a misclassification only ever stores an XML part losslessly as base64 -- it never misparses a binary part. function looksLikeXml(bytes: Uint8Array): boolean { - let i = 0; - if ( - bytes.length >= 3 && - bytes[0] === 0xef && - bytes[1] === 0xbb && - bytes[2] === 0xbf - ) { - i = 3; - } - while (i < bytes.length) { - const b = bytes[i]!; - if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) { - i = i + 1; + const start = hasUtf8Bom(bytes) ? UTF8_BOM.length : 0; + for (const b of bytes.subarray(start)) { + if (XML_LEADING_WHITESPACE.has(b)) { continue; } return b === 0x3c; diff --git a/packages/odf.js/src/package-io/scaffold.test.ts b/packages/odf.js/src/package-io/scaffold.test.ts new file mode 100644 index 000000000..74b196ecc --- /dev/null +++ b/packages/odf.js/src/package-io/scaffold.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { buildXml } from "../xml/build"; +import { base64ToBytes } from "../util/base64"; +import { + createOdfPackage, + odfPartContainer, + DEFAULT_ODF_VERSION, +} from "./scaffold"; + +describe("createOdfPackage", () => { + it("writes content.xml and styles.xml, each starting with the exact XML declaration", () => { + const pkg = createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:text", + attributes: [], + children: [], + }); + const contentPart = pkg.parts["content.xml"]; + const stylesPart = pkg.parts["styles.xml"]; + expect(contentPart?.kind).toBe("xml"); + expect(stylesPart?.kind).toBe("xml"); + if (contentPart?.kind !== "xml" || stylesPart?.kind !== "xml") { + throw new Error("expected xml parts"); + } + expect(buildXml(contentPart.nodes)).toMatch( + /^<\?xml version="1\.0" encoding="UTF-8"\?> { + const bodyElement = { + type: "element" as const, + tag: "office:text", + attributes: [], + children: [], + }; + const defaulted = createOdfPackage( + "application/vnd.oasis.opendocument.text", + bodyElement, + ); + const custom = createOdfPackage( + "application/vnd.oasis.opendocument.text", + bodyElement, + "1.2", + ); + const defaultedContent = defaulted.parts["content.xml"]; + const customContent = custom.parts["content.xml"]; + if (defaultedContent?.kind !== "xml" || customContent?.kind !== "xml") { + throw new Error("expected xml parts"); + } + expect(buildXml(defaultedContent.nodes)).toContain( + `office:version="${DEFAULT_ODF_VERSION}"`, + ); + expect(buildXml(customContent.nodes)).toContain('office:version="1.2"'); + }); + + it("nests the caller's own body element inside office:body in content.xml", () => { + const pkg = createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:spreadsheet", + attributes: [], + children: [], + }); + const contentPart = pkg.parts["content.xml"]; + if (contentPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(buildXml(contentPart.nodes)).toContain( + "", + ); + }); + + it("writes the mimetype part from the given media type", () => { + const pkg = createOdfPackage( + "application/vnd.oasis.opendocument.spreadsheet", + { + type: "element", + tag: "office:spreadsheet", + attributes: [], + children: [], + }, + ); + const mimetypePart = pkg.parts.mimetype; + expect(mimetypePart?.kind).toBe("binary"); + if (mimetypePart?.kind !== "binary") { + throw new Error("expected a binary mimetype part"); + } + expect(new TextDecoder().decode(base64ToBytes(mimetypePart.base64))).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + }); +}); + +describe("odfPartContainer", () => { + function freshPackage() { + return createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:text", + attributes: [], + children: [], + }); + } + + it("returns the named container element from a real part", () => { + const container = odfPartContainer( + freshPackage(), + "content.xml", + "office:automatic-styles", + ); + expect(container.tag).toBe("office:automatic-styles"); + }); + + it("throws when the part path is not an XML part at all", () => { + expect(() => + odfPartContainer(freshPackage(), "mimetype", "office:styles"), + ).toThrow('odfPartContainer: "mimetype" is not an XML part'); + }); + + it("throws when the XML part has no container with that tag", () => { + expect(() => + odfPartContainer(freshPackage(), "content.xml", "office:styles"), + ).toThrow('odfPartContainer: "content.xml" has no office:styles container'); + }); +}); diff --git a/packages/odf.js/src/package-io/write.test.ts b/packages/odf.js/src/package-io/write.test.ts new file mode 100644 index 000000000..9c396757c --- /dev/null +++ b/packages/odf.js/src/package-io/write.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { Package } from "../model/package"; +import { + localFileHeaderNames, + readUint16LE, + readUint32LE, +} from "../test-support/zip"; +import { + serializePackage, + orderedPackagePartPaths, + MIMETYPE_PART, + MANIFEST_PART, +} from "./write"; + +function packageOf(parts: Package["parts"]): Package { + return { parts }; +} + +// Walks local file headers exactly as localFileHeaderNames does, but also returns each entry's own compression-method field (0 = stored, 8 = deflated) -- what a non-mimetype part's storage mode actually is, which localFileHeaderNames itself has no need to expose. +function localFileHeaderCompressionMethods(bytes: Uint8Array): number[] { + const methods: number[] = []; + let offset = 0; + while (offset < bytes.length && readUint32LE(bytes, offset) === 0x04034b50) { + methods.push(readUint16LE(bytes, offset + 8)); + const compressedSize = readUint32LE(bytes, offset + 18); + const filenameLength = readUint16LE(bytes, offset + 26); + const extraLength = readUint16LE(bytes, offset + 28); + offset = offset + 30 + filenameLength + extraLength + compressedSize; + } + return methods; +} + +describe("orderedPackagePartPaths", () => { + it("lists the manifest path exactly once, never once hoisted and once again among the rest", () => { + const paths = orderedPackagePartPaths( + packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + "content.xml": { kind: "xml", nodes: [] }, + }), + ); + expect(paths.filter((path) => path === MANIFEST_PART)).toHaveLength(1); + expect(paths).toEqual([MIMETYPE_PART, MANIFEST_PART, "content.xml"]); + }); +}); + +describe("serializePackage", () => { + it("hoists mimetype first, then manifest, then every remaining part in its own key order", () => { + const pkg = packageOf({ + "content.xml": { kind: "xml", nodes: [] }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + "styles.xml": { kind: "xml", nodes: [] }, + }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names).toEqual([ + MIMETYPE_PART, + MANIFEST_PART, + "content.xml", + "styles.xml", + ]); + }); + + it("emits the manifest entry exactly once, not once hoisted and once again as a remaining part", () => { + const pkg = packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + "content.xml": { kind: "xml", nodes: [] }, + }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names.filter((name) => name === MANIFEST_PART)).toHaveLength(1); + }); + + it("never fabricates a mimetype or manifest part that was not already in the package", () => { + const pkg = packageOf({ "content.xml": { kind: "xml", nodes: [] } }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names).toEqual(["content.xml"]); + }); + + it("stores only the mimetype entry uncompressed; every other part is deflated (compression method 8), not also stored", () => { + const pkg = packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + "content.xml": { kind: "xml", nodes: [] }, + }); + const methods = localFileHeaderCompressionMethods(serializePackage(pkg)); + expect(methods).toEqual([0, 8]); + }); +}); diff --git a/packages/odf.js/src/package-io/write.ts b/packages/odf.js/src/package-io/write.ts index e9b777db6..be208ea80 100644 --- a/packages/odf.js/src/package-io/write.ts +++ b/packages/odf.js/src/package-io/write.ts @@ -7,30 +7,27 @@ import { zipPackage, type ZipEntry } from "../zip"; export const MIMETYPE_PART = "mimetype"; export const MANIFEST_PART = "META-INF/manifest.xml"; -// Serializes a Package back to zip bytes. This is the one deliberate behavioural difference from a generic zip-of-XML writer: ODF requires the "mimetype" part to be the very first zip entry, stored uncompressed (see zip.ts), so it hoists that part first if present, then META-INF/manifest.xml next if present, then every remaining part in the Package's own existing key order. It never fabricates a mimetype or manifest.xml part that doesn't already exist in the input -- that belongs to a later phase's manifest-construction logic, not this lossless zip<->Package mapping, which stays a pure, honest round trip with no side effects. -export function serializePackage(pkg: Package): Uint8Array { - const remaining = new Map(Object.entries(pkg.parts)); - const entries: [string, ZipEntry][] = []; - - const mimetype = remaining.get(MIMETYPE_PART); - if (mimetype !== undefined) { - entries.push([ - MIMETYPE_PART, - { bytes: partToBytes(mimetype), stored: true }, - ]); - remaining.delete(MIMETYPE_PART); - } - - const manifest = remaining.get(MANIFEST_PART); - if (manifest !== undefined) { - entries.push([MANIFEST_PART, { bytes: partToBytes(manifest) }]); - remaining.delete(MANIFEST_PART); - } - - for (const [path, part] of remaining) { - entries.push([path, { bytes: partToBytes(part) }]); - } +// The zip entry order serializePackage below actually writes in: "mimetype" first if present, then META-INF/manifest.xml if present, then every other part in the Package's own existing key order -- with each hoisted path EXCLUDED from that final group by construction (a filter predicate, not a delete-then-iterate step some part of the pipeline could skip), so a hoisted path can never also appear a second time among "every other part". Exported (and returning bare paths rather than the built ZipEntry values) so a test can pin this ordering-and-exclusion logic directly, independent of zipPackage's own object-keyed Zippable structure silently collapsing a same-path duplicate into one entry regardless of whether this function ever produced one. +export function orderedPackagePartPaths(pkg: Package): string[] { + const paths = Object.keys(pkg.parts); + const hoisted = [MIMETYPE_PART, MANIFEST_PART].filter((path) => + paths.includes(path), + ); + const rest = paths.filter((path) => !hoisted.includes(path)); + return [...hoisted, ...rest]; +} +// Serializes a Package back to zip bytes. This is the one deliberate behavioural difference from a generic zip-of-XML writer: ODF requires the "mimetype" part to be the very first zip entry, stored uncompressed (see zip.ts), so it hoists that part first if present, then META-INF/manifest.xml next if present, then every remaining part in the Package's own existing key order -- see orderedPackagePartPaths above for that ordering itself. It never fabricates a mimetype or manifest.xml part that doesn't already exist in the input -- that belongs to a later phase's manifest-construction logic, not this lossless zip<->Package mapping, which stays a pure, honest round trip with no side effects. +export function serializePackage(pkg: Package): Uint8Array { + const entries: [string, ZipEntry][] = orderedPackagePartPaths(pkg).map( + (path) => { + const part = pkg.parts[path]!; + return [ + path, + { bytes: partToBytes(part), stored: path === MIMETYPE_PART }, + ]; + }, + ); return zipPackage(entries); } diff --git a/packages/odf.js/src/styles/properties.test.ts b/packages/odf.js/src/styles/properties.test.ts index 89f0cf9a9..9b08dddd2 100644 --- a/packages/odf.js/src/styles/properties.test.ts +++ b/packages/odf.js/src/styles/properties.test.ts @@ -174,6 +174,72 @@ describe("parseTextProperties", () => { expect(result.hasUnknown).toBe(true); }); + it("flags hasUnknown for an underline companion attribute (width) present with no style:text-underline-style at all", () => { + const element = el("style:text-properties", { + "style:text-underline-width": "auto", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("flags hasUnknown for an underline companion attribute (color) present with no style:text-underline-style at all", () => { + const element = el("style:text-properties", { + "style:text-underline-color": "font-color", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("flags hasUnknown for a canonical-looking underline whose width does not match the on-value, even when its colour does", () => { + const element = el("style:text-properties", { + "style:text-underline-style": "solid", + "style:text-underline-width": "bold", + "style:text-underline-color": "font-color", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it('flags hasUnknown for style:text-underline-style="none" accompanied by a companion attribute, since a real "off" underline never carries one', () => { + const withWidth = parseTextProperties( + el("style:text-properties", { + "style:text-underline-style": "none", + "style:text-underline-width": "auto", + }), + ); + expect(withWidth.properties.underline).toBeUndefined(); + expect(withWidth.hasUnknown).toBe(true); + + const withColor = parseTextProperties( + el("style:text-properties", { + "style:text-underline-style": "none", + "style:text-underline-color": "font-color", + }), + ); + expect(withColor.properties.underline).toBeUndefined(); + expect(withColor.hasUnknown).toBe(true); + }); + + it('flags hasUnknown for style:text-line-through-style="none" accompanied by style:text-line-through-type', () => { + const result = parseTextProperties( + el("style:text-properties", { + "style:text-line-through-style": "none", + "style:text-line-through-type": "single", + }), + ); + expect(result.properties.strike).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("sets no underline/strike property at all (not even as undefined) when neither has any attribute present", () => { + const result = parseTextProperties(el("style:text-properties")); + expect("underline" in result.properties).toBe(false); + expect("strike" in result.properties).toBe(false); + }); + it("returns an empty, non-unknown result for an element with no attributes at all", () => { const element = el("style:text-properties"); expect(parseTextProperties(element)).toEqual({ @@ -338,6 +404,14 @@ describe("parseParagraphProperties", () => { } }); + it("flags hasUnknown for an fo:break-after value the boolean model cannot hold, the identical way fo:break-before does", () => { + const result = parseParagraphProperties( + el("style:paragraph-properties", { "fo:break-after": "column" }), + ); + expect(result.properties.pageBreakAfter).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + // fo:border-* parsing, added for ExaDev/documents.js#1086 -- the odt half of #1082's own docx w:pBdr reading, so a border-only paragraph (Word's AutoCorrect "---" horizontal rule, or LibreOffice's own equivalent) is detected the same way regardless of source format. it("parses a bottom-only border -- the exact shape a border-only horizontal rule takes", () => { const element = el("style:paragraph-properties", { @@ -433,6 +507,15 @@ describe("parseParagraphProperties", () => { expect(result.hasUnknown).toBe(false); }); + it("flags hasUnknown for a malformed fo:border shorthand value, the identical way a malformed per-edge value does", () => { + const element = el("style:paragraph-properties", { + "fo:border": "not-three-tokens", + }); + const result = parseParagraphProperties(element); + expect(result.properties.borderLeft).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + it("flags hasUnknown and leaves the field untouched for a malformed fo:border-* value (wrong token count, unparseable length/colour)", () => { for (const value of [ "not-three-tokens", @@ -487,6 +570,17 @@ describe("parseStyleElementProperties", () => { expect(result.hasUnknown).toBe(true); }); + it("never routes an unrecognised child tag through the paragraph-properties parser, even when it happens to carry an attribute name paragraph-properties would otherwise recognise", () => { + const styleElement = el( + "style:style", + { "style:name": "ta1", "style:family": "table" }, + [el("style:table-properties", { "fo:text-align": "center" })], + ); + const result = parseStyleElementProperties(styleElement); + expect(result.properties.alignment).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + it("flags hasUnknown for style:master-page-name even with otherwise fully-modelled properties (real LibreOffice output, style P1)", () => { // Real attributes: style:name="P1" style:family="paragraph" style:parent-style-name="Text_20_body" style:master-page-name="HTML", with paragraph-properties fo:text-align="left" (plus style:justify-single-word/fo:text-indent/style:auto-text-indent/style:page-number, themselves already unmodelled). const styleElement = el( diff --git a/packages/odf.js/src/styles/properties.ts b/packages/odf.js/src/styles/properties.ts index 062da93ff..9c5f7ed0e 100644 --- a/packages/odf.js/src/styles/properties.ts +++ b/packages/odf.js/src/styles/properties.ts @@ -119,15 +119,11 @@ const PERCENTAGE_PATTERN = /^(-?(?:\d+(?:\.\d+)?|\.\d+))%$/; // fo:line-height as a percentage (e.g. "150%") maps to document-schema.js's ContentParagraph.lineSpacing, which is a multiplier (1.5), not a percentage (150) -- see ooxml.js's own docx/pptx line-spacing readers, which establish this convention (`expect(props.lineSpacing).toBe(1.5)` for what OOXML calls 360/240). An absolute-length fo:line-height (e.g. "12pt") or the literal value "normal" is valid ODF but outside this multiplier-only model, so it parses as undefined here (triggering the caller's hasUnknown, not a silent misinterpretation). function parsePercentageMultiplier(value: string): number | undefined { - const match = PERCENTAGE_PATTERN.exec(value); - if (match === null) { + // No capture-group extraction: the pattern anchors the numeric portion between ^ and a trailing "%$", so a successful match's numeric text is always exactly the input with its last character (the "%") removed -- reading it back out of a capture group would need a second, provably-always-true undefined check the type system can't see through on its own. + if (!PERCENTAGE_PATTERN.test(value)) { return undefined; } - const numeric = match[1]; - if (numeric === undefined) { - return undefined; - } - return Number(numeric) / 100; + return Number(value.slice(0, -1)) / 100; } export function formatPercentageMultiplier(multiplier: number): string { @@ -136,32 +132,32 @@ export function formatPercentageMultiplier(multiplier: number): string { // The canonical ODF colour parse/format pair now lives in ../typed/shared/color.ts, shared with every other reader in this package rather than duplicated here -- see that module's own top-of-file note on the text:color datatype. This module calls parseOdfColor/formatOdfColor directly (see parseTextProperties/textPropertiesToAttributes below) rather than through a local alias. -// Reads a boolean tri-state (true/false/absent) plus an "unrecognised combination" outcome from ODF's compound line-decoration attributes (underline: style+width+color; strike: style+type). Ground truth (LibreOffice 26.2): underline "on" is `style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"`; strike "on" is `style:text-line-through-style="solid" style:text-line-through-type="single"`. Only that exact canonical "on" shape, or a plain "none" with no companion attributes, parses cleanly -- anything else (a custom underline colour, a dotted style, a companion attribute present alongside "none") is real formatting information this boolean model cannot represent, so it comes back as 'unknown' rather than being silently approximated. +// Reads a boolean tri-state (true/false/absent) plus an "unrecognised combination" outcome from ODF's compound line-decoration attributes (underline: style+width+color; strike: style+type). Ground truth (LibreOffice 26.2): underline "on" is `style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"`; strike "on" is `style:text-line-through-style="solid" style:text-line-through-type="single"`. Only that exact canonical "on" shape, or a plain "none" with no companion attributes, parses cleanly -- anything else (a custom underline colour, a dotted style, a companion attribute present alongside "none") is real formatting information this boolean model cannot represent, so it comes back as 'unknown' rather than being silently approximated. companionB is genuinely optional at the type level, not just always undefined at runtime: strike has only one companion attribute (type), so its call site omits companionB entirely rather than passing a hardcoded "no on-value for the companion that doesn't exist" placeholder that no test could ever observe. function parseLineDecoration( style: string | undefined, companionA: string | undefined, companionAOnValue: string, - companionB: string | undefined, - companionBOnValue: string, + companionB?: { readonly value: string | undefined; readonly onValue: string }, ): boolean | undefined | "unknown" { + const companionBValue = companionB?.value; if ( style === undefined && companionA === undefined && - companionB === undefined + companionBValue === undefined ) { return undefined; } if ( style === "solid" && (companionA === undefined || companionA === companionAOnValue) && - (companionB === undefined || companionB === companionBOnValue) + (companionBValue === undefined || companionBValue === companionB?.onValue) ) { return true; } if ( style === "none" && companionA === undefined && - companionB === undefined + companionBValue === undefined ) { return false; } @@ -207,8 +203,7 @@ export function parseTextProperties(element: XmlElement): ParsedProperties { attrs.get(ATTR.underlineStyle), attrs.get(ATTR.underlineWidth), "auto", - attrs.get(ATTR.underlineColor), - "font-color", + { value: attrs.get(ATTR.underlineColor), onValue: "font-color" }, ); if (underline === "unknown") { hasUnknown = true; @@ -220,8 +215,6 @@ export function parseTextProperties(element: XmlElement): ParsedProperties { attrs.get(ATTR.lineThroughStyle), attrs.get(ATTR.lineThroughType), "single", - undefined, - "", ); if (strike === "unknown") { hasUnknown = true; diff --git a/packages/odf.js/src/styles/registry.test.ts b/packages/odf.js/src/styles/registry.test.ts index 0cdf1419f..76eb62a4c 100644 --- a/packages/odf.js/src/styles/registry.test.ts +++ b/packages/odf.js/src/styles/registry.test.ts @@ -113,6 +113,43 @@ describe("StyleRegistry.forPart: construction", () => { const registry = StyleRegistry.forPart(contentPackage(), "content.xml"); expect(registry.names()).toEqual([]); }); + + it("resolves a part path with a directory prefix by its own base name, not the full path", () => { + const pkg: Package = { + parts: { + "objects/1/content.xml": { + kind: "xml", + nodes: [el("office:document-content")], + }, + }, + }; + // If the base-name extraction were ever skipped, the full path "objects/1/content.xml" would match neither "content.xml" nor "styles.xml" by strict equality and forPart would throw instead of recognising this as a content part. + expect(() => + StyleRegistry.forPart(pkg, "objects/1/content.xml"), + ).not.toThrow(); + }); + + it("inserts office:automatic-styles before office:master-styles when there is no office:body", () => { + const masterStyles = el("office:master-styles"); + const pkg = stylesPackage([masterStyles]); + StyleRegistry.forPart(pkg, "styles.xml"); + const root = rootElementOf(pkg, "styles.xml"); + const tags = root.children.map((c) => + c.type === "element" ? c.tag : c.type, + ); + expect(tags).toEqual(["office:automatic-styles", "office:master-styles"]); + }); + + it("inserts office:automatic-styles before office:settings when there is no office:body or office:master-styles", () => { + const settings = el("office:settings"); + const pkg = stylesPackage([settings]); + StyleRegistry.forPart(pkg, "styles.xml"); + const root = rootElementOf(pkg, "styles.xml"); + const tags = root.children.map((c) => + c.type === "element" ? c.tag : c.type, + ); + expect(tags).toEqual(["office:automatic-styles", "office:settings"]); + }); }); describe("rule (a): adoption on construction", () => { @@ -226,6 +263,45 @@ describe("rule (a): adoption on construction", () => { }), ).toBe("PS3"); }); + + it("does not adopt or reserve a differently-tagged element carrying style:name/style:family attributes shaped just like a real style:style", () => { + const impostor = el("style:default-style", { + "style:name": "T1", + "style:family": "text", + }); + const pkg = contentPackage([el("office:automatic-styles", {}, [impostor])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect(registry.names()).toEqual([]); + // If the impostor's own tag were never checked, "T1" would already be reserved and this mint would have to skip straight to "T2". + expect(registry.intern(BOLD)).toBe("T1"); + }); + + it("adopts an existing style's own style:parent-style-name into its fingerprint, distinguishing a matching request from one with no parent", () => { + const existing = el( + "style:style", + { + "style:name": "P5", + "style:family": "paragraph", + "style:parent-style-name": "Heading1", + }, + [el("style:paragraph-properties", { "fo:text-align": "center" })], + ); + const pkg = contentPackage([el("office:automatic-styles", {}, [existing])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect( + registry.intern({ + properties: { alignment: "center" }, + family: "paragraph", + parentStyleName: "Heading1", + }), + ).toBe("P5"); + expect( + registry.intern({ + properties: { alignment: "center" }, + family: "paragraph", + }), + ).not.toBe("P5"); + }); }); describe("rule (b): unknown attributes opt a style out of reuse, not out of existence", () => { @@ -460,6 +536,20 @@ describe("rule (d): name minting is collision-checked across all four containers }); expect(registry.intern(CENTER_PARAGRAPH)).toBe("P2"); }); + + it("does not reserve a name/family carried by a differently-tagged element when scanning office:styles for reservations", () => { + const impostor = el("style:default-style", { + "style:name": "P1", + "style:family": "paragraph", + }); + const pkg = contentPackage([ + el("office:styles", {}, [impostor]), + el("office:automatic-styles"), + ]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + // "P1" was never actually reserved -- a name/family check without the tag check would wrongly reserve it, forcing this mint to skip to "P2". + expect(registry.intern(CENTER_PARAGRAPH)).toBe("P1"); + }); }); describe("rule (e): content.xml and styles.xml registries use distinct name-minting prefixes", () => { @@ -665,6 +755,37 @@ describe("gc()", () => { expect(registry.gc(new Set([name]))).toBe(0); expect(registry.names()).toEqual([name]); }); + + it("gc'ing a minted, fingerprint-matchable style also forgets its own fingerprint entry, so a later identical request mints fresh rather than returning the now-removed name", () => { + const registry = StyleRegistry.forPart(contentPackage(), "content.xml"); + const minted = registry.intern(BOLD); + expect(minted).toBe("T1"); + + expect(registry.gc(new Set())).toBe(1); + expect(registry.names()).toEqual([]); + + const next = registry.intern(BOLD); // identical fingerprint to the gc'd style + expect(next).not.toBe("T1"); // T1 no longer exists; reusing it would be a dangling reference + expect(next).toBe("T2"); + }); + + it("gc'ing an adopted, fingerprint-matchable style also forgets its own fingerprint entry, the same as a minted one", () => { + const existing = el( + "style:style", + { "style:name": "T1", "style:family": "text" }, + [el("style:text-properties", { "fo:font-weight": "bold" })], + ); + const pkg = contentPackage([el("office:automatic-styles", {}, [existing])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect(registry.names()).toEqual(["T1"]); + + expect(registry.gc(new Set())).toBe(1); + expect(registry.names()).toEqual([]); + + const next = registry.intern(BOLD); + expect(next).not.toBe("T1"); + expect(next).toBe("T2"); + }); }); // The seam a writer reaches the table and graphic families through: property elements this module's own StyleProperties vocabulary has no field for, supplied already built. It exists so the table-family styles ODF requires (a column width, a row height, a cell fill and borders) are minted by THIS registry rather than by a second, parallel name-minting mechanism beside it -- widening StyleProperties instead would change what the reader treats as unmodelled, which is load-bearing for the adoption rules above and for the residue channel. diff --git a/packages/odf.js/src/styles/registry.ts b/packages/odf.js/src/styles/registry.ts index 4a5250253..b3a6f6e09 100644 --- a/packages/odf.js/src/styles/registry.ts +++ b/packages/odf.js/src/styles/registry.ts @@ -359,7 +359,7 @@ export class StyleRegistry { this.automaticStyles.children.push(styleElement); this.knownStyles.set(name, styleElement); - this.reservedByFamily[request.family].add(name); + // No `reservedByFamily[family].add(name)` here for a freshly minted name: mintName's own counter for this family always advances past whatever it just minted (see mintName below), so no later mintName call for this same registry instance can ever re-derive this exact counter value and need to check it against `reserved` again -- and adoption (the only other place a name can become "taken") only ever runs once, before construction finishes, never interleaved with intern() calls. A name minted here therefore never needs its own registration in `reserved` to stay unique. this.fingerprintToName.set(fingerprint, name); this.nameToFingerprint.set(name, fingerprint); return name; @@ -392,16 +392,17 @@ export class StyleRegistry { if (referenced.has(name)) { continue; } - const index = this.automaticStyles.children.indexOf(element); - if (index !== -1) { - this.automaticStyles.children.splice(index, 1); - } + // No `index !== -1` guard: `element` is the exact reference this same class itself put into `automaticStyles.children` -- either during forPart's own adoption scan of that very array, or via intern()'s own `.push(styleElement)` just before storing that same reference in knownStyles -- and nothing in this class ever replaces `.children` wholesale or removes a name from knownStyles without also splicing its element out in this same step, so a name still in knownStyles always has its element still present in the array, findable by indexOf. + this.automaticStyles.children.splice( + this.automaticStyles.children.indexOf(element), + 1, + ); this.knownStyles.delete(name); const fingerprint = this.nameToFingerprint.get(name); if (fingerprint !== undefined) { this.fingerprintToName.delete(fingerprint); - this.nameToFingerprint.delete(name); } + // No `this.nameToFingerprint.delete(name)` here: nameToFingerprint is only ever read (above) for a name still present in knownStyles, and this same iteration just removed `name` from knownStyles for good (a gc'd name is reserved forever and never re-adopted or re-minted -- see this method's own class-level comment), so no future gc() call can ever read this entry again. Deleting it would only ever tidy a map slot nothing will look at again, exactly like reservedByFamily's own already-documented "kept forever" bookkeeping above. removed += 1; } return removed; diff --git a/packages/odf.js/src/styles/serialize.test.ts b/packages/odf.js/src/styles/serialize.test.ts index cec3a881b..1c52f857b 100644 --- a/packages/odf.js/src/styles/serialize.test.ts +++ b/packages/odf.js/src/styles/serialize.test.ts @@ -38,6 +38,12 @@ describe("buildStylePropertyElements", () => { }); describe("canonicalPropertiesString", () => { + it("joins each name=value entry with a real '|' separator, not run together", () => { + expect(canonicalPropertiesString({ alignment: "center", bold: true })).toBe( + "fo:text-align=center|fo:font-weight=bold", + ); + }); + it("is a pure function: the same bag produces byte-identical output on every call", () => { const properties: StyleProperties = { bold: true, diff --git a/packages/odf.js/src/styles/span.test.ts b/packages/odf.js/src/styles/span.test.ts index 0942a1a4d..11c9e8502 100644 --- a/packages/odf.js/src/styles/span.test.ts +++ b/packages/odf.js/src/styles/span.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { XmlElement } from "../model/node"; import { el, txt } from "../xml/fragment"; -import { ensureSpan } from "./span"; +import { ensureSpan, splitNode } from "./span"; function paragraphOf(...children: XmlElement["children"]): XmlElement { return el("text:p", {}, children); @@ -67,6 +67,21 @@ describe("ensureSpan: plain text wrapping", () => { ]); }); + it("finds the existing text:style-name attribute by its own name, not merely the first attribute on the span, leaving an unrelated earlier attribute untouched", () => { + const existingSpan = el("text:span", {}, [txt("Hello")]); + existingSpan.attributes = [ + { name: "xml:id", value: "keep-me" }, + { name: "text:style-name", value: "Old" }, + ]; + const paragraph = paragraphOf(existingSpan); + const reused = ensureSpan(paragraph, 0, 5, "New"); + expect(reused).toBe(existingSpan); + expect(reused.attributes).toEqual([ + { name: "xml:id", value: "keep-me" }, + { name: "text:style-name", value: "New" }, + ]); + }); + it("treats a zero-width node (e.g. a comment) as occupying no character positions, carrying it through untouched on whichever side it falls", () => { const paragraph = paragraphOf( txt("ab"), @@ -202,6 +217,35 @@ describe("ensureSpan: text:s straddling a split boundary", () => { const paragraph = paragraphOf(el("text:s", { "text:c": "not-a-number" })); expect(() => ensureSpan(paragraph, 0, 1, "T1")).toThrow(/malformed/); }); + + it("splits a text:s into two count=1 halves that each omit the text:c attribute entirely, rather than writing it out explicitly for the implicit default", () => { + const paragraph = paragraphOf( + txt("a"), + el("text:s", { "text:c": "2" }), + txt("b"), + ); // "a" (0), text:s count=2 (1-2), "b" (3) + const span = ensureSpan(paragraph, 1, 2, "T1"); // splits the count=2 run into count=1 (inside the span) + count=1 (leftover after) + + expect(span.children).toEqual([ + { type: "element", tag: "text:s", attributes: [], children: [] }, + ]); + const leftover = paragraph.children[2]!; + if (leftover.type !== "element" || leftover.tag !== "text:s") { + throw new Error("expected a text:s element"); + } + expect(leftover.attributes).toEqual([]); + }); +}); + +describe("splitNode: unreachable fractional-offset branch", () => { + it("throws for text:tab (and, symmetrically, text:line-break) given an offset that isn't exactly 0 or its own length -- a shape ensureSpan's own integer-offset validation prevents any real caller from ever producing, exercised here by calling the split primitive directly", () => { + expect(() => splitNode(el("text:tab"), 0.5)).toThrow( + /cannot split "text:tab" at a fractional offset/, + ); + expect(() => splitNode(el("text:line-break"), 0.5)).toThrow( + /cannot split "text:line-break" at a fractional offset/, + ); + }); }); describe("ensureSpan: text:tab and text:line-break", () => { @@ -246,6 +290,13 @@ describe("ensureSpan: splitting a pre-existing text:span", () => { expect(styleName(newSpan)).toBe("T2"); expect(paragraph.children).toHaveLength(3); expect(paragraph.children[2]).toBe(newSpan); + // newSpan must wrap BOTH of the two nodes that made up "middle" (the split-off EFGH span, still styled T1, and the IJ text node) -- not merely reuse/rename the first of those two nodes in place and silently drop the second, which is exactly what a broken "is there exactly one middle node" check would do. + expect(newSpan.children).toHaveLength(2); + const innerSpan = newSpan.children[0]!; + if (innerSpan.type !== "element") throw new Error("expected an element"); + expect(styleName(innerSpan)).toBe("T1"); + expect(textOf(innerSpan.children[0]!)).toBe("EFGH"); + expect(textOf(newSpan.children[1]!)).toBe("IJ"); }); it("reuses (renames) an existing span in place when the requested range exactly matches it, and does not disturb a sibling split off the same original span", () => { diff --git a/packages/odf.js/src/styles/span.ts b/packages/odf.js/src/styles/span.ts index 387c336a3..8b52dc2e1 100644 --- a/packages/odf.js/src/styles/span.ts +++ b/packages/odf.js/src/styles/span.ts @@ -74,7 +74,8 @@ function buildSpaceRun(count: number): XmlElement { } // Splits a single node at a character offset strictly inside it (0 < offset < measureOdfNodeLength(node), guaranteed by splitChildrenAt's caller). A text node splits by string slicing; a text:s splits into two text:s elements whose counts sum to the original (a text:c="5" run split at offset 2 becomes text:c="2" and text:c="3", never silently merged or corrupted); a text:span splits recursively into two sibling spans carrying the same style-name, each holding its half of the original content. text:tab/text:line-break have length exactly 1, so an offset strictly between 0 and 1 can never be an integer -- that branch is unreachable given ensureSpan's own integer-offset validation, and throws rather than silently doing something wrong if it is ever somehow reached. -function splitNode( +// Exported so a direct unit test can reach the defensive throw at this function's own tail below, which no call reachable through ensureSpan's public entry point can ever trigger (see that throw's own comment) -- the only way to observe it is to call this function directly with an offset ensureSpan's own integer validation would have already rejected. +export function splitNode( node: XmlNode, offset: number, ): { left?: XmlNode; right?: XmlNode } { @@ -93,23 +94,17 @@ function splitNode( } if (node.type === "element" && node.tag === "text:span") { const inner = splitChildrenAt(node.children, offset); - // Each half gets its OWN deep copy of `attributes` -- both the array AND each individual { name, value } object within it -- not a shared reference to the original. A shallow `[...node.attributes]` copy would still share the same Attribute *objects* between both halves, so setStyleName's `existing.value = ...` mutation (reusing a split-off span on a subsequent ensureSpan call) would silently corrupt the other half's style-name too, even though the two halves' attribute arrays were themselves already distinct. - const left: XmlElement | undefined = - inner.before.length === 0 - ? undefined - : { - ...node, - attributes: cloneAttributes(node.attributes), - children: inner.before, - }; - const right: XmlElement | undefined = - inner.after.length === 0 - ? undefined - : { - ...node, - attributes: cloneAttributes(node.attributes), - children: inner.after, - }; + // Each half gets its OWN deep copy of `attributes` -- both the array AND each individual { name, value } object within it -- not a shared reference to the original. A shallow `[...node.attributes]` copy would still share the same Attribute *objects* between both halves, so setStyleName's `existing.value = ...` mutation (reusing a split-off span on a subsequent ensureSpan call) would silently corrupt the other half's style-name too, even though the two halves' attribute arrays were themselves already distinct. No `inner.before.length === 0` / `inner.after.length === 0` check guarding either half: this function is only ever invoked (from splitChildrenAt's own loop below) with an offset strictly between 0 and this node's own measureOdfNodeLength, and at that invariant, the recursive splitChildrenAt(node.children, offset) above can never come back with an empty `before` or `after` -- an offset > 0 means its loop either pushes at least one whole/zero-width child into `before` before reaching the split point, or lands inside a child whose own split contributes a defined, non-empty half (text and text:s always return one; a nested text:span does too, by this same argument applied recursively); and offset strictly less than this node's own total length guarantees genuine content remains for `after` too. Both halves are therefore always real, non-empty node arrays here, never the empty array an `undefined` branch would exist to represent. + const left: XmlElement = { + ...node, + attributes: cloneAttributes(node.attributes), + children: inner.before, + }; + const right: XmlElement = { + ...node, + attributes: cloneAttributes(node.attributes), + children: inner.after, + }; return { left, right }; } const label = node.type === "element" ? node.tag : node.type; @@ -123,17 +118,14 @@ function splitChildrenAt( children: readonly XmlNode[], offset: number, ): { before: XmlNode[]; after: XmlNode[] } { - if (offset <= 0) { - return { before: [], after: [...children] }; - } - + // No separate `offset <= 0` fast path: every caller in this module only ever passes an offset in [0, this children array's own total measureOdfNodeLength] -- ensureSpan's own upfront validation guarantees start >= 0 and end <= total, and splitNode's own recursive call into this function is only ever reached with an offset strictly between 0 and the node's own length. offset is therefore never negative, and offset === 0 is already handled identically by the loop's own `remaining === 0` check on its very first iteration below (before stays empty, after becomes the full children array via children.slice(0)) -- a dedicated early return would only ever produce a result that check already produces on its own. const before: XmlNode[] = []; let remaining = offset; - for (let index = 0; index < children.length; index += 1) { + // A `for...of` over `.entries()`, not an indexed `for` loop bounded by `index < children.length`: every valid offset this function is ever called with (see the comment above) makes `remaining` reach exactly 0 at or before the final entry, so the loop below always returns from inside its own body -- an indexed bound never needs comparing against `children.length` at all, so there is no such comparison here to get wrong. + for (const [index, node] of children.entries()) { if (remaining === 0) { return { before, after: children.slice(index) }; } - const node = children[index]!; const length = measureOdfNodeLength(node); if (remaining >= length) { before.push(node); diff --git a/packages/odf.js/src/test-support/document-tree.test.ts b/packages/odf.js/src/test-support/document-tree.test.ts new file mode 100644 index 000000000..27f30f943 --- /dev/null +++ b/packages/odf.js/src/test-support/document-tree.test.ts @@ -0,0 +1,169 @@ +import type { ContentDocument, DocumentTree } from "document-schema.js"; +import { assembleTree } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { + assertPackageRoundTrip, + drawingPackage, + formulaPackage, + presentationPackage, + spreadsheetPackage, + wordprocessingPackage, +} from "./document-tree"; + +const wordprocessingContent: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], +}; +const presentationContent: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [], +}; +const spreadsheetContent: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], +}; +const drawingContent: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [], +}; +const formulaContent: ContentDocument = { + kind: "formula", + metadata: {}, + formula: { mathml: [] }, +}; + +const wordprocessingTree = assembleTree(wordprocessingContent); +const presentationTree = assembleTree(presentationContent); +const spreadsheetTree = assembleTree(spreadsheetContent); +const drawingTree = assembleTree(drawingContent); +const formulaTree = assembleTree(formulaContent); + +describe("wordprocessingPackage", () => { + it("returns a wordprocessing package unchanged", () => { + expect(wordprocessingPackage(wordprocessingTree)).toBe(wordprocessingTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => wordprocessingPackage(presentationTree)).toThrow( + "expected a wordprocessing package, got presentation", + ); + }); +}); + +describe("presentationPackage", () => { + it("returns a presentation package unchanged", () => { + expect(presentationPackage(presentationTree)).toBe(presentationTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => presentationPackage(wordprocessingTree)).toThrow( + "expected a presentation package, got wordprocessing", + ); + }); +}); + +describe("spreadsheetPackage", () => { + it("returns a spreadsheet package unchanged", () => { + expect(spreadsheetPackage(spreadsheetTree)).toBe(spreadsheetTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => spreadsheetPackage(wordprocessingTree)).toThrow( + "expected a spreadsheet package, got wordprocessing", + ); + }); +}); + +describe("drawingPackage", () => { + it("returns a drawing package unchanged", () => { + expect(drawingPackage(drawingTree)).toBe(drawingTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => drawingPackage(wordprocessingTree)).toThrow( + "expected a drawing package, got wordprocessing", + ); + }); +}); + +describe("formulaPackage", () => { + it("returns a formula package unchanged", () => { + expect(formulaPackage(formulaTree)).toBe(formulaTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => formulaPackage(wordprocessingTree)).toThrow( + "expected a formula package, got wordprocessing", + ); + }); +}); + +describe("assertPackageRoundTrip", () => { + it("passes when the tree, its flattened form, and its re-minted form all agree", () => { + expect(() => { + assertPackageRoundTrip(wordprocessingTree, wordprocessingContent); + }).not.toThrow(); + }); + + it("throws when the tree fails schema validation", () => { + // "fonts" is schema-typed as TreeEmbeddedFont[] | undefined but read by + // neither flattenTree (it has no ContentDocument spelling at all) nor factorStyles (which carries an existing value through verbatim rather than recomputing it, per factor-styles.ts's own comment on the three package-level fields it re-carries untouched). A malformed value here is therefore invisible to the other two checks and trips only + // DocumentTreeSchema.parse -- isolating that one call. The cast is + // deliberate: this is exactly a value the type system exists to rule out, constructed so the runtime check has something real to catch. + const invalidTree = { + ...wordprocessingTree, + fonts: "not an array", + } as unknown as DocumentTree; + expect(() => { + assertPackageRoundTrip(invalidTree, wordprocessingContent); + }).toThrow(); + }); + + it("throws when the flattened tree doesn't match the given content", () => { + const mismatchedContent: ContentDocument = { + ...wordprocessingContent, + metadata: { title: "not what this tree flattens to" }, + }; + expect(() => { + assertPackageRoundTrip(wordprocessingTree, mismatchedContent); + }).toThrow(); + }); + + it("throws when re-minting the tree doesn't reproduce it", () => { + // Two paragraphs sharing one run-level tuple (bold, differing only in text, which factor-styles.ts's own header notes is not a mintable property) cross the plan's repeat-count-of-two threshold, so a fresh mint of this content genuinely extracts a shared style entry. Tacking an extra, unreferenced entry onto the tree's own already-minted table makes the tree schema-valid and still flatten to the same content (flattenTree resolves refs the tree's nodes actually carry; an unused table entry is invisible to it) while no longer matching what re-minting that same content produces -- isolating the third check. + const styledContent: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { kind: "paragraph", runs: [{ text: "one", bold: true }] }, + { kind: "paragraph", runs: [{ text: "two", bold: true }] }, + ], + }, + ], + }; + const styledTree = assembleTree(styledContent); + if (styledTree.kind !== "wordprocessing") { + throw new Error("expected assembleTree to preserve the document kind"); + } + if (styledTree.styles?.s1 === undefined) { + throw new Error( + "expected the repeated bold run to mint a shared styles entry", + ); + } + const treeWithUnusedEntry: DocumentTree = { + ...styledTree, + styles: { ...styledTree.styles, "unused-copy": styledTree.styles.s1 }, + }; + expect(() => { + assertPackageRoundTrip(treeWithUnusedEntry, styledContent); + }).toThrow(); + }); +}); diff --git a/packages/odf.js/src/test-support/zip.test.ts b/packages/odf.js/src/test-support/zip.test.ts new file mode 100644 index 000000000..37c7e55fe --- /dev/null +++ b/packages/odf.js/src/test-support/zip.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import { + assertMimetypeEntryLayout, + localFileHeaderNames, + readUint16LE, + readUint32LE, +} from "./zip"; + +// Builds a single synthetic local file header (signature 0x04034b50) plus a body of `compressedSize` zero bytes, with an arbitrary filename and extra-field length, entirely by hand rather than through fflate -- fflate's own zipSync never emits a non-empty extra field, so exercising the `extraLength` term in localFileHeaderNames's offset arithmetic needs bytes built directly. +function buildLocalFileHeader(options: { + filename: string; + extraLength: number; + compressedSize: number; +}): Uint8Array { + const nameBytes = new TextEncoder().encode(options.filename); + const total = + 30 + nameBytes.length + options.extraLength + options.compressedSize; + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(8, 0, true); // compression method + view.setUint32(18, options.compressedSize, true); + view.setUint16(26, nameBytes.length, true); + view.setUint16(28, options.extraLength, true); + bytes.set(nameBytes, 30); + return bytes; +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +describe("readUint16LE", () => { + it("combines two distinct bytes little-endian", () => { + // 0x02 | (0x01 << 8) = 0x0102 -- transposing the bytes or negating the shift would give a different value. + expect(readUint16LE(new Uint8Array([0x02, 0x01]), 0)).toBe(0x0102); + }); + + it("reads from a non-zero offset (offset + 1, not offset - 1, addresses the high byte)", () => { + const bytes = new Uint8Array([0xff, 0x02, 0x01, 0xff]); + expect(readUint16LE(bytes, 1)).toBe(0x0102); + }); + + it("throws when both bytes are missing", () => { + expect(() => readUint16LE(new Uint8Array([]), 0)).toThrow( + "truncated zip bytes while reading a uint16 at offset 0", + ); + }); + + it("throws when only the high byte is missing", () => { + expect(() => readUint16LE(new Uint8Array([0x42]), 0)).toThrow( + "truncated zip bytes while reading a uint16 at offset 0", + ); + }); + + it("throws when only the low byte is missing (negative offset)", () => { + // offset=-1 makes bytes[-1] (the low byte) undefined while bytes[0] (the high byte) is defined. + expect(() => readUint16LE(new Uint8Array([0x42, 0x43]), -1)).toThrow( + "truncated zip bytes while reading a uint16 at offset -1", + ); + }); + + it("reports the exact offset that failed, not a neighbouring one", () => { + expect(() => readUint16LE(new Uint8Array([1, 2, 3]), 5)).toThrow( + "truncated zip bytes while reading a uint16 at offset 5", + ); + }); +}); + +describe("readUint32LE", () => { + it("combines four distinct bytes little-endian, unsigned", () => { + // 0x04 | (0x03 << 8) | (0x02 << 16) | (0x01 << 24) = 0x01020304 + expect(readUint32LE(new Uint8Array([0x04, 0x03, 0x02, 0x01]), 0)).toBe( + 0x01020304, + ); + }); + + it("stays unsigned even when the top byte would set the sign bit", () => { + // Without the >>> 0 conversion this would read as a negative number. + expect(readUint32LE(new Uint8Array([0x00, 0x00, 0x00, 0xff]), 0)).toBe( + 0xff000000, + ); + }); + + it("throws when all four bytes are missing", () => { + expect(() => readUint32LE(new Uint8Array([]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the last byte is missing", () => { + expect(() => readUint32LE(new Uint8Array([1, 2, 3]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the third byte is missing", () => { + expect(() => readUint32LE(new Uint8Array([1, 2]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the first byte is present", () => { + expect(() => readUint32LE(new Uint8Array([1]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the first byte is missing (negative offset)", () => { + // offset=-1 makes bytes[-1] (b0) undefined while b1..b3 (bytes[0..2]) are defined. + expect(() => readUint32LE(new Uint8Array([1, 2, 3]), -1)).toThrow( + "truncated zip bytes while reading a uint32 at offset -1", + ); + }); +}); + +describe("localFileHeaderNames", () => { + it("returns no names for an empty byte array", () => { + expect(localFileHeaderNames(new Uint8Array([]))).toEqual([]); + }); + + it("stops cleanly when the walk consumes every byte exactly (no off-by-one past the end)", () => { + const header = buildLocalFileHeader({ + filename: "a.txt", + extraLength: 0, + compressedSize: 0, + }); + expect(localFileHeaderNames(header)).toEqual(["a.txt"]); + }); + + it("walks past a non-zero extra field to find the next header", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 4, + compressedSize: 0, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second]); + expect(localFileHeaderNames(bytes)).toEqual(["first.xml", "second.xml"]); + }); + + it("walks past a non-zero compressed size to find the next header", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 0, + compressedSize: 6, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second]); + expect(localFileHeaderNames(bytes)).toEqual(["first.xml", "second.xml"]); + }); + + it("walks past both a non-zero extra field and compressed size together", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 3, + compressedSize: 5, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const third = buildLocalFileHeader({ + filename: "third.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second, third]); + expect(localFileHeaderNames(bytes)).toEqual([ + "first.xml", + "second.xml", + "third.xml", + ]); + }); + + it("stops at the first entry whose signature does not match, without reading past it", () => { + const header = buildLocalFileHeader({ + filename: "only.xml", + extraLength: 0, + compressedSize: 0, + }); + const trailer = new Uint8Array([0x50, 0x4b, 0x01, 0x02]); // central directory signature, not a local file header + const bytes = concatBytes([header, trailer]); + expect(localFileHeaderNames(bytes)).toEqual(["only.xml"]); + }); +}); + +describe("assertMimetypeEntryLayout", () => { + const mediaType = "application/vnd.oasis.opendocument.text"; + + function validLayout(): Uint8Array { + return buildLocalFileHeader({ + filename: "mimetype", + extraLength: 0, + compressedSize: 0, + }).slice(0, 30 + 8); // header only, then the content bytes appended below + } + + function withMimetypeContent(content: string): Uint8Array { + const header = validLayout(); + return concatBytes([header, new TextEncoder().encode(content)]); + } + + it("accepts a correctly-laid-out mimetype entry", () => { + expect(() => { + assertMimetypeEntryLayout(withMimetypeContent(mediaType), mediaType); + }).not.toThrow(); + }); + + it("rejects a wrong local file header signature", () => { + const bytes = withMimetypeContent(mediaType); + bytes[0] = 0x00; + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/local file header signature/); + }); + + it("rejects a non-zero compression method", () => { + const bytes = withMimetypeContent(mediaType); + bytes[8] = 8; // DEFLATE, not stored + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/compression method/); + }); + + it("rejects a filename length other than 8", () => { + const bytes = buildLocalFileHeader({ + filename: "mimetype2", + extraLength: 0, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/filename length/); + }); + + it("rejects a non-zero extra field length", () => { + const bytes = buildLocalFileHeader({ + filename: "mimetype", + extraLength: 4, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/extra field length/); + }); + + it("rejects filename bytes other than the literal string mimetype", () => { + const bytes = buildLocalFileHeader({ + filename: "MIMETYPE", + extraLength: 0, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/filename bytes/); + }); + + it("rejects mimetype content bytes that don't match the given media type", () => { + const bytes = withMimetypeContent( + "application/vnd.oasis.opendocument.spreadsheet", + ); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/mimetype content bytes/); + }); +}); diff --git a/packages/odf.js/src/test-support/zip.ts b/packages/odf.js/src/test-support/zip.ts index 7dd9f7fc1..b14d296c7 100644 --- a/packages/odf.js/src/test-support/zip.ts +++ b/packages/odf.js/src/test-support/zip.ts @@ -2,32 +2,35 @@ import { expect } from "vitest"; // Little-endian integer readers over raw zip bytes, shared by every test that walks a zip's physical local-file-header layout rather than trusting a round trip through unzipPackage's Record (which makes no ordering promise of its own to test against). Never imported by src/index.ts and never reaches dist/ -- test-only, mirroring the same test-only, never-exported convention as this package's other test-support helpers. -export function readUint16LE(bytes: Uint8Array, offset: number): number { - const b0 = bytes[offset]; - const b1 = bytes[offset + 1]; - if (b0 === undefined || b1 === undefined) { +// A single out-of-range check on the whole [offset, offset + byteCount) span, rather than one `bytes[i] === undefined` comparison per byte -- the per-byte form used to leave middle bytes (b1 of 4, say) impossible to isolate as the sole missing one, since a real Uint8Array's undefined region is always a contiguous prefix (negative indices) or suffix (indices past the end), never a single interior gap: no test input could ever tell "byte 1 alone is missing" apart from "the guard doesn't check byte 1 at all", so that mutation was unkillable by construction. A span check has no such interior case to isolate. +function requireBytesInRange( + bytes: Uint8Array, + offset: number, + byteCount: number, + typeLabel: string, +): void { + if (offset < 0 || offset + byteCount > bytes.length) { throw new Error( - `truncated zip bytes while reading a uint16 at offset ${offset}`, + `truncated zip bytes while reading a ${typeLabel} at offset ${offset}`, ); } +} + +export function readUint16LE(bytes: Uint8Array, offset: number): number { + requireBytesInRange(bytes, offset, 2, "uint16"); + // Bounds already verified above, so both indices are in range -- this is the standard escape hatch for a typed-array read TypeScript otherwise types as `number | undefined` under noUncheckedIndexedAccess with no way to narrow it from a separately-expressed arithmetic guard. + const b0 = bytes[offset]!; + const b1 = bytes[offset + 1]!; return b0 | (b1 << 8); } export function readUint32LE(bytes: Uint8Array, offset: number): number { - const b0 = bytes[offset]; - const b1 = bytes[offset + 1]; - const b2 = bytes[offset + 2]; - const b3 = bytes[offset + 3]; - if ( - b0 === undefined || - b1 === undefined || - b2 === undefined || - b3 === undefined - ) { - throw new Error( - `truncated zip bytes while reading a uint32 at offset ${offset}`, - ); - } + requireBytesInRange(bytes, offset, 4, "uint32"); + // Bounds already verified above, so all four indices are in range -- see readUint16LE's identical comment. + const b0 = bytes[offset]!; + const b1 = bytes[offset + 1]!; + const b2 = bytes[offset + 2]!; + const b3 = bytes[offset + 3]!; return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0; } diff --git a/packages/odf.js/src/typed/draw/embedded-write.test.ts b/packages/odf.js/src/typed/draw/embedded-write.test.ts new file mode 100644 index 000000000..0087afe71 --- /dev/null +++ b/packages/odf.js/src/typed/draw/embedded-write.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { ContentDocument } from "document-schema.js"; +import { PAGE_SIZE_A4 } from "document-schema.js"; +import type { Package } from "../../model/package"; +import { el } from "../../xml/fragment"; +import { readMimetype, writeMimetype } from "../../mimetype"; +import { + writeEmbeddedObjectPackage, + writeDrawObjectElement, + writeEmbeddedObject, +} from "./embedded-write"; + +const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }; +const FRAME = { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }; + +function mediaTypeOf(pkg: Package): string | undefined { + return readMimetype(pkg); +} + +function wordprocessingDocument(): ContentDocument { + return { + kind: "wordprocessing", + metadata: {}, + sections: [{ pageSize: PAGE_SIZE_A4, margins: MARGINS, blocks: [] }], + }; +} + +describe("writeEmbeddedObjectPackage", () => { + it("dispatches a wordprocessing document to writeOdtContent", () => { + const pkg = writeEmbeddedObjectPackage({ + objectKind: "wordprocessing", + frame: FRAME, + document: wordprocessingDocument(), + }); + expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.text"); + }); + + it("dispatches a presentation document to writeOdpContent", () => { + const document: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "presentation", + frame: FRAME, + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.presentation", + ); + }); + + it("dispatches a spreadsheet document to writeOdsContent", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "spreadsheet", + frame: FRAME, + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + }); + + it("dispatches a drawing document to writeOdgContent", () => { + const document: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "drawing", + frame: FRAME, + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.graphics", + ); + }); + + it("dispatches a formula document to writeOdfFormulaContent", () => { + const document: ContentDocument = { + kind: "formula", + metadata: {}, + formula: { mathml: [el("math", {}, [])] }, + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "formula", + frame: FRAME, + document, + }); + expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.formula"); + }); + + it("refuses a chart object, which has no write-side serialiser", () => { + expect(() => + writeEmbeddedObjectPackage({ + objectKind: "chart", + frame: FRAME, + document: { kind: "spreadsheet", metadata: {}, sheets: [] }, + }), + ).toThrow(/no write-side serialiser/); + }); +}); + +describe("writeDrawObjectElement", () => { + it('carries xlink:type="simple" alongside the href', () => { + const element = writeDrawObjectElement("Object 1"); + const attrByName = (name: string) => + element.attributes.find((a) => a.name === name)?.value; + expect(attrByName("xlink:type")).toBe("simple"); + expect(attrByName("xlink:href")).toBe("./Object 1"); + }); +}); + +describe("writeEmbeddedObject", () => { + it("re-syncs the outer package's manifest so the new embedding directory is actually listed", () => { + const pkg: Package = { parts: {} }; + writeMimetype(pkg, "application/vnd.oasis.opendocument.text"); + writeEmbeddedObject( + { + objectKind: "wordprocessing", + frame: FRAME, + document: wordprocessingDocument(), + }, + "Object 1", + pkg, + ); + const manifestPart = pkg.parts["META-INF/manifest.xml"]; + expect(manifestPart?.kind).toBe("xml"); + if (manifestPart?.kind !== "xml") { + throw new Error("expected a manifest part"); + } + const manifestXml = JSON.stringify(manifestPart.nodes); + expect(manifestXml).toContain("Object 1/"); + }); +}); diff --git a/packages/odf.js/src/typed/draw/embedded.test.ts b/packages/odf.js/src/typed/draw/embedded.test.ts index ae995e3cd..f0727c8a2 100644 --- a/packages/odf.js/src/typed/draw/embedded.test.ts +++ b/packages/odf.js/src/typed/draw/embedded.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; -import { readDrawObjectReference } from "./embedded"; +import { + normaliseObjectHref, + readDrawObjectReference, + readEmbeddedObjectDocument, + type EmbeddedDrawObject, +} from "./embedded"; // The real shape this reader targets is proven end to end against genuine LibreOffice output in typed/ods/read.test.ts (src/typed/ods/fixtures/sheet-anchors.ods, a real Calc sheet with a real embedded Draw document anchored to a cell, and src/typed/ods/fixtures/sheet-formula.ods, the same with a real Math object). This suite covers the reference-resolution edges those files cannot: a linked (not embedded) object, a broken href, and each representable/unrepresentable body kind. @@ -192,3 +197,151 @@ describe("readDrawObjectReference", () => { expect(reference?.objectKind).toBe("wordprocessing"); }); }); + +describe("normaliseObjectHref", () => { + it("accepts a plain relative directory name unchanged", () => { + expect(normaliseObjectHref("Object 1")).toBe("Object 1"); + }); + + it("strips a leading './' and a trailing '/'", () => { + expect(normaliseObjectHref("./Object 1/")).toBe("Object 1"); + }); + + it("rejects an empty href, once the './' prefix and trailing '/' are stripped away", () => { + expect(normaliseObjectHref("./")).toBeUndefined(); + expect(normaliseObjectHref("")).toBeUndefined(); + }); + + it('rejects a href starting with ".." after stripping, even when it is not otherwise empty, absolute, or a URL', () => { + expect(normaliseObjectHref("../sibling")).toBeUndefined(); + expect(normaliseObjectHref("..")).toBeUndefined(); + }); + + it("rejects an absolute path (leading '/'), even when it is not otherwise empty, '..'-prefixed, or a URL", () => { + expect(normaliseObjectHref("/Object 1")).toBeUndefined(); + }); + + it('rejects any href containing "://", even a relative-looking one with no leading "..", "/", or emptiness', () => { + expect(normaliseObjectHref("weird://Object 1")).toBeUndefined(); + }); + + it("checks the START of the string for '..' and '/', not the end, so a name merely ending with either is accepted unchanged", () => { + expect(normaliseObjectHref("Object 1/..")).toBe("Object 1/.."); // ends with ".." but does not START with it + expect(normaliseObjectHref("folder..")).toBe("folder.."); // ditto + // "a//" has only ONE trailing slash stripped by the earlier, separate trailing-slash removal above, leaving "a/" -- which still itself ends with "/" without starting with it, isolating startsWith("/") from a wrongly-substituted endsWith("/") the way the first two cases isolate startsWith("..") from endsWith(".."). + expect(normaliseObjectHref("a//")).toBe("a/"); + }); +}); + +const EMBED_FRAME = { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }; + +function embeddedReferenceOf( + objectKind: EmbeddedDrawObject["objectKind"], + bodyChild: XmlElement, +): EmbeddedDrawObject { + return { + objectKind, + href: "Object 1", + package: { parts: { "content.xml": subDocumentPart(bodyChild) } }, + }; +} + +describe("readEmbeddedObjectDocument", () => { + it('dispatches "wordprocessing" to readOdtContent', () => { + const reference = embeddedReferenceOf("wordprocessing", el("office:text")); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("wordprocessing"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "presentation" to readOdpContent', () => { + const reference = embeddedReferenceOf( + "presentation", + el("office:presentation"), + ); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("presentation"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "drawing" to readOdgContent', () => { + const reference = embeddedReferenceOf("drawing", el("office:drawing")); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("drawing"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "spreadsheet" to readOdsContent', () => { + const reference = embeddedReferenceOf( + "spreadsheet", + el("office:spreadsheet"), + ); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("spreadsheet"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "formula" to readOdfFormulaContent, whose own reader already returns a finished ContentDocument', () => { + const reference: EmbeddedDrawObject = { + objectKind: "formula", + href: "Object 1", + package: { + parts: { + "content.xml": { kind: "xml", nodes: [realEmbeddedFormulaRoot()] }, + }, + }, + }; + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("formula"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "chart" to readOdfChartContent, which alone of every kind carries residue', () => { + const chartElement = el("chart:chart", {}, [ + el("table:table", {}, [el("table:table-row")]), + ]); + const reference: EmbeddedDrawObject = { + objectKind: "chart", + href: "Object 1", + package: { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [chartElement]), + ]), + ], + }, + }, + }, + }; + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("drawing"); + expect(residue).not.toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/draw/embedded.ts b/packages/odf.js/src/typed/draw/embedded.ts index 01beac429..7f52ff034 100644 --- a/packages/odf.js/src/typed/draw/embedded.ts +++ b/packages/odf.js/src/typed/draw/embedded.ts @@ -194,8 +194,8 @@ function subDocumentKind( return findMathRoot(nodes) === undefined ? undefined : "formula"; } -// The normalised directory prefix a draw:object's own xlink:href names, or undefined when the href is absent, empty, or points outside the package (a LINKED object: an absolute URL, or a path escaping the package root). -function normaliseObjectHref(raw: string): string | undefined { +// The normalised directory prefix a draw:object's own xlink:href names, or undefined when the href is absent, empty, or points outside the package (a LINKED object: an absolute URL, or a path escaping the package root). Exported so each of the four rejection clauses can be pinned directly: every one of them, if silently skipped, still leaves subDocumentPackage's own lookup failing for a different reason (a mismatched or empty re-keyed part set) against any package a black-box readDrawObjectReference test could build, which would make a mutation here unobservable through that entry point alone. +export function normaliseObjectHref(raw: string): string | undefined { const withoutPrefix = raw.startsWith("./") ? raw.slice(2) : raw; const trimmed = withoutPrefix.endsWith("/") ? withoutPrefix.slice(0, -1) diff --git a/packages/odf.js/src/typed/draw/shapes.test.ts b/packages/odf.js/src/typed/draw/shapes.test.ts index bb072afdf..eeb55e83c 100644 --- a/packages/odf.js/src/typed/draw/shapes.test.ts +++ b/packages/odf.js/src/typed/draw/shapes.test.ts @@ -220,6 +220,23 @@ describe("readDrawFrame: content dispatch", () => { ]); }); + it("does not mint a spurious list numId for a draw:text-box child that is neither text:p nor text:list -- only a genuine text:list consumes the numId counter", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", {}, [txt("item")])]), + ]); + const frame = el("draw:frame", box, [ + el("draw:text-box", {}, [ + el("text:p", {}, [txt("Hello")]), + el("draw:custom-shape", {}), + list, + ]), + ]); + const shape = readDrawFrame(frame, [], { parts: {} }); + expect( + shape?.blocks.map((b) => (b.kind === "paragraph" ? b.list : undefined)), + ).toEqual([undefined, { numId: "list1", level: 0 }]); + }); + it("reads a draw:image's referenced media part, sniffed and sized to the frame's own resolved box", () => { const pkg: Package = { parts: { @@ -288,6 +305,75 @@ describe("readDrawFrame: content dispatch", () => { ); }); + it("falls back to svg:desc when svg:title is present but empty -- an empty title carries no real alt text", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + el("svg:title", {}, []), + el("svg:desc", {}, [txt("A longer description")]), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).toMatchObject({ + kind: "image", + altText: "A longer description", + }); + }); + + it("leaves altText undefined when both svg:title and svg:desc are present but empty", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + el("svg:title", {}, []), + el("svg:desc", {}, []), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).not.toHaveProperty( + "altText", + ); + }); + + it("reads a positioned frame's own text:anchor-type into a floatPosition relative to the resolved origin", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", { ...box, "text:anchor-type": "page" }, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).toEqual({ + kind: "image", + format: "png", + base64: tinyPngBase64(), + widthPt: 100, + heightPt: 50, + floatPosition: { + horizontal: { relativeTo: "page", offsetPt: 0 }, + vertical: { relativeTo: "page", offsetPt: 0 }, + }, + }); + }); + + it("leaves floatPosition absent for a frame with no text:anchor-type at all", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).not.toHaveProperty( + "floatPosition", + ); + }); + it("returns no blocks (not a thrown error) for a draw:image whose referenced part is missing", () => { const frame = el("draw:frame", box, [ el("draw:image", { "xlink:href": "Pictures/missing.png" }), @@ -328,6 +414,100 @@ describe("readDrawFrame: content dispatch", () => { }); }); +describe("readDrawFrame: embedded objects (embeddedFormat opt-in)", () => { + const box = { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "100pt", + "svg:height": "50pt", + }; + + it("attaches a chart embedded object's own residue as the block's source -- the one embedded kind whose sub-reader quarantines presentation-specific XML", () => { + const chartElement = el("chart:chart", {}, [ + el("table:table", {}, [el("table:table-row")]), + ]); + const pkg: Package = { + parts: { + "Object 1/content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:chart", {}, [chartElement])]), + ]), + ], + }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:object", { "xlink:href": "./Object 1" }), + ]); + const shape = readDrawFrame(frame, [], pkg, undefined, false, "odp"); + expect(shape?.blocks).toHaveLength(1); + const block = shape?.blocks[0]; + expect(block).toMatchObject({ + kind: "embeddedObject", + objectKind: "chart", + }); + expect(block).toHaveProperty("source"); + expect( + block?.kind === "embeddedObject" ? block.source : undefined, + ).not.toBeUndefined(); + }); + + it("carries no source at all for an embedded kind other than chart -- residue is genuinely absent, not an empty placeholder", () => { + const pkg: Package = { + parts: { + "Object 1/content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:drawing")]), + ]), + ], + }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:object", { "xlink:href": "./Object 1" }), + ]); + const shape = readDrawFrame(frame, [], pkg, undefined, false, "odp"); + expect(shape?.blocks[0]).not.toHaveProperty("source"); + }); +}); + +describe("readDrawFrame: flowPositioning opt-in", () => { + const flowBox = { "svg:width": "40pt", "svg:height": "20pt" }; + + it("readDrawFrame's own flowPositioning parameter defaults to false: a frame with no svg:x/svg:y (only svg:width/svg:height) reads as undefined unless the caller opts in", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + expect(readDrawFrame(frame, [], { parts: {} })).toBeUndefined(); + }); + + it("resolves the same frame at the origin of its own box when flowPositioning is explicitly true", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + const shape = readDrawFrame(frame, [], { parts: {} }, undefined, true); + expect(shape?.frame).toEqual({ + xPt: 0, + yPt: 0, + widthPt: 40, + heightPt: 20, + }); + }); + + it("walkDrawShapes never applies flow positioning: a frame with no svg:x/svg:y is dropped, not read at the origin of its own box", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + const out: ContentShape[] = []; + walkDrawShapes([frame], [], { parts: {} }, out); + expect(out).toEqual([]); + }); +}); + describe("readDrawFrame: rotation via draw:transform", () => { it("composes into a center-pivoting frame + rotationDeg -- see transform.test.ts for the pixel-verified geometry this delegates to", () => { const frame = el("draw:frame", { @@ -728,6 +908,122 @@ describe("readDrawPageContent: non-flat fills (gradient/bitmap/hatch) and fill o expect(vector.fill).toBeUndefined(); }); + it("a resolved definition with a missing or unrecognised draw:style leaves fillPattern undefined, same as an unresolvable name", () => { + const gradient = el("draw:gradient", { + "draw:name": "grad1", + "draw:style": "not-a-real-style", + "draw:start-color": "#ff0000", + "draw:end-color": "#0000ff", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "gradient", + "draw:fill-gradient-name": "grad1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [gradient]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).toBeUndefined(); + }); + + it("a resolved definition with a missing or unrecognised draw:style leaves fillPattern undefined, same as an unresolvable name", () => { + const hatch = el("draw:hatch", { + "draw:name": "hatch1", + "draw:style": "not-a-real-style", + "draw:color": "#123456", + "draw:distance": "0.1cm", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "hatch", + "draw:fill-hatch-name": "hatch1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [hatch]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).toBeUndefined(); + }); + + it("omits angleDeg from the gradient fillPattern entirely (not a present-but-undefined key) when draw:angle is absent", () => { + const gradient = el("draw:gradient", { + "draw:name": "grad1", + "draw:style": "linear", + "draw:start-color": "#ff0000", + "draw:end-color": "#0000ff", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "gradient", + "draw:fill-gradient-name": "grad1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [gradient]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).not.toHaveProperty("angleDeg"); + }); + + it("omits rotationDeg from the hatch fillPattern entirely (not a present-but-undefined key) when draw:rotation is absent", () => { + const hatch = el("draw:hatch", { + "draw:name": "hatch1", + "draw:style": "single", + "draw:color": "#123456", + "draw:distance": "0.1cm", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "hatch", + "draw:fill-hatch-name": "hatch1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [hatch]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).not.toHaveProperty("rotationDeg"); + }); + it("reads draw:opacity into fillOpacity as a 0..1 fraction", () => { const gr1 = graphicStyle("gr1", { "draw:fill-color": "#ff0000", @@ -764,16 +1060,139 @@ describe("readDrawPageContent: non-flat fills (gradient/bitmap/hatch) and fill o if (vector?.kind !== "rect") { throw new Error("expected a rect vector"); } - expect(vector.fillOpacity).toBeUndefined(); + expect(vector.fillOpacity).toBeUndefined(); + }); +}); + +describe("readDrawPageContent: stroke opacity and the real dash run-length pattern (ExaDev/documents.js#954)", () => { + it("reads svg:stroke-opacity (a bare [0,1] double) into the stroke's own opacity field", () => { + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "svg:stroke-opacity": "0.25", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.opacity).toBeCloseTo(0.25, 6); + }); + + it("also accepts svg:stroke-opacity as a percentage", () => { + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "svg:stroke-opacity": "80%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.opacity).toBeCloseTo(0.8, 6); + }); + + it("resolves a \"dash\"-mode stroke's own named definition into a real dashPattern, alongside the existing style: 'dashed'", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "1", + "draw:dots1-length": "3pt", + "draw:dots2": "2", + "draw:dots2-length": "1pt", + "draw:distance": "2pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toEqual({ + dots1: 1, + dots1LengthPt: 3, + dots2: 2, + dots2LengthPt: 1, + distancePt: 2, + }); + }); + + it("resolves a single-length dash pattern (no draw:dots2) with dots2/dots2LengthPt genuinely absent, not zero", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "4", + "draw:dots1-length": "150%", // percentage of svg:stroke-width + "draw:distance": "1pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "2pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.dashPattern).toEqual({ + dots1: 4, + dots1LengthPt: 3, // 150% of the 2pt stroke width + distancePt: 1, + }); + expect(vector.stroke?.dashPattern?.dots2).toBeUndefined(); }); -}); -describe("readDrawPageContent: stroke opacity and the real dash run-length pattern (ExaDev/documents.js#954)", () => { - it("reads svg:stroke-opacity (a bare [0,1] double) into the stroke's own opacity field", () => { + it("a dashed stroke whose named dash definition cannot be resolved keeps style: 'dashed' alone, with no fabricated dashPattern", () => { const gr1 = graphicStyle("gr1", { "svg:stroke-color": "#000000", "svg:stroke-width": "1pt", - "svg:stroke-opacity": "0.25", + "draw:stroke": "dash", + "draw:stroke-dash": "does-not-exist", }); const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; const rect = el("draw:rect", { @@ -788,16 +1207,27 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte if (vector?.kind !== "rect") { throw new Error("expected a rect vector"); } - expect(vector.stroke?.opacity).toBeCloseTo(0.25, 6); + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); }); - it("also accepts svg:stroke-opacity as a percentage", () => { + it("a resolved dash definition with a non-positive draw:dots1 leaves dashPattern undefined -- dots1/dots1-length/distance are jointly required", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "0", + "draw:dots1-length": "3pt", + "draw:distance": "2pt", + }); const gr1 = graphicStyle("gr1", { "svg:stroke-color": "#000000", "svg:stroke-width": "1pt", - "svg:stroke-opacity": "80%", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", }); - const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; const rect = el("draw:rect", { "draw:style-name": "gr1", "svg:x": "0pt", @@ -810,17 +1240,16 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte if (vector?.kind !== "rect") { throw new Error("expected a rect vector"); } - expect(vector.stroke?.opacity).toBeCloseTo(0.8, 6); + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); }); - it("resolves a \"dash\"-mode stroke's own named definition into a real dashPattern, alongside the existing style: 'dashed'", () => { + it("a resolved dash definition with a non-positive draw:dots1-length leaves dashPattern undefined", () => { const dash = el("draw:stroke-dash", { "draw:name": "dash1", "draw:style": "rect", "draw:dots1": "1", - "draw:dots1-length": "3pt", - "draw:dots2": "2", - "draw:dots2-length": "1pt", + "draw:dots1-length": "0pt", "draw:distance": "2pt", }); const gr1 = graphicStyle("gr1", { @@ -845,26 +1274,20 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte throw new Error("expected a rect vector"); } expect(vector.stroke?.style).toBe("dashed"); - expect(vector.stroke?.dashPattern).toEqual({ - dots1: 1, - dots1LengthPt: 3, - dots2: 2, - dots2LengthPt: 1, - distancePt: 2, - }); + expect(vector.stroke?.dashPattern).toBeUndefined(); }); - it("resolves a single-length dash pattern (no draw:dots2) with dots2/dots2LengthPt genuinely absent, not zero", () => { + it("a resolved dash definition with a negative draw:distance leaves dashPattern undefined -- a zero distance is itself valid (dots touching)", () => { const dash = el("draw:stroke-dash", { "draw:name": "dash1", "draw:style": "rect", - "draw:dots1": "4", - "draw:dots1-length": "150%", // percentage of svg:stroke-width - "draw:distance": "1pt", + "draw:dots1": "1", + "draw:dots1-length": "3pt", + "draw:distance": "-1pt", }); const gr1 = graphicStyle("gr1", { "svg:stroke-color": "#000000", - "svg:stroke-width": "2pt", + "svg:stroke-width": "1pt", "draw:stroke": "dash", "draw:stroke-dash": "dash1", }); @@ -883,22 +1306,29 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte if (vector?.kind !== "rect") { throw new Error("expected a rect vector"); } - expect(vector.stroke?.dashPattern).toEqual({ - dots1: 4, - dots1LengthPt: 3, // 150% of the 2pt stroke width - distancePt: 1, - }); - expect(vector.stroke?.dashPattern?.dots2).toBeUndefined(); + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); }); - it("a dashed stroke whose named dash definition cannot be resolved keeps style: 'dashed' alone, with no fabricated dashPattern", () => { + it("a resolved dash definition whose draw:dots2 is present but non-positive keeps the single-length pattern, with dots2/dots2LengthPt genuinely absent (not present-but-undefined)", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "4", + "draw:dots1-length": "3pt", + "draw:distance": "1pt", + "draw:dots2": "0", + "draw:dots2-length": "5pt", + }); const gr1 = graphicStyle("gr1", { "svg:stroke-color": "#000000", "svg:stroke-width": "1pt", "draw:stroke": "dash", - "draw:stroke-dash": "does-not-exist", + "draw:stroke-dash": "dash1", }); - const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; const rect = el("draw:rect", { "draw:style-name": "gr1", "svg:x": "0pt", @@ -911,8 +1341,11 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte if (vector?.kind !== "rect") { throw new Error("expected a rect vector"); } - expect(vector.stroke?.style).toBe("dashed"); - expect(vector.stroke?.dashPattern).toBeUndefined(); + expect(vector.stroke?.dashPattern).toStrictEqual({ + dots1: 4, + dots1LengthPt: 3, + distancePt: 1, + }); }); }); @@ -1953,3 +2386,259 @@ describe("readDrawPageContent: stroke style (solid/dashed) from draw:stroke", () expect(vector.stroke.style).toBe("dashed"); }); }); + +describe("readDrawPageContent: fixed-preset and regular-polygon exact vertex coordinates", () => { + function customShape(name: string, type: string): XmlElement { + return el( + "draw:custom-shape", + { + "draw:name": name, + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [el("draw:enhanced-geometry", { "draw:type": type })], + ); + } + + function pathVertices(type: string): { xPt: number; yPt: number }[] { + const { vectors } = readDrawPageContent( + [customShape(`Custom${type}`, type)], + { parts: {} }, + ); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error(`expected a path vector for preset "${type}"`); + } + const subpath = vector.subpaths[0]; + return [ + subpath!.start, + ...subpath!.segments.map((s) => + s.kind === "line" ? s.to : { xPt: NaN, yPt: NaN }, + ), + ]; + } + + it("isosceles-triangle's own three vertices: apex at top-centre, base spanning the full frame width at the bottom", () => { + expect(pathVertices("isosceles-triangle")).toEqual([ + { xPt: 25, yPt: 0 }, + { xPt: 50, yPt: 30 }, + { xPt: 0, yPt: 30 }, + ]); + }); + + it("right-triangle's own three vertices: the right angle at the bottom-left corner", () => { + expect(pathVertices("right-triangle")).toEqual([ + { xPt: 0, yPt: 0 }, + { xPt: 0, yPt: 30 }, + { xPt: 50, yPt: 30 }, + ]); + }); + + it("hexagon's own six vertices are evenly spaced around the frame's own centre, point-up", () => { + const vertices = pathVertices("hexagon"); + expect(vertices).toHaveLength(6); + const cx = 25; + const cy = 15; + const expected = Array.from({ length: 6 }, (_, i) => { + const angle = -Math.PI / 2 + (2 * Math.PI * i) / 6; + return { xPt: cx + cx * Math.cos(angle), yPt: cy + cy * Math.sin(angle) }; + }); + vertices.forEach((v, i) => { + expect(v.xPt).toBeCloseTo(expected[i]!.xPt, 9); + expect(v.yPt).toBeCloseTo(expected[i]!.yPt, 9); + }); + // The topmost vertex sits at dead centre horizontally, at the very top of the frame. + expect(vertices[0]!.xPt).toBeCloseTo(25, 9); + expect(vertices[0]!.yPt).toBeCloseTo(0, 9); + // The bottommost vertex (index 3, halfway round) sits at dead centre horizontally, at the very bottom. + expect(vertices[3]!.xPt).toBeCloseTo(25, 9); + expect(vertices[3]!.yPt).toBeCloseTo(30, 9); + }); + + it("round-rectangle's real rounded path carries every one of its 8 segments' own exact coordinates, not just the start point", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 21600 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "3600", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error("expected a path vector"); + } + const r = (3600 / 21600) * 50; // ~8.333333pt + const k = r * 0.5522847498307936; + const w = 50; + const h = 30; + const subpath = vector.subpaths[0]!; + expect(subpath.start).toEqual({ xPt: r, yPt: 0 }); + expect(subpath.segments).toEqual([ + { kind: "line", to: { xPt: w - r, yPt: 0 } }, + { + kind: "cubic", + control1: { xPt: w - r + k, yPt: 0 }, + control2: { xPt: w, yPt: r - k }, + to: { xPt: w, yPt: r }, + }, + { kind: "line", to: { xPt: w, yPt: h - r } }, + { + kind: "cubic", + control1: { xPt: w, yPt: h - r + k }, + control2: { xPt: w - r + k, yPt: h }, + to: { xPt: w - r, yPt: h }, + }, + { kind: "line", to: { xPt: r, yPt: h } }, + { + kind: "cubic", + control1: { xPt: r - k, yPt: h }, + control2: { xPt: 0, yPt: h - r + k }, + to: { xPt: 0, yPt: h - r }, + }, + { kind: "line", to: { xPt: 0, yPt: r } }, + { + kind: "cubic", + control1: { xPt: 0, yPt: r - k }, + control2: { xPt: r - k, yPt: 0 }, + to: { xPt: r, yPt: 0 }, + }, + ]); + }); + + it("round-rectangle degrades to a plain rect when the shape's own svg:viewBox has a zero or negative width", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 0 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "3600", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + expect(vectors[0]?.kind).toBe("rect"); + }); + + it("round-rectangle degrades to a plain rect when the resolved radius is zero or negative", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 21600 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "0", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + expect(vectors[0]?.kind).toBe("rect"); + }); +}); + +describe("readDrawPageContent: fillPattern/fillOpacity carried through every vector kind, not only rect", () => { + it("draw:ellipse carries fillOpacity through, the same as draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const ellipse = el("draw:ellipse", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([ellipse], pkg); + const vector = vectors[0]; + if (vector?.kind !== "ellipse") { + throw new Error("expected an ellipse vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); + + it("draw:path carries fillOpacity through, the same as draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const path = el("draw:path", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + "svg:viewBox": "0 0 100 100", + "svg:d": "M0 0h100v100z", + }); + const { vectors } = readDrawPageContent([path], pkg); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error("expected a path vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); + + it("a recognised custom-shape preset carries fillOpacity through, the same as a plain draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const shape = el( + "draw:custom-shape", + { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }, + [el("draw:enhanced-geometry", { "draw:type": "ellipse" })], + ); + const { vectors } = readDrawPageContent([shape], pkg); + const vector = vectors[0]; + if (vector?.kind !== "ellipse") { + throw new Error("expected an ellipse vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); +}); diff --git a/packages/odf.js/src/typed/draw/shapes.ts b/packages/odf.js/src/typed/draw/shapes.ts index cda69e4f6..ad8f43ca2 100644 --- a/packages/odf.js/src/typed/draw/shapes.ts +++ b/packages/odf.js/src/typed/draw/shapes.ts @@ -348,10 +348,8 @@ export function walkDrawShapes( } } else if (node.tag === "draw:g") { const ownFunctions = readOwnTransformFunctions(node); - const nested = - ownFunctions.length === 0 - ? groupFunctions - : [...ownFunctions, ...groupFunctions]; + // No length-0 shortcut returning groupFunctions unchanged: spreading an empty ownFunctions ahead of groupFunctions produces the identical content either way, so the shortcut was a pure allocation micro-optimisation, not an observable behavioural branch. + const nested = [...ownFunctions, ...groupFunctions]; walkDrawShapes(node.children, nested, pkg, out, indexState, listIdState); } } @@ -378,8 +376,8 @@ function parseOdfPercentUnit(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - return numeric === undefined ? undefined : Number(numeric) / 100; + // match[1]'s own group has no `?` quantifier of its own (only the alternation inside it does), so it always matches once `match` itself is non-null -- the same mandatory-group guarantee typed/shared/units.ts's parseOdfLength/parseOdfAngleDeg rely on for their own match[1]!. + return Number(match[1]!) / 100; } // svg:stroke-opacity's own value grammar (OASIS ODF 1.3, style:graphic-properties): "a value of type double 18.2 in the range [0,1] or a value of type zeroToHundredPercent 18.3.41" -- unlike draw:opacity, which is always a percentage. @@ -1226,10 +1224,8 @@ function walkDrawPageContent( } } else if (node.tag === "draw:g") { const ownFunctions = readOwnTransformFunctions(node); - const nested = - ownFunctions.length === 0 - ? groupFunctions - : [...ownFunctions, ...groupFunctions]; + // See walkDrawShapes' own identical construction above for why there is no length-0 shortcut here either. + const nested = [...ownFunctions, ...groupFunctions]; walkDrawPageContent( node.children, nested, diff --git a/packages/odf.js/src/typed/draw/write-shapes.test.ts b/packages/odf.js/src/typed/draw/write-shapes.test.ts new file mode 100644 index 000000000..457922ab0 --- /dev/null +++ b/packages/odf.js/src/typed/draw/write-shapes.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it } from "vitest"; +import type { ContentShape, ContentBlock } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import type { ListPlanState } from "../shared/list"; +import { + planShapeContent, + frameGeometryAttrs, + odfZIndexOf, + writeDrawFrame, + canonicalDrawShape, + writeDrawShapes, + createDrawShapeWriteState, + type DrawShapeWriteState, +} from "./write-shapes"; + +// This module had no direct unit tests at all -- every function here was only exercised indirectly through typed/odp/write.test.ts and typed/odg/write.test.ts's own whole-document round-trip suites, which (see typed/shared/canonicalise.ts's own top-of-file note) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates. + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +function writeState(): { + state: DrawShapeWriteState; + mintedStyles: () => XmlElement[]; +} { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + return { + state: createDrawShapeWriteState(pkg, registry, automaticStyles), + mintedStyles: () => + automaticStyles.children.filter( + (c): c is XmlElement => c.type === "element" && c.tag === "style:style", + ), + }; +} + +function attr( + element: XmlElement | undefined, + name: string, +): string | undefined { + return element === undefined ? undefined : attrValue(element, name); +} + +const ZERO_INSETS = { + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, +}; + +function shape(overrides: Partial = {}): ContentShape { + return { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 }, + ...ZERO_INSETS, + blocks: [], + ...overrides, + }; +} + +function paragraphBlock(text: string): ContentBlock { + return { kind: "paragraph", runs: [{ text }] }; +} + +describe("planShapeContent", () => { + it("resolves a single table block to kind: table", () => { + const table: ContentBlock = { kind: "table", rows: [], columnWidthsPt: [] }; + const plan = planShapeContent([table], freshListState()); + expect(plan.kind).toBe("table"); + }); + + it("resolves a single image block to kind: image", () => { + const image: ContentBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }; + const plan = planShapeContent([image], freshListState()); + expect(plan.kind).toBe("image"); + }); + + it("resolves a single embeddedObject block to kind: embedded", () => { + const object: ContentBlock = { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + frame: { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + }; + const plan = planShapeContent([object], freshListState()); + expect(plan.kind).toBe("embedded"); + }); + + it("resolves any list of paragraphs (including a single one) to kind: text", () => { + const plan = planShapeContent([paragraphBlock("hi")], freshListState()); + expect(plan.kind).toBe("text"); + if (plan.kind === "text") { + expect(plan.paragraphs).toHaveLength(1); + } + }); + + it("refuses a table alongside another block", () => { + const table: ContentBlock = { kind: "table", rows: [], columnWidthsPt: [] }; + expect(() => + planShapeContent([table, paragraphBlock("x")], freshListState()), + ).toThrow(/a table alongside other content/); + }); + + it("refuses an image alongside another block", () => { + const image: ContentBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }; + expect(() => + planShapeContent([image, paragraphBlock("x")], freshListState()), + ).toThrow(/an image alongside other content/); + }); + + it("refuses a page break", () => { + const pageBreak: ContentBlock = { kind: "pageBreak" }; + expect(() => planShapeContent([pageBreak], freshListState())).toThrow( + /a page break/, + ); + }); + + it("refuses an embedded object alongside another block", () => { + const object: ContentBlock = { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + frame: { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + }; + expect(() => + planShapeContent([object, paragraphBlock("x")], freshListState()), + ).toThrow(/an embedded object alongside other content/); + }); + + it("refuses a construct boundary marker", () => { + const marker: ContentBlock = { + kind: "constructStart", + descriptor: { kind: "division" }, + }; + expect(() => planShapeContent([marker], freshListState())).toThrow( + /a construct boundary marker/, + ); + }); + + it("refuses a heading paragraph", () => { + const heading: ContentBlock = { + kind: "paragraph", + runs: [{ text: "H" }], + headingLevel: 1, + }; + expect(() => planShapeContent([heading], freshListState())).toThrow( + /a heading/, + ); + }); + + it("force-closes the plan's currently open run unconditionally, even when the next shape's own blocks carry no list membership at all", () => { + const listState = freshListState(); + planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + expect(listState.openNumId).toBeDefined(); + planShapeContent([paragraphBlock("b")], listState); + expect(listState.openNumId).toBeUndefined(); + }); + + it("canonicalises list membership onto a fresh numId for a new shape's first paragraph, even when its incoming numId string happens to match a still-open run from before this call", () => { + const listState = freshListState(); + const first = planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + const second = planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + if (first.kind !== "text" || second.kind !== "text") { + throw new Error("expected text plans"); + } + expect(first.paragraphs[0]?.list?.numId).not.toBe( + second.paragraphs[0]?.list?.numId, + ); + }); +}); + +describe("frameGeometryAttrs", () => { + const frame = { xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 }; + + it("writes plain svg:x/y/width/height with no draw:transform when rotationDeg is undefined", () => { + const attrs = frameGeometryAttrs(frame, undefined); + expect(attrs["svg:x"]).toBe("10pt"); + expect(attrs["svg:y"]).toBe("20pt"); + expect(attrs["svg:width"]).toBe("100pt"); + expect(attrs["svg:height"]).toBe("50pt"); + expect(attrs["draw:transform"]).toBeUndefined(); + }); + + it("treats rotationDeg === 0 identically to undefined", () => { + expect(frameGeometryAttrs(frame, 0)).toEqual( + frameGeometryAttrs(frame, undefined), + ); + }); + + it("writes svg:width/height plus draw:transform (no svg:x/y) for a genuinely rotated frame", () => { + const attrs = frameGeometryAttrs(frame, 90); + expect(attrs["svg:width"]).toBe("100pt"); + expect(attrs["svg:height"]).toBe("50pt"); + expect(attrs["svg:x"]).toBeUndefined(); + expect(attrs["svg:y"]).toBeUndefined(); + expect(attrs["draw:transform"]).toMatch( + /^rotate\(.+\) translate\(.+ .+\)$/, + ); + }); + + it("a 90-degree rotation's own transform matches the documented algebraic derivation exactly", () => { + const attrs = frameGeometryAttrs(frame, 90); + const angleRad = (-90 * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const halfW = frame.widthPt / 2; + const halfH = frame.heightPt / 2; + const txPt = frame.xPt + halfW - halfW * cos - halfH * sin; + const tyPt = frame.yPt + halfH - halfH * cos + halfW * sin; + expect(attrs["draw:transform"]).toBe( + `rotate(${angleRad}) translate(${txPt}pt ${tyPt}pt)`, + ); + }); +}); + +describe("odfZIndexOf", () => { + it("returns undefined for an undefined paintOrder", () => { + expect(odfZIndexOf(undefined)).toBeUndefined(); + }); + + it("returns undefined for a negative paintOrder", () => { + expect(odfZIndexOf(-1)).toBeUndefined(); + }); + + it("returns undefined for a fractional paintOrder", () => { + expect(odfZIndexOf(1.5)).toBeUndefined(); + }); + + it("returns undefined for a paintOrder beyond Number.isSafeInteger's own bound", () => { + expect(odfZIndexOf(Number.MAX_SAFE_INTEGER + 2)).toBeUndefined(); + }); + + it("returns the value unchanged for a genuine non-negative safe integer, including zero", () => { + expect(odfZIndexOf(0)).toBe(0); + expect(odfZIndexOf(5)).toBe(5); + }); +}); + +describe("writeDrawFrame", () => { + it("uses the shape's own resolvable paintOrder over documentIndex, and falls back to documentIndex otherwise", () => { + const { state } = writeState(); + const withPaintOrder = writeDrawFrame( + shape({ paintOrder: 9 }), + freshListState(), + state, + 2, + ); + expect(attr(withPaintOrder, "draw:z-index")).toBe("9"); + const withoutPaintOrder = writeDrawFrame( + shape(), + freshListState(), + state, + 2, + ); + expect(attr(withoutPaintOrder, "draw:z-index")).toBe("2"); + }); + + it("writes draw:name only when the shape actually states one", () => { + const { state } = writeState(); + expect( + attr(writeDrawFrame(shape(), freshListState(), state, 0), "draw:name"), + ).toBeUndefined(); + expect( + attr( + writeDrawFrame(shape({ name: "Rect 1" }), freshListState(), state, 0), + "draw:name", + ), + ).toBe("Rect 1"); + }); + + it("writes a draw:text-box for an all-paragraph shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ blocks: [paragraphBlock("hi")] }), + freshListState(), + state, + 0, + ); + expect(written.children[0]).toMatchObject({ tag: "draw:text-box" }); + }); + + it("writes a table:table for a single-table shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ + blocks: [{ kind: "table", rows: [], columnWidthsPt: [] }], + }), + freshListState(), + state, + 0, + ); + expect(written.children[0]).toMatchObject({ tag: "table:table" }); + }); + + it("writes a draw:image for a single-image shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ + blocks: [ + { + kind: "image", + format: "png", + base64: "abc", + widthPt: 1, + heightPt: 1, + }, + ], + }), + freshListState(), + state, + 0, + ); + expect( + written.children.some( + (c) => c.type === "element" && c.tag === "draw:image", + ), + ).toBe(true); + }); + + it("mints a graphic-family style stating explicit no-fill/no-stroke, with padding attributes only when an inset is non-zero", () => { + const { state, mintedStyles } = writeState(); + writeDrawFrame(shape(), freshListState(), state, 0); + const zeroInsetStyle = mintedStyles().find( + (s) => attrValue(s, "style:family") === "graphic", + ); + const zeroProps = zeroInsetStyle?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + expect(attr(zeroProps, "draw:fill")).toBe("none"); + expect(attr(zeroProps, "draw:stroke")).toBe("none"); + expect(attr(zeroProps, "fo:padding-left")).toBeUndefined(); + + const { state: state2, mintedStyles: mintedStyles2 } = writeState(); + writeDrawFrame(shape({ insetLeftPt: 3 }), freshListState(), state2, 0); + const withInsetStyle = mintedStyles2().find( + (s) => attrValue(s, "style:family") === "graphic", + ); + const withProps = withInsetStyle?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + expect(attr(withProps, "fo:padding-left")).toBe("3pt"); + expect(attr(withProps, "fo:padding-top")).toBe("0pt"); + }); +}); + +describe("canonicalDrawShape", () => { + it("resolves paintOrder to documentIndex when the shape states none, and to its own value when it does", () => { + expect(canonicalDrawShape(shape(), 4, freshListState()).paintOrder).toBe(4); + expect( + canonicalDrawShape(shape({ paintOrder: 7 }), 4, freshListState()) + .paintOrder, + ).toBe(7); + }); + + it("collapses rotationDeg === 0 to absent, but keeps a genuine non-zero rotation", () => { + expect( + canonicalDrawShape(shape({ rotationDeg: 0 }), 0, freshListState()) + .rotationDeg, + ).toBeUndefined(); + expect( + canonicalDrawShape(shape({ rotationDeg: 30 }), 0, freshListState()) + .rotationDeg, + ).toBe(30); + }); + + it("carries name through only when stated", () => { + expect( + canonicalDrawShape(shape(), 0, freshListState()).name, + ).toBeUndefined(); + expect( + canonicalDrawShape(shape({ name: "X" }), 0, freshListState()).name, + ).toBe("X"); + }); + + it("overrides an image block's own widthPt/heightPt with the enclosing shape's frame size", () => { + const result = canonicalDrawShape( + shape({ + frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 80 }, + blocks: [ + { + kind: "image", + format: "png", + base64: "abc", + widthPt: 1, + heightPt: 1, + }, + ], + }), + 0, + freshListState(), + ); + expect(result.blocks[0]).toMatchObject({ widthPt: 200, heightPt: 80 }); + }); + + it("carries frame/insets through unchanged", () => { + const s = shape({ insetLeftPt: 5 }); + const result = canonicalDrawShape(s, 0, freshListState()); + expect(result.frame).toEqual(s.frame); + expect(result.insetLeftPt).toBe(5); + }); +}); + +describe("writeDrawShapes", () => { + it("writes each shape at its own array index as documentIndex, in order", () => { + const { state } = writeState(); + const written = writeDrawShapes( + [shape(), shape()], + freshListState(), + state, + ); + expect(attr(written[0], "draw:z-index")).toBe("0"); + expect(attr(written[1], "draw:z-index")).toBe("1"); + }); +}); diff --git a/packages/odf.js/src/typed/draw/write-vectors.test.ts b/packages/odf.js/src/typed/draw/write-vectors.test.ts new file mode 100644 index 000000000..01941e1bd --- /dev/null +++ b/packages/odf.js/src/typed/draw/write-vectors.test.ts @@ -0,0 +1,443 @@ +import { describe, expect, it } from "vitest"; +import type { ContentVector } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import { + createDrawShapeWriteState, + type DrawShapeWriteState, +} from "./write-shapes"; +import { + writeDrawVector, + writeDrawVectors, + canonicalDrawVector, +} from "./write-vectors"; + +// writeDrawVector/canonicalDrawVector had no direct unit tests at all -- only indirect exercise through typed/odg/write.test.ts's own round-trip suite, which (see typed/shared/canonicalise.ts's own top-of-file note) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates. These tests assert directly against the raw written XML and against canonicalDrawVector's own return value. + +function writeState(): DrawShapeWriteState { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + return createDrawShapeWriteState(pkg, registry, automaticStyles); +} + +function attr(element: XmlElement, name: string): string | undefined { + return attrValue(element, name); +} + +function graphicPropsOf( + written: XmlElement, + state: DrawShapeWriteState, +): XmlElement { + const styleName = attr(written, "draw:style-name"); + const style = state.contentAutomaticStyles.children.find( + (c): c is XmlElement => + c.type === "element" && + c.tag === "style:style" && + attrValue(c, "style:name") === styleName, + ); + if (style === undefined) { + throw new Error(`expected a minted style named ${styleName}`); + } + const props = style.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + if (props === undefined) { + throw new Error("expected a style:graphic-properties child"); + } + return props; +} + +// `satisfies` rather than `: ContentVector`, so each fixture keeps its own literal "rect"/"line"/"path" member type -- annotating with the full union would widen it back to the union and lose the narrowing canonicalDrawVector's own per-kind assertions below rely on. +const RECT = { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, +} satisfies ContentVector; + +describe("writeDrawVector: paint (vectorGraphicStyleName)", () => { + it('writes draw:fill="none" when the vector states no fill', () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 0); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:fill")).toBe("none"); + expect(attr(props, "draw:fill-color")).toBeUndefined(); + }); + + it('writes draw:fill="solid" and draw:fill-color when the vector states a fill', () => { + const state = writeState(); + const written = writeDrawVector( + { ...RECT, fill: { r: 1, g: 0, b: 0 } }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:fill")).toBe("solid"); + expect(attr(props, "draw:fill-color")).toBe("#ff0000"); + }); + + it("writes svg:fill-rule only when the path vector states one", () => { + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ], + }; + const state = writeState(); + const withoutRule = graphicPropsOf(writeDrawVector(path, state, 0), state); + expect(attr(withoutRule, "svg:fill-rule")).toBeUndefined(); + + const state2 = writeState(); + const withRule = graphicPropsOf( + writeDrawVector({ ...path, fillRule: "evenodd" }, state2, 0), + state2, + ); + expect(attr(withRule, "svg:fill-rule")).toBe("evenodd"); + }); + + it('writes draw:stroke="none" and no stroke-color/width when the vector states no stroke', () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 0); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("none"); + expect(attr(props, "svg:stroke-color")).toBeUndefined(); + expect(attr(props, "svg:stroke-width")).toBeUndefined(); + }); + + it("writes a solid stroke's colour and width, and draw:stroke=solid for an absent or explicit solid style", () => { + const state = writeState(); + const written = writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 2 }, + }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("solid"); + expect(attr(props, "svg:stroke-color")).toBe("#0000ff"); + expect(attr(props, "svg:stroke-width")).toBe("2pt"); + }); + + it('writes draw:stroke="solid" for an explicitly stated "solid" style, not only an absent one', () => { + const state = writeState(); + const written = writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("solid"); + }); + + it('writes draw:stroke="dash" for a dashed stroke style', () => { + const state = writeState(); + const written = writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "dashed" }, + }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("dash"); + }); + + it("refuses a 'dotted' stroke style, naming it and ODF's own none/solid/dash enumeration", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "dotted" }, + }, + state, + 0, + ), + ).toThrow(/dotted.*none\/solid\/dash/s); + }); + + it("refuses a stroke whose widthPt is not positive, naming the actual width", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { ...RECT, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 0 } }, + state, + 0, + ), + ).toThrow(/width 0pt/); + }); +}); + +describe("writeDrawVector: draw:z-index (zIndexAttrs)", () => { + it("uses the caller's documentIndex when the vector states no ODF-spellable paintOrder", () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 3); + expect(attr(written, "draw:z-index")).toBe("3"); + }); + + it("uses the vector's own resolvable paintOrder over the caller's documentIndex", () => { + const state = writeState(); + const written = writeDrawVector({ ...RECT, paintOrder: 5 }, state, 3); + expect(attr(written, "draw:z-index")).toBe("5"); + }); +}); + +describe("writeDrawVector: per-kind element shape", () => { + it("writes a 'line' as draw:line with its own four endpoint coordinates and no frame geometry", () => { + const state = writeState(); + const line: ContentVector = { + kind: "line", + from: { xPt: 1, yPt: 2 }, + to: { xPt: 3, yPt: 4 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }; + const written = writeDrawVector(line, state, 0); + expect(written.tag).toBe("draw:line"); + expect(attr(written, "svg:x1")).toBe("1pt"); + expect(attr(written, "svg:y1")).toBe("2pt"); + expect(attr(written, "svg:x2")).toBe("3pt"); + expect(attr(written, "svg:y2")).toBe("4pt"); + }); + + it("writes 'rect' as draw:rect and 'ellipse' as draw:ellipse", () => { + const state = writeState(); + expect(writeDrawVector(RECT, state, 0).tag).toBe("draw:rect"); + expect(writeDrawVector({ ...RECT, kind: "ellipse" }, state, 0).tag).toBe( + "draw:ellipse", + ); + }); + + it("writes 'path' as draw:path with svg:viewBox and svg:d", () => { + const state = writeState(); + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ], + }; + const written = writeDrawVector(path, state, 0); + expect(written.tag).toBe("draw:path"); + expect(attr(written, "svg:viewBox")).toBeDefined(); + expect(attr(written, "svg:d")).toBeDefined(); + }); + + it("refuses a 'path' with no subpaths at all", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [], + }, + state, + 0, + ), + ).toThrow(/no subpaths/); + }); + + it("refuses a 'path' whose frame has a non-positive width or height, naming the actual dimensions", () => { + const state = writeState(); + const subpaths: Extract["subpaths"] = [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ]; + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 0, heightPt: 10 }, + subpaths, + }, + state, + 0, + ), + ).toThrow(/0pt x 10pt/); + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: -1 }, + subpaths, + }, + state, + 0, + ), + ).toThrow(/10pt x -1pt/); + }); + + it("refuses a 'path' whose frame has a height of exactly zero, not only a negative one", () => { + const state = writeState(); + const subpaths: Extract["subpaths"] = [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ]; + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 0 }, + subpaths, + }, + state, + 0, + ), + ).toThrow(/10pt x 0pt/); + }); +}); + +describe("writeDrawVectors", () => { + it("writes each vector at baseIndex plus its own array position", () => { + const state = writeState(); + const written = writeDrawVectors([RECT, RECT], state, 5); + expect(written).toHaveLength(2); + const [first, second] = written; + if (first === undefined || second === undefined) { + throw new Error("expected two written vectors"); + } + expect(attr(first, "draw:z-index")).toBe("5"); + expect(attr(second, "draw:z-index")).toBe("6"); + }); +}); + +describe("canonicalDrawVector", () => { + it("resolves paintOrder to documentIndex when the vector states none", () => { + expect(canonicalDrawVector(RECT, 7).paintOrder).toBe(7); + }); + + it("resolves paintOrder to the vector's own value when it states one", () => { + expect(canonicalDrawVector({ ...RECT, paintOrder: 2 }, 7).paintOrder).toBe( + 2, + ); + }); + + it("collapses rotationDeg === 0 to absent, but keeps a genuine non-zero rotation", () => { + const zero = canonicalDrawVector({ ...RECT, rotationDeg: 0 }, 0); + const nonZero = canonicalDrawVector({ ...RECT, rotationDeg: 45 }, 0); + if (zero.kind === "line" || nonZero.kind === "line") { + throw new Error("expected 'rect' results, not 'line'"); + } + expect(zero.rotationDeg).toBeUndefined(); + expect(nonZero.rotationDeg).toBe(45); + }); + + it("omits the rotationDeg key entirely (not merely undefined) when the vector never stated one", () => { + const result = canonicalDrawVector(RECT, 0); + expect(result).not.toHaveProperty("rotationDeg"); + }); + + it("quantises fill through canonicalColor and leaves an absent fill absent", () => { + const absent = canonicalDrawVector(RECT, 0); + const stated = canonicalDrawVector( + { ...RECT, fill: { r: 0.9, g: 0, b: 0 } }, + 0, + ); + if (absent.kind === "line" || stated.kind === "line") { + throw new Error("expected 'rect' results, not 'line'"); + } + expect(absent.fill).toBeUndefined(); + expect(stated.fill).toEqual({ r: 230 / 255, g: 0, b: 0 }); + }); + + it("canonicalises an absent stroke style to 'solid' and leaves an absent stroke absent", () => { + expect(canonicalDrawVector(RECT, 0).stroke).toBeUndefined(); + const result = canonicalDrawVector( + { ...RECT, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } }, + 0, + ); + expect(result.stroke).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + style: "solid", + }); + }); + + it("carries fillRule through for a 'path' only when stated", () => { + const path = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [], + } satisfies ContentVector; + expect(canonicalDrawVector(path, 0)).not.toHaveProperty("fillRule"); + const withRule = canonicalDrawVector({ ...path, fillRule: "nonzero" }, 0); + if (withRule.kind !== "path") { + throw new Error("expected a 'path' result"); + } + expect(withRule.fillRule).toBe("nonzero"); + }); + + it("carries every subpath's own start/segments/closed through for a 'path', as a fresh array", () => { + const subpaths: NonNullable< + Extract["subpaths"] + > = [ + { + start: { xPt: 1, yPt: 2 }, + segments: [{ kind: "line", to: { xPt: 3, yPt: 4 } }], + closed: true, + }, + ]; + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths, + }; + const result = canonicalDrawVector(path, 0); + expect(result.kind).toBe("path"); + if (result.kind === "path") { + expect(result.subpaths).toEqual(subpaths); + expect(result.subpaths).not.toBe(subpaths); + } + }); + + it("carries kind/frame through unchanged for 'line', 'rect', and 'ellipse'", () => { + const line: ContentVector = { + kind: "line", + from: { xPt: 0, yPt: 0 }, + to: { xPt: 1, yPt: 1 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }; + const result = canonicalDrawVector(line, 0); + expect(result.kind).toBe("line"); + if (result.kind === "line") { + expect(result.from).toEqual({ xPt: 0, yPt: 0 }); + expect(result.to).toEqual({ xPt: 1, yPt: 1 }); + } + const rectResult = canonicalDrawVector(RECT, 0); + if (rectResult.kind === "line") { + throw new Error("expected a 'rect' result, not 'line'"); + } + expect(rectResult.frame).toEqual(RECT.frame); + }); +}); diff --git a/packages/odf.js/src/typed/formula/read.test.ts b/packages/odf.js/src/typed/formula/read.test.ts index 0d5c7d8ae..77ec86fbf 100644 --- a/packages/odf.js/src/typed/formula/read.test.ts +++ b/packages/odf.js/src/typed/formula/read.test.ts @@ -225,6 +225,29 @@ describe("readOdfFormulaMathMl", () => { el("mi", {}, [txt("y")]), ]); }); + + it('defensively finds a "math:math"-prefixed root nested inside the wrapper, continuing past "math" (tried first, absent here) rather than stopping there', () => { + const mathRoot = el( + "math:math", + { "xmlns:math": "http://www.w3.org/1998/Math/MathML" }, + [el("math:mi", {}, [txt("z")])], + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [mathRoot]), + ]), + ], + }, + }, + }; + expect(readOdfFormulaMathMl(pkg).mathml).toEqual([ + el("math:mi", {}, [txt("z")]), + ]); + }); }); describe("readOdfFormulaContent", () => { diff --git a/packages/odf.js/src/typed/formula/write.test.ts b/packages/odf.js/src/typed/formula/write.test.ts index abc9a5825..eb1e212d6 100644 --- a/packages/odf.js/src/typed/formula/write.test.ts +++ b/packages/odf.js/src/typed/formula/write.test.ts @@ -5,7 +5,8 @@ import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; -import { validateManifest } from "../../manifest"; +import { readManifest, validateManifest } from "../../manifest"; +import { readOdfMetadata } from "../shared/metadata"; import { rootElement } from "../../xml/query"; import { readOdfFormula, readOdfFormulaMathMl } from "./read"; import { @@ -111,4 +112,55 @@ describe("writeOdfFormulaMathMl", () => { }), ).toThrow(/formula/); }); + + it("writes a standard XML declaration (version 1.0, encoding UTF-8) as content.xml's very first node", () => { + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mi", {}, [txt("x")])], + metadata: {}, + }); + const part = pkg.parts["content.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(part.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + }); + + it("actually writes the given metadata into meta.xml, not just an empty document", () => { + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mi", {}, [txt("x")])], + metadata: { title: "Quadratic Formula" }, + }); + expect(readOdfMetadata(pkg).title).toBe("Quadratic Formula"); + }); + + it("stamps the given version option onto both meta.xml and the manifest's root entry, not the default", () => { + const pkg = writeOdfFormulaMathMl( + { mathml: [el("mi", {}, [txt("x")])], metadata: {} }, + { version: "1.4" }, + ); + expect(readManifest(pkg).version).toBe("1.4"); + }); + + it("detects a math:-prefixed tag nested arbitrarily deep, not only at the mathml array's own top level", () => { + // The root and its immediate child both use plain (unprefixed) tags; only the leaf two levels down is math:-prefixed. A check that only inspects the top-level tag itself, without ever recursing into children, would wrongly report no math:-prefixed content here at all. + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mrow", {}, [el("mi", {}, [el("math:mi", {}, [])])])], + metadata: {}, + }); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect( + root?.attributes.find((attribute) => attribute.name === "xmlns:math") + ?.value, + ).toBe("http://www.w3.org/1998/Math/MathML"); + }); }); diff --git a/packages/odf.js/src/typed/formula/write.ts b/packages/odf.js/src/typed/formula/write.ts index 7f7304fdc..65a054dfd 100644 --- a/packages/odf.js/src/typed/formula/write.ts +++ b/packages/odf.js/src/typed/formula/write.ts @@ -89,14 +89,9 @@ export function writeOdfFormulaContent( `writeOdfFormulaContent: expected a 'formula' document, got '${content.kind}' -- odf.js writes .odf from the formula arm only`, ); } + // No starMath here: writeOdfFormulaMathMl (see its own top-of-file note) never reads document.starMath -- it round-trips the StarMath annotation verbatim as part of the mathml nodes themselves, so carrying content.formula.starMath through this object would be inert either way. return writeOdfFormulaMathMl( - { - mathml: content.formula.mathml, - ...(content.formula.starMath !== undefined - ? { starMath: content.formula.starMath } - : {}), - metadata: content.metadata, - }, + { mathml: content.formula.mathml, metadata: content.metadata }, options, ); } diff --git a/packages/odf.js/src/typed/odb/read.test.ts b/packages/odf.js/src/typed/odb/read.test.ts index ea474086b..952c26f46 100644 --- a/packages/odf.js/src/typed/odb/read.test.ts +++ b/packages/odf.js/src/typed/odb/read.test.ts @@ -1,12 +1,13 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; import { readOdbInventory, resolveOdbComponent } from "./read"; +import type { OdbInventory } from "./read"; // This suite reads TWO real, unmodified LibreOffice 26.2-generated .odb fixtures for its genuine-producer-shape assertions, mirroring readOdtContent's and readOdm's own established convention: src/typed/odb/fixtures/embedded-firebird.odb (an embedded-Firebird database document with two live SQL tables and one real query, and deliberately no forms or reports), and src/typed/odb/fixtures/form-and-report.odb (the same engine, plus a real bound form and a real Report Builder report -- see read.ts's own top-of-file note for how it was generated and for the two findings about real form/report registration it produced). A handful of synthetic, hand-built packages (via el/txt) cover shapes neither real fixture exercises -- an external connection, the two defensive db:database-description variants (never empirically observed), and the db:component-collection grouping and malformed-component paths. @@ -51,7 +52,11 @@ const BASE_MANIFEST_ENTRIES = [ ]; describe("readOdbInventory: embedded-firebird.odb (real LibreOffice output)", () => { - const inventory = readOdbInventory(loadFixture("embedded-firebird.odb")); + // Recomputed in beforeEach, not read once at describe-body scope: a describe-body call runs exactly once during Vitest's collection phase, before any individual test executes, which makes Stryker's per-test coverage analysis treat readOdbInventory's own mutants as "static" (unkillable by any single it()) rather than attributing them to the test that actually exercises the resulting assertion. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(loadFixture("embedded-firebird.odb")); + }); it('reads the real embedded connection info -- an "sdbc:embedded:" href classifies as embedded', () => { expect(inventory.connection).toEqual({ @@ -81,7 +86,11 @@ describe("readOdbInventory: embedded-firebird.odb (real LibreOffice output)", () }); describe("readOdbInventory: form-and-report.odb (real LibreOffice output)", () => { - const inventory = readOdbInventory(loadFixture("form-and-report.odb")); + // See the identical beforeEach note on the embedded-firebird.odb describe above. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(loadFixture("form-and-report.odb")); + }); it("reads the form's real user-visible name alongside its opaque persistent storage path -- the two genuinely differ in real output", () => { expect(inventory.forms).toEqual([ @@ -133,6 +142,41 @@ describe("resolveOdbComponent", () => { /no report named "SalesForm"/, ); }); + + it('names the available list as "(none)" rather than a bare empty string when the .odb declares no component of that kind at all', () => { + const emptyPkg: Package = { + parts: { + "content.xml": databaseContentPart([]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(() => resolveOdbComponent(emptyPkg, "form", "Anything")).toThrow( + /no form named "Anything" -- available: \(none\)/, + ); + }); + + it("joins two or more available names with a comma and a space, not concatenated bare", () => { + const twoFormsPkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:forms", {}, [ + el("db:component", { + "db:name": "First", + "xlink:href": "forms/Obj1", + }), + el("db:component", { + "db:name": "Second", + "xlink:href": "forms/Obj2", + }), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(() => resolveOdbComponent(twoFormsPkg, "form", "Nope")).toThrow( + 'no form named "Nope" -- available: First, Second', + ); + }); }); describe("readOdbInventory: synthetic fully-populated embedded package", () => { @@ -188,7 +232,11 @@ describe("readOdbInventory: synthetic fully-populated embedded package", () => { "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), }, }; - const inventory = readOdbInventory(pkg); + // See the identical beforeEach note on the embedded-firebird.odb describe above. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(pkg); + }); it("reads db:connection-resource", () => { expect(inventory.connection).toEqual({ @@ -223,6 +271,46 @@ describe("readOdbInventory: synthetic fully-populated embedded package", () => { }); }); +describe("readOdbInventory: table names from db:schema-definition", () => { + it("reads db:schema-definition/db:table-definitions table names alongside db:table-representations, deduplicating a name the two sources share", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:table-representations", {}, [ + el("db:table-representation", { "db:name": "Customers" }), + el("db:table-representation", { "db:name": "Orders" }), + ]), + el("db:schema-definition", {}, [ + el("db:table-definitions", {}, [ + // "Orders" is the same table db:table-representations already named above -- it must appear once in the result, proving the reader actually deduplicates across the two sources rather than merely happening not to repeat within one of them. + el("db:table-definition", { "db:name": "Orders" }), + el("db:table-definition", { "db:name": "Invoices" }), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).tables).toEqual([ + "Customers", + "Orders", + "Invoices", + ]); + }); + + it("reads no table names when office:database has a db:schema-definition with no db:table-definitions child", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:schema-definition", {}, []), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).tables).toEqual([]); + }); +}); + describe("readOdbInventory: query definitions", () => { it("reads db:escape-processing when present, as a real boolean, and omits the field entirely when absent", () => { const pkg: Package = { @@ -232,7 +320,7 @@ describe("readOdbInventory: query definitions", () => { el("db:query", { "db:name": "WithFlag", "db:command": "SELECT 1", - "db:escape-processing": "false", + "db:escape-processing": "true", }), el("db:query", { "db:name": "NoFlag", "db:command": "SELECT 2" }), ]), @@ -242,12 +330,32 @@ describe("readOdbInventory: query definitions", () => { }; const inventory = readOdbInventory(pkg); expect(inventory.queries).toEqual([ - { name: "WithFlag", command: "SELECT 1", escapeProcessing: false }, + { name: "WithFlag", command: "SELECT 1", escapeProcessing: true }, { name: "NoFlag", command: "SELECT 2" }, ]); expect("escapeProcessing" in (inventory.queries[1] ?? {})).toBe(false); }); + it('reads db:escape-processing="false" as a real false, distinguishing it from a bare string comparison against the wrong literal', () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:queries", {}, [ + el("db:query", { + "db:name": "Disabled", + "db:command": "SELECT 1", + "db:escape-processing": "false", + }), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).queries).toEqual([ + { name: "Disabled", command: "SELECT 1", escapeProcessing: false }, + ]); + }); + it("skips a db:query missing its mandatory db:command rather than returning it half-populated", () => { const pkg: Package = { parts: { @@ -259,6 +367,25 @@ describe("readOdbInventory: query definitions", () => { }; expect(readOdbInventory(pkg).queries).toEqual([]); }); + + it("never descends into a stray child that is neither db:query nor db:query-collection, even when it happens to carry a nested db:query of its own", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:queries", {}, [ + el("db:not-a-collection", {}, [ + el("db:query", { + "db:name": "Hidden", + "db:command": "SELECT 1", + }), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).queries).toEqual([]); + }); }); describe("readOdbInventory: external datasource", () => { @@ -369,6 +496,101 @@ describe("readOdbInventory: db:database-description variants (RNG-derived, never }); }); + it("treats a db:server-database with no db:type as a bare external connection with no url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:hostname": "db.example.com", + "db:port": "3306", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + // toStrictEqual, not toEqual: readConnectionInfo's own ternary either omits `url` entirely or sets it to a real string -- it never sets the key to a literal `undefined`. toEqual treats an `undefined`-valued property as equivalent to an absent one, so it cannot tell those two shapes apart; toStrictEqual can, and is what actually proves the key is genuinely missing. + expect(readOdbInventory(pkg).connection).toStrictEqual({ + type: "external", + }); + }); + + it("formats a db:server-database (hostname, no port) into a descriptive url with no port suffix", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:type": "mysql", + "db:hostname": "db.example.com", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql://db.example.com/salesdb", + }); + }); + + it("formats a db:server-database with neither a hostname nor a local socket name (only a database name) into a bare scheme-and-name url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:type": "mysql", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql:///salesdb", + }); + }); + + it("formats a db:server-database with neither a hostname/local-socket-name nor a database name into a bare scheme url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { "db:type": "mysql" }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql://", + }); + }); + it("reads a db:file-based-database href as an external connection", () => { const pkg: Package = { parts: { @@ -394,6 +616,30 @@ describe("readOdbInventory: db:database-description variants (RNG-derived, never url: "../data/", }); }); + + it("treats a db:file-based-database with no xlink:href as a bare external connection with no url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:file-based-database", { + "db:media-type": + "application/vnd.oasis.opendocument.spreadsheet", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + // toStrictEqual: see the "no db:type" test above for why toEqual cannot prove `url` is absent rather than merely undefined. + expect(readOdbInventory(pkg).connection).toStrictEqual({ + type: "external", + }); + }); }); describe("readOdbInventory: malformed db:component handling", () => { @@ -446,6 +692,27 @@ describe("readOdbInventory: malformed db:component handling", () => { ]), ).toEqual([{ name: "Sales & Marketing", href: "forms/A&B" }]); }); + + it("skips a stray child that is neither db:component nor db:component-collection, rather than misreading it as one", () => { + expect( + formsInventory([ + el("db:desc", {}, [txt("Not a real component.")]), + el("db:component", { "db:name": "Good", "xlink:href": "forms/Obj4" }), + ]), + ).toEqual([{ name: "Good", href: "forms/Obj4" }]); + }); + + it("skips a stray child tag even when it coincidentally carries a well-formed db:name and xlink:href of its own", () => { + expect( + formsInventory([ + el("db:not-a-component", { + "db:name": "Sneaky", + "xlink:href": "forms/ObjSneaky", + }), + el("db:component", { "db:name": "Good", "xlink:href": "forms/Obj5" }), + ]), + ).toEqual([{ name: "Good", href: "forms/Obj5" }]); + }); }); describe("readOdbInventory: scope boundaries and error paths", () => { diff --git a/packages/odf.js/src/typed/odb/report.test.ts b/packages/odf.js/src/typed/odb/report.test.ts index 70e5318db..893dcddf5 100644 --- a/packages/odf.js/src/typed/odb/report.test.ts +++ b/packages/odf.js/src/typed/odb/report.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import { el, txt } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; @@ -17,10 +17,12 @@ function loadFixture(name: string): Package { } describe("readOdbReport: form-and-report.odb (real LibreOffice Report Builder output)", () => { - const report = readOdbReport( - loadFixture("form-and-report.odb"), - "SalesByRegion", - ); + // Computed in beforeEach, not directly in this describe block (and not in beforeAll either): Stryker's per-test mutation coverage only attributes an executed statement to a specific test when that statement runs inside that test's own tracked window, which beforeEach (run immediately before each test, as part of running it) is part of but describe-body and beforeAll code (which run once, tied to no single test) are not -- either of those leaves every mutant this fixture alone would kill permanently unattributed to any test, regardless of how thorough the assertions below are. + let report: ReturnType; + + beforeEach(() => { + report = readOdbReport(loadFixture("form-and-report.odb"), "SalesByRegion"); + }); it("reports the report's user-visible name alongside the opaque persistent path its sub-document actually lives at", () => { expect(report.name).toBe("SalesByRegion"); @@ -329,10 +331,106 @@ describe("readOdbReport: synthetic report shapes", () => { ]), ]), ]); - expect(readOdbReport(pkg, "R").reportHeader?.elements[0]?.text).toBe( - "Total for region", + const element = readOdbReport(pkg, "R").reportHeader?.elements[0]; + expect(element?.text).toBe("Total for region"); + // toStrictEqual, not toEqual: this control's rpt:report-element has no rpt:report-component (no name) and the element itself carries no rpt:formula, so both fields must be genuinely ABSENT from the object -- toEqual alone would pass even if the reader set them to `undefined` explicitly, since it treats an undefined-valued property as equivalent to a missing one. + expect(element).toStrictEqual({ + tag: "rpt:fixed-content", + text: "Total for region", + }); + }); + + it("excludes rpt:report-element's own content from a control's text even when it happens to carry a stray text node of its own", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("rpt:fixed-content", {}, [ + txt("Visible"), + el("rpt:report-element", {}, [txt("SHOULD-NOT-APPEAR")]), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements[0]?.text).toBe("Visible"); + }); + + it("reads a direct text-node child of a control, not just text wrapped in a nested element", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("rpt:fixed-content", {}, [ + txt("Direct text"), + el("rpt:report-element", {}), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements[0]?.text).toBe( + "Direct text", ); }); + + it("never treats a non-rpt: element as a control, even when it directly contains an rpt:report-element", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("table:table", {}, [ + el("table:table-cell", {}, [el("rpt:report-element", {})]), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements).toEqual([]); + }); + + it("reads a false boolean attribute as a real false, not merely as 'present'", () => { + const pkg = reportPackage([ + el("rpt:group", { "rpt:sort-ascending": "false" }), + ]); + expect(readOdbReport(pkg, "R").groups[0]?.sortAscending).toBe(false); + }); + + it("omits a band's own name when its layout table carries no table:name", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [el("table:table", {}, [])]), + ]); + const report = readOdbReport(pkg, "R"); + expect(report.detail).toEqual({ kind: "detail", elements: [] }); + expect("name" in (report.detail ?? {})).toBe(false); + }); + + it("never treats a non-rpt:function element as a function, even when it happens to carry rpt:name and rpt:formula", () => { + const pkg = reportPackage([ + el("rpt:something-else", { "rpt:name": "X", "rpt:formula": "Y" }), + ]); + expect(readOdbReport(pkg, "R").functions).toEqual([]); + }); + + it("omits every optional top-level and group field entirely when the source declares none of them", () => { + const pkg = reportPackage([el("rpt:group", {}, [])]); + const report = readOdbReport(pkg, "R"); + for (const key of [ + "command", + "commandType", + "caption", + "mimeType", + "reportHeader", + "pageHeader", + "detail", + "pageFooter", + "reportFooter", + ]) { + expect(key in report).toBe(false); + } + const group = report.groups[0]; + expect(group).toBeDefined(); + for (const key of [ + "groupExpression", + "sortExpression", + "sortAscending", + "startNewColumn", + "resetPageNumber", + "keepTogether", + "header", + "footer", + ]) { + expect(group !== undefined && key in group).toBe(false); + } + }); }); describe("readOdbReport: error paths", () => { @@ -382,4 +480,16 @@ describe("readOdbReport: error paths", () => { }; expect(() => readOdbReport(pkg, "R")).toThrow(/office:report/); }); + + it("throws when the sub-document's content.xml is a binary part rather than XML", () => { + const pkg: Package = { + parts: { + "content.xml": baseContent, + "reports/Obj1/content.xml": { kind: "binary", base64: "" }, + }, + }; + expect(() => readOdbReport(pkg, "R")).toThrow( + /reports\/Obj1\/content\.xml is not an XML part/, + ); + }); }); diff --git a/packages/odf.js/src/typed/odb/subdocument.test.ts b/packages/odf.js/src/typed/odb/subdocument.test.ts index cb392c9df..4a7c78c9a 100644 --- a/packages/odf.js/src/typed/odb/subdocument.test.ts +++ b/packages/odf.js/src/typed/odb/subdocument.test.ts @@ -66,6 +66,19 @@ describe("subDocumentPackage", () => { ).toEqual(["Pictures/logo.png", "content.xml"]); }); + it("excludes a part whose path is exactly the prefix itself, with nothing left over to key it by", () => { + // A bare directory-marker entry at the prefix's own path (rather than nested beneath it) slices down to an empty relative path, which cannot become a part key. + const pkg: Package = { + parts: { + "forms/Obj1/": { kind: "binary", base64: "" }, + "forms/Obj1/content.xml": { kind: "xml", nodes: [] }, + }, + }; + expect(Object.keys(subDocumentPackage(pkg, "forms/Obj1").parts)).toEqual([ + "content.xml", + ]); + }); + it("shares the same Part values rather than deep-copying them", () => { const part = { kind: "binary" as const, base64: "AA==" }; const pkg: Package = { diff --git a/packages/odf.js/src/typed/odb/write.test.ts b/packages/odf.js/src/typed/odb/write.test.ts index ca95d0bd0..d5ee555c1 100644 --- a/packages/odf.js/src/typed/odb/write.test.ts +++ b/packages/odf.js/src/typed/odb/write.test.ts @@ -3,14 +3,57 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; import { validateManifest } from "../../manifest"; import { parsePackage } from "../../package-io/read"; +import { rootElement, childrenWithTag } from "../../xml/query"; +import { attrValue } from "../../xml/query"; +import { readManifest } from "../../manifest"; import { readOdbInventory } from "./read"; import type { OdbInventory } from "./read"; import { writeOdb } from "./write"; +// Direct, one-sided structural checks against the raw written XML, alongside the round-trip suite below: a round trip (write then read back, compare to the original inventory) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates or a fixture never exercises (see typed/shared/canonicalise.ts's own top-of-file note on this exact failure mode) -- e.g. a fixture whose components all share one asTemplate value can't distinguish "always writes true" from "writes the real value". +function databaseElement(pkg: Package): XmlElement { + const part = pkg.parts["content.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml content.xml part"); + } + const root = rootElement(part.nodes); + if (root === undefined) { + throw new Error("expected a root element"); + } + const body = childrenWithTag(root, "office:body")[0]; + if (body === undefined) { + throw new Error("expected an office:body element"); + } + const database = childrenWithTag(body, "office:database")[0]; + if (database === undefined) { + throw new Error("expected an office:database element"); + } + return database; +} + +function emptyInventory(): OdbInventory { + return { + connection: undefined, + tables: [], + queries: [], + forms: [], + reports: [], + }; +} + +// attrValue itself requires a real XmlElement; every caller here reads an attribute off a `[n]` array-index result that is legitimately `XmlElement | undefined` under noUncheckedIndexedAccess, so this short-circuits the same way optional chaining does rather than asserting the element is present. +function attr( + element: XmlElement | undefined, + name: string, +): string | undefined { + return element === undefined ? undefined : attrValue(element, name); +} + const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); function loadFixture(name: string): Package { @@ -80,4 +123,144 @@ describe("writeOdb", () => { }), ).toThrow(/no url/); }); + + it("names the connection kind that carries no url in the thrown message", () => { + expect(() => + writeOdb({ + ...emptyInventory(), + connection: { type: "embedded" }, + }), + ).toThrow(/an embedded connection/); + expect(() => + writeOdb({ + ...emptyInventory(), + connection: { type: "external" }, + }), + ).toThrow(/an external connection/); + }); + + it("writes no db:forms/db:reports/db:queries/db:table-representations element at all when every list is empty", () => { + const database = databaseElement(writeOdb(emptyInventory())); + expect(childrenWithTag(database, "db:forms")).toHaveLength(0); + expect(childrenWithTag(database, "db:reports")).toHaveLength(0); + expect(childrenWithTag(database, "db:queries")).toHaveLength(0); + expect(childrenWithTag(database, "db:table-representations")).toHaveLength( + 0, + ); + expect(childrenWithTag(database, "db:data-source")).toHaveLength(0); + }); + + it("writes db:as-template only when the component actually states it, verbatim true or false", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + forms: [ + { name: "NoFlag", href: "forms/Obj1" }, + { name: "FlagTrue", href: "forms/Obj2", asTemplate: true }, + { name: "FlagFalse", href: "forms/Obj3", asTemplate: false }, + ], + }), + ); + const forms = childrenWithTag(database, "db:forms")[0]; + const components = + forms === undefined ? [] : childrenWithTag(forms, "db:component"); + expect(attr(components[0], "db:as-template")).toBeUndefined(); + expect(attr(components[1], "db:as-template")).toBe("true"); + expect(attr(components[2], "db:as-template")).toBe("false"); + }); + + it("writes db:escape-processing only when the query actually states it, verbatim true or false", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + queries: [ + { name: "NoFlag", command: "SELECT 1" }, + { name: "FlagTrue", command: "SELECT 1", escapeProcessing: true }, + { name: "FlagFalse", command: "SELECT 1", escapeProcessing: false }, + ], + }), + ); + const queries = childrenWithTag(database, "db:queries")[0]; + if (queries === undefined) { + throw new Error("expected a db:queries element"); + } + const writtenQueries = childrenWithTag(queries, "db:query"); + expect(attr(writtenQueries[0], "db:escape-processing")).toBeUndefined(); + expect(attr(writtenQueries[1], "db:escape-processing")).toBe("true"); + expect(attr(writtenQueries[2], "db:escape-processing")).toBe("false"); + }); + + it("writes each table name as its own db:table-representation, in order", () => { + const database = databaseElement( + writeOdb({ ...emptyInventory(), tables: ["Customers", "Orders"] }), + ); + const representations = childrenWithTag( + database, + "db:table-representations", + )[0]; + if (representations === undefined) { + throw new Error("expected a db:table-representations element"); + } + const rows = childrenWithTag(representations, "db:table-representation"); + expect(rows.map((row) => attrValue(row, "db:name"))).toEqual([ + "Customers", + "Orders", + ]); + }); + + it("writes the connection's own url verbatim into db:connection-resource, regardless of connection type", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + connection: { type: "embedded", url: "sdbc:embedded:hsqldb" }, + }), + ); + const dataSource = childrenWithTag(database, "db:data-source")[0]; + if (dataSource === undefined) { + throw new Error("expected a db:data-source element"); + } + const connectionData = childrenWithTag(dataSource, "db:connection-data")[0]; + const resource = + connectionData === undefined + ? undefined + : childrenWithTag(connectionData, "db:connection-resource")[0]; + expect(attr(resource, "xlink:href")).toBe("sdbc:embedded:hsqldb"); + }); + + it('writes xlink:type="simple" on both the connection resource and a component, not an empty string', () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + connection: { type: "embedded", url: "sdbc:embedded:hsqldb" }, + forms: [{ name: "Form1", href: "forms/Obj1" }], + }), + ); + const dataSource = childrenWithTag(database, "db:data-source")[0]; + const connectionData = + dataSource === undefined + ? undefined + : childrenWithTag(dataSource, "db:connection-data")[0]; + const resource = + connectionData === undefined + ? undefined + : childrenWithTag(connectionData, "db:connection-resource")[0]; + expect(attr(resource, "xlink:type")).toBe("simple"); + const forms = childrenWithTag(database, "db:forms")[0]; + const component = + forms === undefined + ? undefined + : childrenWithTag(forms, "db:component")[0]; + expect(attr(component, "xlink:type")).toBe("simple"); + }); + + it("stamps a caller-supplied non-default version onto both content.xml's office:version and the manifest's own manifest:version, not silently falling back to the default for either", () => { + const pkg = writeOdb(emptyInventory(), { version: "1.2" }); + const part = pkg.parts["content.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml content.xml part"); + } + const root = rootElement(part.nodes); + expect(attr(root, "office:version")).toBe("1.2"); + expect(readManifest(pkg).version).toBe("1.2"); + }); }); diff --git a/packages/odf.js/src/typed/odg/read.test.ts b/packages/odf.js/src/typed/odg/read.test.ts index 07ff809c2..cebd53ea6 100644 --- a/packages/odf.js/src/typed/odg/read.test.ts +++ b/packages/odf.js/src/typed/odg/read.test.ts @@ -253,6 +253,15 @@ describe("readOdgContent", () => { expect(pages).toHaveLength(2); }); + it("omits the 'source' key entirely from a page with no residue, rather than carrying it set to undefined", () => { + const { pages } = readOdgContent(buildFixturePackage()); + expect(Object.hasOwn(pages[0]!, "source")).toBe(false); + }); + + it("omits the package-level 'source' key entirely from readOdg when nothing was quarantined", () => { + expect(Object.hasOwn(readOdg(buildFixturePackage()), "source")).toBe(false); + }); + it("resolves page size from the master-page -> page-layout chain, identically to readOdpContent", () => { const { pages } = readOdgContent(buildFixturePackage()); expect(pages[0]?.size.widthPt).toBeCloseTo((21 * 72) / 2.54, 6); diff --git a/packages/odf.js/src/typed/odg/write-round-trip.test.ts b/packages/odf.js/src/typed/odg/write-round-trip.test.ts index ffd2b0075..0eebaef8a 100644 --- a/packages/odf.js/src/typed/odg/write-round-trip.test.ts +++ b/packages/odf.js/src/typed/odg/write-round-trip.test.ts @@ -314,6 +314,12 @@ describe("writeOdgContent: the round-trip law", () => { expectRoundTrip(document); }); + it("refuses a document that is not a drawing, by kind, with an exact message naming both the expected and actual kind", () => { + expect(() => + normaliseOdgContent({ kind: "presentation", metadata: {}, slides: [] }), + ).toThrow(/expected a 'drawing' document, got 'presentation'/); + }); + it("collapses an absent stroke style to the 'solid' ContentStrokeStyleSchema already documents absence to mean", () => { const written = roundTrip( documentOf([ @@ -364,6 +370,63 @@ describe("writeOdgContent: the round-trip law", () => { const readBack = readOdg(decodePackage(encodePackage(written))); expect(readBack.source).toEqual(source); }); + + it("round-trips a footnote anchor in shape text with its definitions-table body through writeOdg", () => { + // Mirrors typed/odp/write-round-trip.test.ts's identical test: a footnote/comment construct only writes when the definitions table actually holds its body (typed/shared/constructs.ts's odfRunConstructWriteKind), so this also proves writeOdg's own definitions option genuinely reaches the shape writer. + const document = documentOf([ + page( + [], + [ + shape({}, [ + { + kind: "paragraph", + runs: [{ text: "1" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "note1", + definition: "note:note1", + }, + startRun: 0, + endRun: 1, + }, + ], + }, + ]), + ], + ), + ]); + const tree = assembleTree(document); + tree.definitions = { + "note:note1": { + kind: "footnote", + citation: "1", + body: [{ kind: "paragraph", runs: [{ text: "the note body" }] }], + }, + }; + const rewritten = readOdgContent(writeOdg(tree)); + const shapeText = rewritten.pages[0]!.shapes[0]!.blocks[0]!; + expect(shapeText).toMatchObject({ + kind: "paragraph", + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "note1", + }, + }, + ], + }); + }); + + it("stamps a custom version option onto the manifest via writeOdg's own final sync, distinct from the default DEFAULT_ODF_VERSION both share", () => { + const tree = assembleTree(documentOf([page([])])); + const written = writeOdg(tree, { version: "1.4" }); + expect(readManifest(written).version).toBe("1.4"); + }); }); describe("writeOdgContent: refusals", () => { diff --git a/packages/odf.js/src/typed/odg/write.test.ts b/packages/odf.js/src/typed/odg/write.test.ts index 973f0b0ea..5fa3d973a 100644 --- a/packages/odf.js/src/typed/odg/write.test.ts +++ b/packages/odf.js/src/typed/odg/write.test.ts @@ -187,9 +187,9 @@ describe("writeOdgContent: page geometry", () => { expect(pages).toHaveLength(2); const firstName = attrValue(pages[0]!, "draw:master-page-name"); const secondName = attrValue(pages[1]!, "draw:master-page-name"); - expect(firstName).toBeDefined(); - expect(secondName).toBeDefined(); - expect(firstName).not.toBe(secondName); + // Exact 1-indexed names, not merely "defined and distinct": a page's own index counts up from 0, and MP{index+1} is the spelling, never MP{index-1} (which would coincidentally still differ page-to-page). + expect(firstName).toBe("MP1"); + expect(secondName).toBe("MP2"); const stylesRoot = partRoot(pkg, "styles.xml"); const masterStyles = findChildElement( @@ -206,6 +206,7 @@ describe("writeOdgContent: page geometry", () => { throw new Error("expected the referenced master page to exist"); } const pageLayoutName = attrValue(masterPage, "style:page-layout-name"); + expect(pageLayoutName).toBe("PM2"); // same 1-indexed spelling as the master-page name, for the second page const automaticStyles = findChildElement( stylesRoot.children, @@ -233,6 +234,58 @@ describe("writeOdgContent: page geometry", () => { ); expect(attrValue(properties, "style:print-orientation")).toBe("portrait"); }); + + it("writes style:print-orientation=landscape for a page wider than it is tall", () => { + const pkg = writeOdgContent(documentOf([page([rect()])])); // PAGE_SIZE_LANDSCAPE by default: 720x540, width > height + const stylesRoot = partRoot(pkg, "styles.xml"); + const automaticStyles = findChildElement( + stylesRoot.children, + "office:automatic-styles", + ); + if (automaticStyles === undefined) { + throw new Error("expected styles.xml office:automatic-styles"); + } + const pageLayout = childrenWithTag( + automaticStyles, + "style:page-layout", + )[0]!; + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]!; + expect(attrValue(properties, "style:print-orientation")).toBe("landscape"); + }); + + it("writes style:print-orientation=portrait, not landscape, for a perfectly square page", () => { + // A strict width > height comparison, not >=: a square page's width and height are equal, so a >= mutant would wrongly call this landscape. + const pkg = writeOdgContent( + documentOf([page([rect()], [], { widthPt: 400, heightPt: 400 })]), + ); + const stylesRoot = partRoot(pkg, "styles.xml"); + const automaticStyles = findChildElement( + stylesRoot.children, + "office:automatic-styles", + ); + if (automaticStyles === undefined) { + throw new Error("expected styles.xml office:automatic-styles"); + } + const pageLayout = childrenWithTag( + automaticStyles, + "style:page-layout", + )[0]!; + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]!; + expect(attrValue(properties, "style:print-orientation")).toBe("portrait"); + }); + + it("stamps a custom version option directly onto the manifest from writeOdgContent alone", () => { + const pkg = writeOdgContent(documentOf([page([rect()])]), { + version: "1.4", + }); + expect(readManifest(pkg).version).toBe("1.4"); + }); }); describe("writeOdgContent: vector elements", () => { diff --git a/packages/odf.js/src/typed/odg/write.ts b/packages/odf.js/src/typed/odg/write.ts index a40c1163c..d6efd9ef7 100644 --- a/packages/odf.js/src/typed/odg/write.ts +++ b/packages/odf.js/src/typed/odg/write.ts @@ -140,9 +140,8 @@ export function writeOdgContent( version, ); - const registry = StyleRegistry.forPart(pkg, CONTENT_PART, { - otherPart: { pkg, partPath: STYLES_PART }, - }); + // No otherPart cross-check against styles.xml: createOdfPackage above just built this exact package from scratch, so styles.xml's own office:automatic-styles is always freshly empty at this point -- there is no pre-existing style:style anywhere in it for a scan to find, since nothing (this call included) has written to styles.xml yet. + const registry = StyleRegistry.forPart(pkg, CONTENT_PART); const contentAutomaticStyles = odfPartContainer( pkg, CONTENT_PART, diff --git a/packages/odf.js/src/typed/odm/read.test.ts b/packages/odf.js/src/typed/odm/read.test.ts index 05324d4ff..48f79893e 100644 --- a/packages/odf.js/src/typed/odm/read.test.ts +++ b/packages/odf.js/src/typed/odm/read.test.ts @@ -88,6 +88,38 @@ describe("readOdm: scope boundaries and error paths (synthetic packages)", () => expect(readOdm(pkg).sections).toEqual([]); }); + it("ignores a non-element child and a differently-tagged element among office:text's children, rather than trying to read them as sections", () => { + const realSection = el("text:section", { "text:name": "ChapterOne" }, [ + el("text:section-source", { "xlink:href": "chapter1.odt" }), + ]); + // A decoy carrying a genuine text:section-source child, deliberately shaped so readSection would succeed on it if this element's own tag check were ever skipped -- an ordinary tagless decoy (like the stray text:p below) can't tell "skipped by tag" apart from "reached readSection, which itself found nothing to read". + const decoy = el("text:p", { "text:name": "Decoy" }, [ + el("text:section-source", { "xlink:href": "not-a-real-chapter.odt" }), + ]); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [ + el("office:text", {}, [ + txt("\n "), + decoy, + el("text:p", {}, [txt("stray paragraph")]), + realSection, + ]), + ]), + ]), + ], + }, + }, + }; + expect(readOdm(pkg).sections).toEqual([ + { name: "ChapterOne", href: "chapter1.odt" }, + ]); + }); + it("skips a top-level text:section with no text:section-source child -- ODF's generic, non-master-document section (e.g. multi-column layout), not a chapter reference", () => { const plainSection = el( "text:section", diff --git a/packages/odf.js/src/typed/odm/write.test.ts b/packages/odf.js/src/typed/odm/write.test.ts index 895a182f2..cec5ecb67 100644 --- a/packages/odf.js/src/typed/odm/write.test.ts +++ b/packages/odf.js/src/typed/odm/write.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; -import { validateManifest } from "../../manifest"; +import { validateManifest, readManifest } from "../../manifest"; import { rootElement, findChildElement, @@ -45,6 +45,28 @@ describe("writeOdm", () => { expect(validateManifest(pkg)).toEqual([]); }); + it("defaults to the current ODF version on both content.xml and the manifest when no version option is given", () => { + const pkg = writeOdm(twoChapterDocument()); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect(attrValue(root!, "office:version")).toBe("1.3"); + expect(readManifest(pkg).version).toBe("1.3"); + }); + + it("honours an explicit ODF version on content.xml and the manifest alike", () => { + const pkg = writeOdm(twoChapterDocument(), { version: "1.2" }); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect(attrValue(root!, "office:version")).toBe("1.2"); + expect(readManifest(pkg).version).toBe("1.2"); + }); + it("writes each chapter as a top-level text:section carrying a self-closing text:section-source with href and filter name only", () => { const pkg = writeOdm(twoChapterDocument()); const root = rootElement( diff --git a/packages/odf.js/src/typed/odp/read.test.ts b/packages/odf.js/src/typed/odp/read.test.ts index 514122d99..e05d99873 100644 --- a/packages/odf.js/src/typed/odp/read.test.ts +++ b/packages/odf.js/src/typed/odp/read.test.ts @@ -369,6 +369,15 @@ describe("readOdpContent", () => { expect(slides).toHaveLength(2); }); + it("omits the 'source' key entirely from a slide with no residue, rather than carrying it set to undefined", () => { + const { slides } = readOdpContent(buildFixturePackage()); + expect(Object.hasOwn(slides[0]!, "source")).toBe(false); + }); + + it("omits the package-level 'source' key entirely from readOdp when nothing was quarantined", () => { + expect(Object.hasOwn(readOdp(buildFixturePackage()), "source")).toBe(false); + }); + it("resolves slide size from the master-page -> page-layout chain (draw:master-page-name -> style:master-page -> style:page-layout-name -> style:page-layout-properties)", () => { const { slides } = readOdpContent(buildFixturePackage()); expect(slides[0]?.size).toEqual({ widthPt: 720, heightPt: 540 }); @@ -611,6 +620,37 @@ describe("readOdpContent: residue rows", () => { expect(slides[0]?.source).toBeUndefined(); }); + it("finds no drawing-page style at all when the slide carries no draw:style-name, even if a nameless drawing-page style happens to exist", () => { + // The automatic style below carries a "drawing-page" family but no style:name attribute at all, so attrValue(style, "style:name") itself resolves to undefined -- coincidentally equal to an undefined draw:style-name -- if findDrawingPageProperties didn't short-circuit before ever reaching the style walk. + const automaticStyles = el("office:automatic-styles", {}, [ + el("style:style", { "style:family": "drawing-page" }, [ + el("style:drawing-page-properties", { + "presentation:transition-type": "automatic", + }), + ]), + ]); + const page = el("draw:page", { "draw:master-page-name": "Default" }, []); + const { slides } = readOdpContent(slidePackage(page, {}, automaticStyles)); + expect(slides[0]?.source).toBeUndefined(); + }); + + it("skips a same-named style whose family is not drawing-page", () => { + const automaticStyles = el("office:automatic-styles", {}, [ + el("style:style", { "style:name": "dp1", "style:family": "paragraph" }, [ + el("style:drawing-page-properties", { + "presentation:transition-type": "automatic", + }), + ]), + ]); + const page = el( + "draw:page", + { "draw:master-page-name": "Default", "draw:style-name": "dp1" }, + [], + ); + const { slides } = readOdpContent(slidePackage(page, {}, automaticStyles)); + expect(slides[0]?.source).toBeUndefined(); + }); + it("quarantines the REAL transitions.odp fixture's Impress-written smil transitions on their own slides", () => { const { slides } = readOdpContent(loadFixture("transitions.odp")); expect(slides).toHaveLength(8); diff --git a/packages/odf.js/src/typed/ods/conditional-format.test.ts b/packages/odf.js/src/typed/ods/conditional-format.test.ts index d877ee3f1..522b6c227 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.test.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; import { el } from "../../xml/fragment"; +import type { + ContentSheetConditionalFormat, + SheetRuleOperator, +} from "document-schema.js"; import { + calextDateForTimePeriod, + calextTypeForCfvoType, + formatTargetRangeList, parseConditionValue, readConditionalFormats, readTargetRangeList, + synthesiseConditionValue, } from "./conditional-format"; // calcext:conditional-formats has no OASIS-published grammar at all -- every value/attribute name exercised here is transcribed from LibreOffice's own real reader (sc/source/filter/xml/xmlcondformat.cxx), see conditional-format.ts's own top-of-file note for the exact source functions. A real LibreOffice-produced fixture (fixtures/conditional-format.ods) exists and is exercised in read.test.ts's own "conditional-format.ods (real LibreOffice output)" describe block -- it directly caught a real entity-decoding bug this file's own synthetic cases below could not have found on their own (a producer that escapes '>' as '>' in calcext:value), since a hand-built package only ever contains what its author thought to escape. Every OTHER variant exercised here (colour-scale, data-bar, icon-set, date-is, every condition mode) has no real fixture available, so those packages are hand-built (el/txt) to the identical wire shape xmlcondformat.cxx establishes, matching data-validation.test.ts's own established fallback for a producer-specific mini-language a real fixture wasn't available for. @@ -148,6 +156,22 @@ describe("parseConditionValue", () => { it("returns undefined for a producer-extended or malformed value this reader cannot make sense of", () => { expect(parseConditionValue("some-future-mode(1)")).toBeUndefined(); }); + + it("matches every no-operand keyword by PREFIX, not by suffix -- a trailing operand list still identifies the mode even though the value no longer ENDS with the bare keyword", () => { + const cases: [string, string][] = [ + ["unique(ignored)", "unique"], + ["duplicate(ignored)", "duplicate"], + ["above-equal-average(ignored)", "above-equal-average"], + ["below-equal-average(ignored)", "below-equal-average"], + ["above-average(ignored)", "above-average"], + ["below-average(ignored)", "below-average"], + ["is-no-error(ignored)", "is-no-error"], + ["is-error(ignored)", "is-error"], + ]; + for (const [value, mode] of cases) { + expect(parseConditionValue(value)?.mode).toBe(mode); + } + }); }); describe("readTargetRangeList", () => { @@ -171,93 +195,826 @@ describe("readTargetRangeList", () => { { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, ]); }); -}); -function conditionalFormatsPackage(table: XmlElement): Package { - return { - parts: { - "content.xml": { - kind: "xml", - nodes: [ - el("office:document-content", {}, [ - el("office:body", {}, [el("office:spreadsheet", {}, [table])]), - ]), - ], - }, - }, - }; -} + it("skips a genuinely empty entry produced by a run of consecutive separators, rather than treating it as malformed", () => { + expect( + readTargetRangeList("Sheet1.A1:Sheet1.A1 Sheet1.B1:Sheet1.B1"), + ).toEqual([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("skips an entry with no ':' separator at all, rather than misreading it as a single-cell range", () => { + expect(readTargetRangeList("Sheet1.A1 Sheet1.B1:Sheet1.B1")).toEqual([ + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("skips a colon-less entry even when both halves a naive split would produce happen to look like valid cell references on their own (A11 read as 'A1' + '1', not as a real range)", () => { + expect(readTargetRangeList("A11 Sheet1.B1:Sheet1.B1")).toEqual([ + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("parses a range with no sheet-name prefix at all, not just the prefixed form", () => { + expect(readTargetRangeList("A1:C3")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }, + ]); + }); +}); + +function conditionalFormatsPackage(table: XmlElement): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; +} + +function tableWith(...children: readonly XmlElement[]): XmlElement { + return el("table:table", { "table:name": "Sheet1" }, [...children]); +} + +describe("readConditionalFormats (synthetic packages, real calcext wire shapes)", () => { + it("promotes a between condition into a cellIs rule with its resolved style", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.A1:Sheet1.A10", + }, + [ + el("calcext:condition", { + "calcext:value": "between(1,10)", + "calcext:apply-style-name": "Good", + }), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + operator: "between", + formula1: "1", + formula2: "10", + }, + ]); + }); + + it("promotes a not-between condition with the notBetween operator, not just between", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:condition", { "calcext:value": "not-between(1,10)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + operator: "notBetween", + formula1: "1", + formula2: "10", + }, + ]); + }); + + it('decodes an XML-escaped comparison operator (a real LibreOffice producer writes calcext:value=">3" as literally >3 on disk -- typed/ods/fixtures/conditional-format.ods\'s own content.xml, confirmed directly)', () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": ">3" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "greaterThan", + formula1: "3", + }, + ]); + }); + + it("resolves apply-style-name through the table-cell style cascade into textColor/background", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", + }, + [ + el("calcext:condition", { + "calcext:value": ">100", + "calcext:apply-style-name": "Warn", + }), + ], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el( + "style:style", + { "style:name": "Warn", "style:family": "table-cell" }, + [ + el("style:table-cell-properties", { + "fo:background-color": "#FFCC00", + }), + el("style:text-properties", { "fo:color": "#CC0000" }), + ], + ), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + operator: "greaterThan", + formula1: "100", + style: { + textColor: { r: 0.8, g: 0, b: 0 }, + background: { r: 1, g: 0.8, b: 0 }, + }, + }, + ]); + }); + + function conditionWithStyle( + styleName: string, + styleProperties: readonly XmlElement[], + ): { pkg: Package; table: XmlElement } { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [ + el("calcext:condition", { + "calcext:value": "unique", + "calcext:apply-style-name": styleName, + }), + ], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el( + "style:style", + { "style:name": "Warn", "style:family": "table-cell" }, + [...styleProperties], + ), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + return { pkg, table }; + } + + it("carries only textColor when the resolved style sets no background at all", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + style: { textColor: { r: 0.8, g: 0, b: 0 } }, + }); + }); + + it("carries only background when the resolved style sets no text color at all", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:table-cell-properties", { "fo:background-color": "#FFCC00" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + style: { background: { r: 1, g: 0.8, b: 0 } }, + }); + }); + + it("omits style entirely when the referenced style resolves to no chain at all (an apply-style-name naming a style that was never declared)", () => { + const { pkg, table } = conditionWithStyle("DoesNotExist", [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely when the resolved chain sets neither a background nor a text color", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:table-cell-properties", { "fo:border": "0.5pt solid #000000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely when the condition carries no calcext:apply-style-name attribute at all", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely for a condition with no apply-style-name, even when the document declares a real table-cell family default-style that would otherwise supply one", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el("style:default-style", { "style:family": "table-cell" }, [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("promotes unique/duplicate, top10, aboveAverage, and containsErrors variants", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.A1:Sheet1.A1", + }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", + }, + [el("calcext:condition", { "calcext:value": "top-percent(10)" })], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.C1:Sheet1.C1", + }, + [ + el("calcext:condition", { + "calcext:value": "above-equal-average", + }), + ], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.D1:Sheet1.D1", + }, + [el("calcext:condition", { "calcext:value": "is-error" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + rank: 10, + percent: true, + }, + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], + aboveAverage: true, + equalAverage: true, + }, + { + type: "containsErrors", + ranges: [{ startRow: 0, startColumn: 3, endRow: 0, endColumn: 3 }], + }, + ]); + }); + + it("promotes notContainsErrors (is-no-error), a plain (non-percent) top-elements rank with neither percent nor bottom set, and a below-average rank with both flags false", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "is-no-error" })], + ), + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.B1:Sheet1.B1" }, + [el("calcext:condition", { "calcext:value": "top-elements(5)" })], + ), + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.C1:Sheet1.C1" }, + [el("calcext:condition", { "calcext:value": "below-average" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "notContainsErrors", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + rank: 5, + }, + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], + aboveAverage: false, + equalAverage: false, + }, + ]); + }); + + it("promotes a bottom-percent rank with both bottom and percent set", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "bottom-percent(20)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 20, + percent: true, + bottom: true, + }, + ]); + }); + + it("promotes a plain bottom-elements rank with bottom set but percent absent", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "bottom-elements(3)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 3, + bottom: true, + }, + ]); + }); + + it("promotes a plain above-average rule with both flags true/false, not just the equal-average variant", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "above-average" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + aboveAverage: true, + equalAverage: false, + }, + ]); + }); + + it("falls back to residue for a top-elements rank that is zero, negative, or not a number at all", () => { + for (const value of [ + "top-elements(0)", + "top-elements(-1)", + "top-elements(not-a-number)", + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("falls back to residue for a between condition missing its second operand", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "between(1)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("falls back to residue for a top-elements/bottom-elements/top-percent/bottom-percent condition with no operand at all (no parenthesised rank to extract)", () => { + for (const value of [ + "top-elements", + "bottom-elements", + "top-percent", + "bottom-percent", + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("promotes a duplicate-values rule read back from calcext:condition, not just parsed in isolation", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "duplicate" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "duplicateValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + ]); + }); + + it("promotes every text-matching mode into its own rule type, carrying the matched text verbatim", () => { + const cases: [string, string][] = [ + ["begins-with(foo)", "beginsWith"], + ["ends-with(foo)", "endsWith"], + ["contains-text(foo)", "containsText"], + ["not-contains-text(foo)", "notContainsText"], + ]; + for (const [value, type] of cases) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type, + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + text: "foo", + }, + ]); + } + }); + + it("falls back to residue for a colour-scale with too few (one) or too many (four) entries", () => { + for (const entries of [ + [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + ], + [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + el("calcext:color-scale-entry", { + "calcext:type": "percentile", + "calcext:value": "33", + "calcext:color": "#FFFF00", + }), + el("calcext:color-scale-entry", { + "calcext:type": "percentile", + "calcext:value": "67", + "calcext:color": "#FFCC00", + }), + el("calcext:color-scale-entry", { + "calcext:type": "maximum", + "calcext:color": "#00FF00", + }), + ], + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:color-scale", {}, entries)], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("promotes a colour-scale with exactly the minimum of two entries", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el("calcext:color-scale", {}, [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + el("calcext:color-scale-entry", { + "calcext:type": "maximum", + "calcext:color": "#00FF00", + }), + ]), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ + { + type: "colorScale", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + stops: [ + { value: { type: "min" }, color: { r: 1, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 0, g: 1, b: 0 } }, + ], + }, + ]); + }); + + it("falls back to residue for an icon-set with no formatting-entry thresholds at all", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:icon-set", { "calcext:icon-set-type": "3Arrows" }, [])], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); -function tableWith(...children: readonly XmlElement[]): XmlElement { - return el("table:table", { "table:name": "Sheet1" }, [...children]); -} + it("reads a data-bar's thresholds from the alternate calcext:data-bar-entry tag, not just calcext:formatting-entry", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el("calcext:data-bar", { "calcext:positive-color": "#638EC6" }, [ + el("calcext:data-bar-entry", { "calcext:type": "minimum" }), + el("calcext:data-bar-entry", { "calcext:type": "maximum" }), + ]), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { + r: 0.38823529411764707, + g: 0.5568627450980392, + b: 0.7764705882352941, + }, + }, + ]); + }); -describe("readConditionalFormats (synthetic packages, real calcext wire shapes)", () => { - it("promotes a between condition into a cellIs rule with its resolved style", () => { + it("promotes an icon-set with a real calcext:show-value flag, distinguishing it from a data-bar that declares none at all", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.A1:Sheet1.A10", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, [ - el("calcext:condition", { - "calcext:value": "between(1,10)", - "calcext:apply-style-name": "Good", - }), + el( + "calcext:icon-set", + { + "calcext:icon-set-type": "3Arrows", + "calcext:show-value": "false", + }, + [ + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "33", + }), + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "67", + }), + ], + ), ], ), ]), ); const pkg = conditionalFormatsPackage(table); - const { formats, residueElements } = readConditionalFormats(table, pkg); - expect(residueElements).toEqual([]); - expect(formats).toEqual([ + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ { - type: "cellIs", + type: "iconSet", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], - operator: "between", - formula1: "1", - formula2: "10", + iconSetType: "3Arrows", + thresholds: [ + { type: "percent", value: "33" }, + { type: "percent", value: "67" }, + ], + showValue: false, }, ]); }); - it('decodes an XML-escaped comparison operator (a real LibreOffice producer writes calcext:value=">3" as literally >3 on disk -- typed/ods/fixtures/conditional-format.ods\'s own content.xml, confirmed directly)', () => { + it("promotes an icon-set with calcext:show-value='true' as a real true, not just as 'not false'", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, - [el("calcext:condition", { "calcext:value": ">3" })], + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el( + "calcext:icon-set", + { + "calcext:icon-set-type": "3Arrows", + "calcext:show-value": "true", + }, + [ + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "50", + }), + ], + ), + ], ), ]), ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { - type: "cellIs", - ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], - operator: "greaterThan", - formula1: "3", + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + iconSetType: "3Arrows", + thresholds: [{ type: "percent", value: "50" }], + showValue: true, }, ]); }); - it("resolves apply-style-name through the table-cell style cascade into textColor/background", () => { + it("resolves a date-is rule's own calcext:style into a real style, exactly as a condition's apply-style-name does", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, [ - el("calcext:condition", { - "calcext:value": ">100", - "calcext:apply-style-name": "Warn", + el("calcext:date-is", { + "calcext:date": "today", + "calcext:style": "Warn", }), ], ), @@ -273,12 +1030,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" el( "style:style", { "style:name": "Warn", "style:family": "table-cell" }, - [ - el("style:table-cell-properties", { - "fo:background-color": "#FFCC00", - }), - el("style:text-properties", { "fo:color": "#CC0000" }), - ], + [el("style:text-properties", { "fo:color": "#CC0000" })], ), ]), el("office:body", {}, [el("office:spreadsheet", {}, [table])]), @@ -288,80 +1040,53 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" }, }; const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { - type: "cellIs", - ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], - operator: "greaterThan", - formula1: "100", - style: { - textColor: { r: 0.8, g: 0, b: 0 }, - background: { r: 1, g: 0.8, b: 0 }, - }, + type: "timePeriod", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + timePeriod: "today", + style: { textColor: { r: 0.8, g: 0, b: 0 } }, }, ]); }); - it("promotes unique/duplicate, top10, aboveAverage, and containsErrors variants", () => { + it("falls back to residue for a wrapper whose only rule child is an element tag this reader does not recognise at all", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.A1:Sheet1.A1", - }, - [el("calcext:condition", { "calcext:value": "unique" })], - ), - el( - "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", - }, - [el("calcext:condition", { "calcext:value": "top-percent(10)" })], + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:some-future-rule-kind", {})], ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("silently ignores an unrecognised sibling rule element rather than turning an otherwise-promotable format into residue", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.C1:Sheet1.C1", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, [ - el("calcext:condition", { - "calcext:value": "above-equal-average", - }), + el("calcext:condition", { "calcext:value": "unique" }), + el("calcext:some-future-rule-kind", {}), ], ), - el( - "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.D1:Sheet1.D1", - }, - [el("calcext:condition", { "calcext:value": "is-error" })], - ), ]), ); const pkg = conditionalFormatsPackage(table); - const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ { type: "uniqueValues", ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], }, - { - type: "top10", - ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], - rank: 10, - percent: true, - }, - { - type: "aboveAverage", - ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], - aboveAverage: true, - equalAverage: true, - }, - { - type: "containsErrors", - ranges: [{ startRow: 0, startColumn: 3, endRow: 0, endColumn: 3 }], - }, ]); }); @@ -395,7 +1120,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "colorScale", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -437,7 +1162,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "dataBar", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -478,7 +1203,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "iconSet", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -512,7 +1237,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "timePeriod", ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], @@ -626,3 +1351,234 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" }); }); }); + +describe("formatTargetRangeList", () => { + it("formats a single range as a sheet-prefixed A1:A1 pair", () => { + expect( + formatTargetRangeList( + [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }], + "Sheet1", + ), + ).toBe("Sheet1.A1:Sheet1.C3"); + }); + + it("space-joins several ranges, matching the read side's own separator", () => { + expect( + formatTargetRangeList( + [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }, + ], + "Sheet1", + ), + ).toBe("Sheet1.A1:Sheet1.A1 Sheet1.B2:Sheet1.B2"); + }); +}); + +describe("calextTypeForCfvoType", () => { + it("inverts every real cfvo type this reader promotes", () => { + for (const [cfvoType, calextType] of [ + ["min", "minimum"], + ["max", "maximum"], + ["percentile", "percentile"], + ["percent", "percent"], + ["formula", "formula"], + ] as const) { + expect(calextTypeForCfvoType(cfvoType)).toBe(calextType); + } + }); + + it("returns undefined for 'num', which no calcext type maps onto", () => { + expect(calextTypeForCfvoType("num")).toBeUndefined(); + }); +}); + +describe("calextDateForTimePeriod", () => { + it("inverts every real calcext:date this reader promotes", () => { + for (const [calextDate, timePeriod] of [ + ["today", "today"], + ["yesterday", "yesterday"], + ["tomorrow", "tomorrow"], + ["last-7-days", "last7Days"], + ["this-week", "thisWeek"], + ["last-week", "lastWeek"], + ["next-week", "nextWeek"], + ["this-month", "thisMonth"], + ["last-month", "lastMonth"], + ["next-month", "nextMonth"], + ["this-year", "thisYear"], + ["last-year", "lastYear"], + ["next-year", "nextYear"], + ] as const) { + expect(calextDateForTimePeriod(timePeriod)).toBe(calextDate); + } + }); + + it("throws for a value with no calcext:date spelling, rather than silently emitting nothing", () => { + expect(() => calextDateForTimePeriod("not-a-real-period")).toThrow( + /no calcext:date spelling for 'not-a-real-period'/, + ); + }); +}); + +describe("synthesiseConditionValue", () => { + function cellIs( + operator: SheetRuleOperator, + formula1: string, + formula2?: string, + ): ContentSheetConditionalFormat { + return { + type: "cellIs", + ranges: [], + operator, + formula1, + ...(formula2 === undefined ? {} : { formula2 }), + }; + } + + it("synthesises between/not-between with both operands", () => { + expect(synthesiseConditionValue(cellIs("between", "1", "10"))).toBe( + "between(1,10)", + ); + expect(synthesiseConditionValue(cellIs("notBetween", "1", "10"))).toBe( + "not-between(1,10)", + ); + }); + + it("falls back to a plain comparison when a between/notBetween operator is missing its second operand", () => { + expect(synthesiseConditionValue(cellIs("between", "1"))).toBeUndefined(); + }); + + it("never treats a plain comparison operator as between/notBetween just because a stray formula2 happens to be present", () => { + expect(synthesiseConditionValue(cellIs("equal", "5", "10"))).toBe("=5"); + }); + + it("synthesises every plain comparison operator's own symbol", () => { + const cases: [SheetRuleOperator, string][] = [ + ["equal", "=5"], + ["notEqual", "!=5"], + ["lessThan", "<5"], + ["lessThanOrEqual", "<=5"], + ["greaterThan", ">5"], + ["greaterThanOrEqual", ">=5"], + ]; + for (const [operator, expected] of cases) { + expect(synthesiseConditionValue(cellIs(operator, "5"))).toBe(expected); + } + }); + + it("synthesises the no-operand keywords", () => { + expect(synthesiseConditionValue({ type: "uniqueValues", ranges: [] })).toBe( + "unique", + ); + expect( + synthesiseConditionValue({ type: "duplicateValues", ranges: [] }), + ).toBe("duplicate"); + expect( + synthesiseConditionValue({ type: "containsErrors", ranges: [] }), + ).toBe("is-error"); + expect( + synthesiseConditionValue({ type: "notContainsErrors", ranges: [] }), + ).toBe("is-no-error"); + }); + + it("synthesises every top10 rank/bottom/percent combination", () => { + const cases: [boolean | undefined, boolean | undefined, string][] = [ + [undefined, undefined, "top-elements(5)"], + [undefined, true, "top-percent(5)"], + [true, undefined, "bottom-elements(5)"], + [true, true, "bottom-percent(5)"], + ]; + for (const [bottom, percent, expected] of cases) { + expect( + synthesiseConditionValue({ + type: "top10", + ranges: [], + rank: 5, + ...(bottom === undefined ? {} : { bottom }), + ...(percent === undefined ? {} : { percent }), + }), + ).toBe(expected); + } + }); + + it("synthesises every aboveAverage direction/qualifier combination", () => { + const cases: [boolean, boolean, string][] = [ + [true, false, "above-average"], + [true, true, "above-equal-average"], + [false, false, "below-average"], + [false, true, "below-equal-average"], + ]; + for (const [aboveAverage, equalAverage, expected] of cases) { + expect( + synthesiseConditionValue({ + type: "aboveAverage", + ranges: [], + aboveAverage, + equalAverage, + }), + ).toBe(expected); + } + }); + + it("synthesises every text-matching mode with its own paren-wrapped text", () => { + const cases: [ContentSheetConditionalFormat["type"], string][] = [ + ["beginsWith", "begins-with(foo)"], + ["endsWith", "ends-with(foo)"], + ["containsText", "contains-text(foo)"], + ["notContainsText", "not-contains-text(foo)"], + ]; + for (const [type, expected] of cases) { + expect( + synthesiseConditionValue({ + type, + ranges: [], + text: "foo", + } as ContentSheetConditionalFormat), + ).toBe(expected); + } + }); + + it("returns undefined for containsBlanks/notContainsBlanks, which calcext:condition's own grammar has no spelling for", () => { + expect( + synthesiseConditionValue({ type: "containsBlanks", ranges: [] }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ type: "notContainsBlanks", ranges: [] }), + ).toBeUndefined(); + }); + + it("returns undefined for the rule kinds that are never calcext:condition rules at all", () => { + expect( + synthesiseConditionValue({ + type: "colorScale", + ranges: [], + stops: [], + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "dataBar", + ranges: [], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 0, b: 0 }, + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "iconSet", + ranges: [], + iconSetType: "3Arrows", + thresholds: [], + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "timePeriod", + ranges: [], + timePeriod: "today", + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/ods/conditional-format.ts b/packages/odf.js/src/typed/ods/conditional-format.ts index b9758f577..9a983f65f 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.ts @@ -39,9 +39,7 @@ const CFVO_TYPE_BY_CALCEXT_TYPE: ReadonlyMap = new Map([ export function readTargetRangeList(value: string): ContentSheetRange[] { const ranges: ContentSheetRange[] = []; for (const part of value.split(" ")) { - if (part.length === 0) { - continue; - } + // No separate `part.length === 0` guard: a genuinely empty part (from a run of consecutive spaces) has no ':' to find either, so it already falls through the very next check below -- an explicit length guard here would only ever fire on input the next line already handles identically. const separatorIndex = part.indexOf(":"); if (separatorIndex === -1) { continue; @@ -64,9 +62,9 @@ export function readTargetRangeList(value: string): ContentSheetRange[] { function parseA1WithOptionalSheetPrefix( cellPart: string, ): { column: number; row: number } | undefined { + // No separate "has a sheet prefix at all" branch: when there is no '.', lastIndexOf returns -1, and slice(-1 + 1) = slice(0) returns cellPart unchanged -- exactly what a bare reference needs, with no ternary required to state it. const dotIndex = cellPart.lastIndexOf("."); - const bareReference = - dotIndex === -1 ? cellPart : cellPart.slice(dotIndex + 1); + const bareReference = cellPart.slice(dotIndex + 1); return parseCellReference(bareReference); } @@ -77,10 +75,8 @@ function readConditionalFormatStyle( if (styleName === undefined) { return undefined; } + // No separate "chain resolved to nothing" guard: readCellStyleDecoration and resolveStyle both fold over `elements` and yield undefined background/color for an empty chain exactly as they would for a chain that resolved but carried neither property, so an empty chain already falls through to the "genuinely no styling" check below with the identical result. const { elements } = resolveStyleElementChain(styleName, "table-cell", pkg); - if (elements.length === 0) { - return undefined; - } // ContentSheetConditionalFormatStyleSchema.background is a plain colour (the two properties actually observed on a real dxf, per that schema's own top comment); readCellStyleDecoration's own background is the richer solid/pattern ContentCellFill a regular cell can carry, so only the 'solid' case narrows down to a colour here -- a pattern fill on the referenced style has no representation in this narrower schema and is simply not carried through, matching the schema's own documented scope. const { background: fill } = readCellStyleDecoration(elements); const background = fill?.kind === "solid" ? fill.color : undefined; @@ -308,9 +304,7 @@ function readCondition( case "bottom-elements": case "top-percent": case "bottom-percent": { - if (parsed.expr1 === undefined) { - return undefined; - } + // No separate `parsed.expr1 === undefined` guard: Number(undefined) is NaN, which the Number.isFinite check right below already rejects -- a missing operand and a non-numeric one degrade to the identical "not a valid rank" outcome, so there is nothing this earlier check catches that the next line doesn't already catch on its own. const rank = Number(parsed.expr1); if (!Number.isFinite(rank) || rank <= 0) { return undefined; @@ -710,14 +704,13 @@ export function synthesiseConditionValue( return `contains-text(${format.text})`; case "notContainsText": return `not-contains-text(${format.text})`; + // containsBlanks/notContainsBlanks: calcext:condition's own grammar has no spelling for either (see this function's own top-of-file note). colorScale/dataBar/iconSet/timePeriod: not calcext:condition rules at all -- each is its own child element, built by the writer's own element builders. One shared return rather than two identical ones per group: a duplicate `return undefined` on its own case label is indistinguishable at runtime from falling through into the next label's identical return, so splitting them apart bought no real coverage. case "containsBlanks": case "notContainsBlanks": - return undefined; case "colorScale": case "dataBar": case "iconSet": case "timePeriod": - // Not calcext:condition rules at all -- each is its own child element, built by the writer's own element builders. return undefined; } } diff --git a/packages/odf.js/src/typed/ods/data-validation.test.ts b/packages/odf.js/src/typed/ods/data-validation.test.ts index a8c3da67c..96c94ed8f 100644 --- a/packages/odf.js/src/typed/ods/data-validation.test.ts +++ b/packages/odf.js/src/typed/ods/data-validation.test.ts @@ -4,6 +4,7 @@ import { parseContentValidationCondition, readContentValidationDefinitions, resolveSheetDataValidations, + synthesiseContentValidationCondition, } from "./data-validation"; // table:condition's own grammar is transcribed from LibreOffice's real reader (sc/source/filter/xml/xmlcvali.cxx, XMLConverter.cxx) -- see data-validation.ts's own top-of-file note. Every example here is either lifted verbatim from a real LibreOffice-produced .fods fixture (sc/qa/unit/data/functions/logical/fods/if.fods) or hand-built to the identical grammar that source establishes. @@ -98,6 +99,124 @@ describe("parseContentValidationCondition", () => { it("returns undefined for an empty string", () => { expect(parseContentValidationCondition("")).toBeUndefined(); }); + + it("returns undefined for a bare comparison identifier with no preceding type token (cell-content has no validation of its own)", () => { + expect( + parseContentValidationCondition("of:cell-content()>=1"), + ).toBeUndefined(); + }); + + it("returns undefined for a function0 identifier with no trailing parentheses at all", () => { + expect( + parseContentValidationCondition("of:cell-content-is-whole-number"), + ).toBeUndefined(); + }); + + it("returns undefined for a function0 identifier whose trailing characters aren't the literal '()'", () => { + expect( + parseContentValidationCondition("of:cell-content-is-whole-number(x)"), + ).toBeUndefined(); + }); + + it("falls back to the bare type, with no operator, when the secondary clause's own comparison identifier has no parentheses -- rather than treating two arbitrary non-'()' characters as the missing pair and mis-parsing whatever operator happens to follow them", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-contentAB>=5", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type when the secondary comparison's operator doesn't match any known spelling", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()~1", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type when the secondary comparison's operand is empty", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()>=", + ), + ).toEqual({ type: "whole" }); + }); + + it("trims surrounding whitespace from a comparison operand", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()>= 5", + ), + ).toEqual({ type: "whole", operator: "greaterThanOrEqual", formula1: "5" }); + }); + + it("falls back to the bare type when the token after 'and' is a function1 (an is-true-formula, its own operand carried on formula1) rather than a comparison or function2 -- not just any wrong-kind token, one whose own parsed fields could otherwise leak through", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and is-true-formula(X)", + ), + ).toEqual({ type: "whole" }); + }); + + it("returns undefined for is-true-formula with no parentheses at all", () => { + expect( + parseContentValidationCondition("of:is-true-formula"), + ).toBeUndefined(); + }); + + it("returns undefined for is-true-formula whose character right after the identifier isn't '(' -- rather than treating whatever follows that character as the parenthesised operand", () => { + expect( + parseContentValidationCondition("of:is-true-formulaXFOO)"), + ).toBeUndefined(); + }); + + it("returns undefined for is-true-formula whose parenthesised operand is empty", () => { + expect( + parseContentValidationCondition("of:is-true-formula()"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between with no opening parenthesis", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between1,10)"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between whose first operand is empty", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between(,10)"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between whose second operand is empty", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between(1,)"), + ).toBeUndefined(); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between has no opening parenthesis -- rather than treating the character right after the identifier as consumed and re-parsing whatever comes after it as the two operands", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between1FOO,BAR)", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between's first operand is empty -- rather than accepting the second operand alone", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between(,10)", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between's second operand is empty -- rather than accepting the first operand alone", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between(10,)", + ), + ).toEqual({ type: "whole" }); + }); }); function contentValidationsElement(...validations: ReturnType[]) { @@ -201,6 +320,125 @@ describe("readContentValidationDefinitions", () => { }); }); + it("omits showInputMessage/showErrorMessage when table:display isn't the literal string 'true', and omits promptTitle/errorTitle when table:title is absent", () => { + const definitions = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:help-message", { "table:display": "false" }, []), + el("table:error-message", { "table:display": "false" }, []), + ], + ), + ), + ); + const rule = definitions.get("val1"); + expect(rule?.showInputMessage).toBeUndefined(); + expect(rule?.showErrorMessage).toBeUndefined(); + // Presence, not just value: readContentValidation only ever assigns promptTitle/errorTitle when table:title is actually present, so an absent title must leave the key itself unset -- not merely holding an explicit `undefined` -- which `?.` equality can't tell apart from a genuinely missing key. + expect(Object.hasOwn(rule ?? {}, "promptTitle")).toBe(false); + expect(Object.hasOwn(rule ?? {}, "errorTitle")).toBe(false); + }); + + it("leaves prompt/error unset when the message element has no text:p children at all (an empty body)", () => { + const definitions = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:help-message", { "table:display": "true" }, []), + el("table:error-message", { "table:display": "true" }, []), + ], + ), + ), + ); + const rule = definitions.get("val1"); + expect(rule?.showInputMessage).toBe(true); + expect(rule?.prompt).toBeUndefined(); + expect(rule?.showErrorMessage).toBe(true); + expect(rule?.error).toBeUndefined(); + }); + + it("leaves errorStyle unset when table:message-type is absent or isn't one of the three recognised values", () => { + const noType = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [el("table:error-message", {}, [txt("body")])], + ), + ), + ); + expect(noType.get("val1")?.errorStyle).toBeUndefined(); + + const unrecognisedType = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "critical" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(unrecognisedType.get("val1")?.errorStyle).toBeUndefined(); + }); + + it("recognises 'stop' and 'information' as valid table:message-type values, alongside 'warning' already covered above", () => { + const stop = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "stop" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(stop.get("val1")?.errorStyle).toBe("stop"); + + const information = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "information" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(information.get("val1")?.errorStyle).toBe("information"); + }); + it("returns an empty map when the document declares no table:content-validations at all", () => { const definitions = readContentValidationDefinitions( el("office:spreadsheet", {}, []), @@ -261,3 +499,191 @@ describe("resolveSheetDataValidations", () => { expect(resolveSheetDataValidations(refs, new Map())).toEqual([]); }); }); + +describe("synthesiseContentValidationCondition", () => { + it("writes cell-content-is-in-list for a list rule with a formula", () => { + expect( + synthesiseContentValidationCondition({ + type: "list", + formula1: "$Sheet1.$A$1:$A$10", + }), + ).toBe("of:cell-content-is-in-list($Sheet1.$A$1:$A$10)"); + }); + + it("emits no table:condition for a list rule with no formula", () => { + expect( + synthesiseContentValidationCondition({ type: "list" }), + ).toBeUndefined(); + }); + + it("writes is-true-formula for a custom rule with a formula", () => { + expect( + synthesiseContentValidationCondition({ + type: "custom", + formula1: "ISNUMBER([.A1])", + }), + ).toBe("of:is-true-formula(ISNUMBER([.A1]))"); + }); + + it("emits no table:condition for a custom rule with no formula", () => { + expect( + synthesiseContentValidationCondition({ type: "custom" }), + ).toBeUndefined(); + }); + + it("emits no table:condition for a textLength rule missing an operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + formula1: "5", + }), + ).toBeUndefined(); + }); + + it("emits no table:condition for a textLength rule missing formula1", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "greaterThan", + }), + ).toBeUndefined(); + }); + + it("writes cell-content-text-length-is-between for a textLength 'between' rule with both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "between", + formula1: "5", + formula2: "20", + }), + ).toBe("of:cell-content-text-length-is-between(5,20)"); + }); + + it("writes cell-content-text-length-is-not-between for a textLength 'notBetween' rule with both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "notBetween", + formula1: "5", + formula2: "20", + }), + ).toBe("of:cell-content-text-length-is-not-between(5,20)"); + }); + + it("emits no table:condition for a textLength 'between' rule missing its second formula (neither a between clause nor a plain comparison can be written)", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "between", + formula1: "5", + }), + ).toBeUndefined(); + }); + + it("writes a plain comparison for a textLength rule with a non-between operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "equal", + formula1: "5", + }), + ).toBe("of:cell-content-text-length()=5"); + }); + + it("writes each plain comparison operator spelling for a textLength rule", () => { + const cases: [string, string][] = [ + ["equal", "="], + ["notEqual", "!="], + ["lessThan", "<"], + ["lessThanOrEqual", "<="], + ["greaterThan", ">"], + ["greaterThanOrEqual", ">="], + ]; + for (const [operator, symbol] of cases) { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: operator as never, + formula1: "5", + }), + ).toBe(`of:cell-content-text-length()${symbol}5`); + } + }); + + it("writes the bare function0 identifier for a whole/decimal/date/time rule with no operator", () => { + expect(synthesiseContentValidationCondition({ type: "whole" })).toBe( + "of:cell-content-is-whole-number()", + ); + expect(synthesiseContentValidationCondition({ type: "decimal" })).toBe( + "of:cell-content-is-decimal-number()", + ); + expect(synthesiseContentValidationCondition({ type: "date" })).toBe( + "of:cell-content-is-date()", + ); + expect(synthesiseContentValidationCondition({ type: "time" })).toBe( + "of:cell-content-is-time()", + ); + }); + + it("writes the bare identifier alone when a whole/decimal/date/time rule has an operator but no formula1", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "greaterThan", + }), + ).toBe("of:cell-content-is-whole-number()"); + }); + + it("appends a secondary comparison clause for a whole/decimal/date/time rule with a non-between operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "greaterThanOrEqual", + formula1: "1", + }), + ).toBe("of:cell-content-is-whole-number() and cell-content()>=1"); + }); + + it("appends a secondary between clause for a whole/decimal/date/time rule with a between operator and both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "between", + formula1: "1", + formula2: "10", + }), + ).toBe( + "of:cell-content-is-whole-number() and cell-content-is-between(1,10)", + ); + }); + + it("appends a secondary between clause for a whole/decimal/date/time rule with a notBetween operator and both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "date", + operator: "notBetween", + formula1: "1", + formula2: "10", + }), + ).toBe("of:cell-content-is-date() and cell-content-is-not-between(1,10)"); + }); + + it("writes just the bare identifier for a whole/decimal/date/time rule with a between operator but no second formula (the secondary clause can't be written either way)", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "between", + formula1: "1", + }), + ).toBe("of:cell-content-is-whole-number()"); + }); + + it("emits no table:condition for a type this grammar has no identifier for", () => { + expect( + synthesiseContentValidationCondition({ + type: "bogus" as never, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/ods/data-validation.ts b/packages/odf.js/src/typed/ods/data-validation.ts index 2ff7a7cb4..aa46faa7c 100644 --- a/packages/odf.js/src/typed/ods/data-validation.ts +++ b/packages/odf.js/src/typed/ods/data-validation.ts @@ -78,9 +78,9 @@ interface ParsedToken { // One token of the condition mini-language starting at `start`: an identifier, matched against CONDITION_INFOS, then whatever that identifier's own kind requires immediately after it (a comparison operator and one trailing expression, an empty ()) pair, or one/two parenthesised expressions). Returns undefined on anything this reader cannot make sense of -- a genuinely malformed or producer-extended condition degrades to no validation type/operator rather than a wrong one, mirroring readCellValue's own "an honest 'we don't have one' beats a fabricated value" convention elsewhere in this reader. function parseToken(text: string, start: number): ParsedToken | undefined { - // Skips leading whitespace before matching -- real ODF condition strings have a literal space either side of the 'and' keyword (lclSkipWhitespace's own call sites in XMLConverter.cxx), and this parser's own primary/secondary calls resume exactly where the previous token's endIndex left off, which is never itself past that space. + // Skips leading whitespace before matching -- real ODF condition strings have a literal space either side of the 'and' keyword (lclSkipWhitespace's own call sites in XMLConverter.cxx), and this parser's own primary/secondary calls resume exactly where the previous token's endIndex left off, which is never itself past that space. No separate `searchStart < text.length` bound: `text[searchStart]` for an out-of-range index is `undefined`, which is never `=== " "`, so the character check alone already stops the loop at the end of the string. let searchStart = start; - while (searchStart < text.length && text[searchStart] === " ") { + while (text[searchStart] === " ") { searchStart += 1; } const match = IDENTIFIER_PATTERN.exec(text.slice(searchStart)); @@ -390,21 +390,15 @@ function comparisonClause( : `cell-content()${operatorText}${formula1}`; } +// Both call sites below already narrow `operator` to this pair before calling -- a between/notBetween clause is the only shape either the textLength or the default (whole/decimal/date/time) branch ever asks this helper to build, so the return type carries no undefined case for a third operator this helper is never actually invoked with. function betweenClause( - operator: SheetRuleOperator, + operator: "between" | "notBetween", stem: string, formula1: string, formula2: string, -): string | undefined { - const suffix = - operator === "between" - ? "is-between" - : operator === "notBetween" - ? "is-not-between" - : undefined; - return suffix === undefined - ? undefined - : `${stem}-${suffix}(${formula1},${formula2})`; +): string { + const suffix = operator === "between" ? "is-between" : "is-not-between"; + return `${stem}-${suffix}(${formula1},${formula2})`; } /** The table:condition attribute value for one rule, "of:"-prefixed the way every real producer spells the OpenFormula namespace. Returns undefined when the rule carries no condition this grammar can state at all -- a custom rule with no formula, a list with no list body, or a textLength rule with no comparison -- in which case the writer emits no table:condition attribute, exactly the shape whose absence the read side itself degrades to a bare custom rule. An operator whose operand is missing degrades the same way rather than emitting a clause the read side would reject: the same partial-parse tolerance readContentValidation already shows in the other direction. */ diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index d0da0067e..7b0b7d5b7 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -16,7 +16,24 @@ import { import { readManifest } from "../../manifest"; import { readMimetype } from "../../mimetype"; import { decodeXmlText } from "../../xml/entities"; -import { writeOdsContent } from "./write"; +import { buildXml } from "../../xml/build"; +import { + writeOdsContent, + canonicalColor, + canonicalCellFill, + canonicalRun, + canonicalCellValue, + canonicalCell, + canonicalCells, + canonicalColumns, + canonicalRows, + canonicalSheetImage, + canonicalImages, + canonicalPrintSettings, + canonicalDataValidations, + canonicalConditionalFormatStyle, + canonicalConditionalFormats, +} from "./write"; // The write side's XML-shape suite: what writeOdsContent actually emits, construct by construct -- the sibling suite (write-round-trip.test.ts) proves the output reads back as the document it came from; this one proves the output is the ODF a real consumer expects, which a round trip through this package's own reader cannot (a writer and reader that agreed on the same wrong spelling would round-trip perfectly and open nowhere). This mirrors typed/odt/write.test.ts's own stated split of responsibility. @@ -80,6 +97,27 @@ function firstTable(pkg: Package): XmlElement { return table; } +function contentValidations(pkg: Package): XmlElement { + const body = findChildElement( + partRoot(pkg, "content.xml").children, + "office:body", + ); + const spreadsheet = + body === undefined + ? undefined + : findChildElement(body.children, "office:spreadsheet"); + const container = + spreadsheet === undefined + ? undefined + : findChildElement(spreadsheet.children, "table:content-validations"); + if (container === undefined) { + throw new Error( + "expected office:body/office:spreadsheet/table:content-validations", + ); + } + return container; +} + function contentAutomaticStyles(pkg: Package): XmlElement { const container = findChildElement( partRoot(pkg, "content.xml").children, @@ -102,6 +140,13 @@ function stylesAutomaticStyles(pkg: Package): XmlElement { return container; } +function firstCell(pkg: Package): XmlElement { + return childrenWithTag( + childrenWithTag(firstTable(pkg), "table:table-row")[0]!, + "table:table-cell", + )[0]!; +} + function masterStyles(pkg: Package): XmlElement { const container = findChildElement( partRoot(pkg, "styles.xml").children, @@ -279,148 +324,112 @@ describe("writeOdsContent XML shapes", () => { expect(pkg.parts["Pictures/image1.png"]?.kind).toBe("binary"); }); - describe("the sheet's own master page", () => { - it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { - const pkg = writeOdsContent(documentOf([sheetOf([])])); - const table = firstTable(pkg); - expect(attrValue(table, "style:master-page-name")).toBeUndefined(); - const tableStyleName = attrValue(table, "table:style-name")!; - const tableStyle = childrenWithTag( - contentAutomaticStyles(pkg), - "style:style", - ).find( - (styleElement) => - attrValue(styleElement, "style:name") === tableStyleName, - )!; - expect(attrValue(tableStyle, "style:family")).toBe("table"); - const masterPageName = attrValue(tableStyle, "style:master-page-name"); - expect(masterPageName).toBeDefined(); - - const masterPage = childrenWithTag( - masterStyles(pkg), - "style:master-page", - ).find((element) => attrValue(element, "style:name") === masterPageName)!; - expect(masterPage).toBeDefined(); - const pageLayoutName = attrValue(masterPage, "style:page-layout-name")!; - const pageLayout = childrenWithTag( - stylesAutomaticStyles(pkg), - "style:page-layout", - ).find((element) => attrValue(element, "style:name") === pageLayoutName)!; - expect(pageLayout).toBeDefined(); - }); - - it("gives each sheet its own distinct master page", () => { - const pkg = writeOdsContent( - documentOf([ - sheetOf([], { name: "First" }), - sheetOf([], { name: "Second" }), - ]), - ); - const tables = childrenWithTag( - findChildElement( - findChildElement( - partRoot(pkg, "content.xml").children, - "office:body", - )!.children, - "office:spreadsheet", - )!, - "table:table", - ); - const styleNames = tables.map((table) => - attrValue(table, "table:style-name")!, - ); - expect(new Set(styleNames).size).toBe(2); + it("mints sequential Pictures/imageN.png paths and sequential draw:z-index across multiple images", () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const imageAt = (anchorRow: number) => ({ + kind: "image" as const, + format: "png" as const, + base64: png, + widthPt: 10, + heightPt: 10, + anchorRow, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, }); - }); - - it("writes gridlines/headers as style:print tokens, present only when true", () => { const pkg = writeOdsContent( - documentOf([ - sheetOf([], { - printSettings: { - ...DEFAULT_PRINT_SETTINGS, - gridlines: true, - headers: false, - }, - }), - ]), + documentOf([sheetOf([], { images: [imageAt(0), imageAt(1)] })]), ); - const pageLayout = childrenWithTag( - stylesAutomaticStyles(pkg), - "style:page-layout", - )[0]!; - const properties = childrenWithTag( - pageLayout, - "style:page-layout-properties", - )[0]!; - expect(attrValue(properties, "style:print")).toBe("grid"); + expect(pkg.parts["Pictures/image1.png"]?.kind).toBe("binary"); + expect(pkg.parts["Pictures/image2.png"]?.kind).toBe("binary"); + const frames = childrenWithTag(firstTable(pkg), "table:table-row").flatMap( + (row) => + childrenWithTag(row, "table:table-cell").flatMap((cell) => + childrenWithTag(cell, "draw:frame"), + ), + ); + expect(frames).toHaveLength(2); + const zIndexes = frames + .map((frame) => Number(attrValue(frame, "draw:z-index"))) + .sort((a, b) => a - b); + expect(zIndexes).toStrictEqual([0, 1]); }); - it("writes table:print-ranges with the sheet name qualifying both ends", () => { + it("mints sequential 'Object N' directories across multiple embedded objects", () => { + const embeddedDocOf = (text: string) => ({ + kind: "wordprocessing" as const, + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph" as const, runs: [{ text }] }], + }, + ], + }); const pkg = writeOdsContent( documentOf([ - sheetOf( - [ + sheetOf([], { + embeddedObjects: [ { - row: 0, - column: 0, - value: { kind: "number", value: 1 }, - displayText: "1", + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 0, + anchorColumn: 0, + document: embeddedDocOf("first"), }, - ], - { - name: "Data", - printSettings: { - ...DEFAULT_PRINT_SETTINGS, - printRange: { - startRow: 0, - startColumn: 0, - endRow: 2, - endColumn: 2, - }, + { + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 1, + anchorColumn: 0, + document: embeddedDocOf("second"), }, - }, - ), + ], + }), ]), ); - const table = firstTable(pkg); - expect(attrValue(table, "table:print-ranges")).toBe("Data.A1:Data.C3"); + const objectParts = Object.keys(pkg.parts).filter((path) => + path.startsWith("Object "), + ); + expect(objectParts.some((path) => path.startsWith("Object 1/"))).toBe(true); + expect(objectParts.some((path) => path.startsWith("Object 2/"))).toBe(true); + const frames = childrenWithTag(firstTable(pkg), "table:table-row").flatMap( + (row) => + childrenWithTag(row, "table:table-cell").flatMap((cell) => + childrenWithTag(cell, "draw:frame"), + ), + ); + expect(frames).toHaveLength(2); + const zIndexes = frames + .map((frame) => Number(attrValue(frame, "draw:z-index"))) + .sort((a, b) => a - b); + expect(zIndexes).toStrictEqual([0, 1]); }); - it("wraps repeated header rows/columns in table:table-header-rows/-columns", () => { + it("mints a distinct SheetTableN style name per sheet, not one shared across all of them", () => { const pkg = writeOdsContent( documentOf([ - sheetOf( - [ - { - row: 3, - column: 3, - value: { kind: "number", value: 1 }, - displayText: "1", - }, - ], - { - printSettings: { - ...DEFAULT_PRINT_SETTINGS, - repeatRows: { start: 0, end: 1 }, - repeatColumns: { start: 0, end: 1 }, - }, - }, - ), + sheetOf([], { name: "Sheet1" }), + sheetOf([], { name: "Sheet2" }), + sheetOf([], { name: "Sheet3" }), ]), ); - const table = firstTable(pkg); - expect(childrenWithTag(table, "table:table-header-rows")).toHaveLength(1); - expect(childrenWithTag(table, "table:table-header-columns")).toHaveLength( - 1, + const body = findChildElement( + partRoot(pkg, "content.xml").children, + "office:body", + )!; + const spreadsheet = findChildElement(body.children, "office:spreadsheet")!; + const tables = childrenWithTag(spreadsheet, "table:table"); + expect(tables).toHaveLength(3); + const styleNames = tables.map((table) => + attrValue(table, "table:style-name"), ); - const headerRows = childrenWithTag(table, "table:table-header-rows")[0]!; - expect(childrenWithTag(headerRows, "table:table-row")).toHaveLength(2); + expect(new Set(styleNames).size).toBe(3); }); -}); -describe("writeOdsContent: cell comments (ExaDev/documents.js#949)", () => { - it("writes office:annotation as the cell's first child, with dc:creator before dc:date before its own text:p", () => { + it("writes table:table-column and table:table-row with no table:style-name when the column/row carries no width, height, or manual break", () => { const pkg = writeOdsContent( documentOf([ sheetOf([ @@ -429,119 +438,1005 @@ describe("writeOdsContent: cell comments (ExaDev/documents.js#949)", () => { column: 0, value: { kind: "string", value: "x" }, displayText: "x", - comment: { - text: "A real note", - author: "Alice", - createdAt: "2026-01-02T03:04:05", - }, }, ]), ]), ); - const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; - const cell = childrenWithTag(row, "table:table-cell")[0]!; - expect(cell.children[0]).toMatchObject({ - type: "element", - tag: "office:annotation", - }); - const annotation = findChildElement(cell.children, "office:annotation")!; - expect( - annotation.children.map((child) => child.type === "element" && child.tag), - ).toEqual(["dc:creator", "dc:date", "text:p"]); - expect(findChildElement(annotation.children, "dc:creator")).toMatchObject({ - children: [{ type: "text", value: "Alice" }], - }); - expect(findChildElement(annotation.children, "dc:date")).toMatchObject({ - children: [{ type: "text", value: "2026-01-02T03:04:05" }], - }); - const annotationParagraph = childrenWithTag(annotation, "text:p")[0]!; - expect(annotationParagraph).toMatchObject({ - children: [{ type: "text", value: "A real note" }], - }); + const table = firstTable(pkg); + const column = childrenWithTag(table, "table:table-column")[0]!; + expect(attrValue(column, "table:style-name")).toBeUndefined(); + const row = childrenWithTag(table, "table:table-row")[0]!; + expect(attrValue(row, "table:style-name")).toBeUndefined(); }); - it("writes no office:annotation at all for a cell with no comment", () => { + it("writes table:table-cell with no table:style-name when the cell carries no background, borders, or alignment", () => { const pkg = writeOdsContent( documentOf([ sheetOf([ { row: 0, column: 0, - value: { kind: "string", value: "x" }, - displayText: "x", + value: { kind: "string", value: "plain" }, + displayText: "plain", }, ]), ]), ); - const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; - const cell = childrenWithTag(row, "table:table-cell")[0]!; - expect( - findChildElement(cell.children, "office:annotation"), - ).toBeUndefined(); + const cell = firstCell(pkg); + expect(attrValue(cell, "table:style-name")).toBeUndefined(); }); - it("writes one text:p per '\\n'-separated line of a multi-paragraph comment, with no author/date elements when neither is present", () => { + it("writes a background-only cell's style:table-cell-properties on its own minted style:style", () => { const pkg = writeOdsContent( documentOf([ sheetOf([ { row: 0, column: 0, - value: { kind: "string", value: "x" }, - displayText: "x", - comment: { text: "First line\nSecond line" }, + value: { kind: "string", value: "coloured" }, + displayText: "coloured", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, }, ]), ]), ); - const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; - const cell = childrenWithTag(row, "table:table-cell")[0]!; - const annotation = findChildElement(cell.children, "office:annotation")!; - expect(findChildElement(annotation.children, "dc:creator")).toBeUndefined(); - expect(findChildElement(annotation.children, "dc:date")).toBeUndefined(); - const paragraphs = childrenWithTag(annotation, "text:p"); - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0]).toMatchObject({ - children: [{ type: "text", value: "First line" }], + const cell = firstCell(pkg); + const styleName = attrValue(cell, "table:style-name")!; + expect(styleName).toBeDefined(); + const cellStyle = childrenWithTag( + contentAutomaticStyles(pkg), + "style:style", + ).find( + (styleElement) => attrValue(styleElement, "style:name") === styleName, + )!; + expect(attrValue(cellStyle, "style:family")).toBe("table-cell"); + const properties = childrenWithTag( + cellStyle, + "style:table-cell-properties", + )[0]!; + expect(attrValue(properties, "fo:background-color")).toBe("#ff0000"); + }); + + describe("used range: each independent source extends its own axis, never the other", () => { + it("a column past the last cell extends table:table-column but not table:table-row", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { columns: [{ index: 3, hidden: false }] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(0); }); - expect(paragraphs[1]).toMatchObject({ - children: [{ type: "text", value: "Second line" }], + + it("a row past the last cell extends table:table-row but not table:table-column", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { rows: [{ index: 2, hidden: false }] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(3); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(0); }); - }); -}); -describe("writeOdsContent: data validation and conditional formatting", () => { - it("declares the calcext namespace the conditional-format elements need, on the part root", () => { - const pkg = writeOdsContent(documentOf([sheetOf([])])); - const root = partRoot(pkg, "content.xml"); - expect(attrValue(root, "xmlns:calcext")).toBe( - "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0", - ); - }); + it("an image past the last cell extends both axes to its own anchor position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + images: [ + { + kind: "image", + format: "png", + base64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + widthPt: 30, + heightPt: 20, + anchorRow: 4, + anchorColumn: 2, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(5); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(3); + }); - it("writes one document-wide table:content-validation before the tables, with a LibreOffice-shaped table:condition", () => { - const pkg = writeOdsContent( - documentOf([ - sheetOf([], { - dataValidations: [ - { - ranges: [ - { startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }, - ], - type: "whole", - operator: "greaterThanOrEqual", - formula1: "1", - allowBlank: false, - showErrorMessage: true, - errorStyle: "warning", - error: "Not whole", - }, - ], - }), - ]), - ); - const spreadsheet = findChildElement( - findChildElement(partRoot(pkg, "content.xml").children, "office:body")! + it("an embedded object past the last cell extends both axes to its own anchor position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + embeddedObjects: [ + { + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 3, + anchorColumn: 1, + document: { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], + }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(4); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(2); + }); + + it("an embedded object with no anchorRow/anchorColumn defaults to position (0,0), not undefined-driven NaN cells", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + embeddedObjects: [ + { + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + document: { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], + }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(1); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(1); + }); + + it("a data-validation rule's range past the last cell extends the grid, unlike a conditional-format rule's range", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 6, endColumn: 4 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(7); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(5); + }); + + it("printSettings.repeatColumns/repeatRows each extend only their own axis (wrapped in their own table:table-header-columns/-rows)", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + repeatColumns: { start: 0, end: 2 }, + repeatRows: { start: 0, end: 5 }, + }, + }), + ]), + ); + const table = firstTable(pkg); + // repeatColumns/repeatRows wrap every column/row 0..end inside their own table:table-header-columns/-rows element (ODF's own repeated-header spelling), rather than leaving them as direct table:table children -- see wrapHeaderRange. + const columnHeader = childrenWithTag( + table, + "table:table-header-columns", + )[0]!; + const rowHeader = childrenWithTag(table, "table:table-header-rows")[0]!; + expect(childrenWithTag(columnHeader, "table:table-column")).toHaveLength( + 3, + ); + expect(childrenWithTag(rowHeader, "table:table-row")).toHaveLength(6); + }); + + it("printSettings.printRange extends both axes to its own end position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 8, + endColumn: 3, + }, + }, + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(9); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + }); + + it("printSettings.manualBreaks rows/columns each extend only their own axis", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + manualBreaks: { rows: [7], columns: [3] }, + }, + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(8); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + }); + }); + + describe("data validation messages", () => { + it("writes no table:help-message/table:error-message and no table:allow-empty-cell for a bare rule with no message fields and allowBlank left absent", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(attrValue(rule, "table:allow-empty-cell")).toBeUndefined(); + expect(childrenWithTag(rule, "table:help-message")).toHaveLength(0); + expect(childrenWithTag(rule, "table:error-message")).toHaveLength(0); + }); + + it("writes table:allow-empty-cell=false only when allowBlank is explicitly false", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + allowBlank: false, + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(attrValue(rule, "table:allow-empty-cell")).toBe("false"); + }); + + it("writes table:help-message and table:error-message with display/title/body only when the corresponding fields are actually set", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + promptTitle: "Pick one", + prompt: "Choose a value", + showErrorMessage: true, + errorTitle: "Invalid", + error: "That value is not allowed", + errorStyle: "warning", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + expect(attrValue(help, "table:display")).toBe("true"); + expect(attrValue(help, "table:title")).toBe("Pick one"); + expect(childrenWithTag(help, "text:p")[0]!.children[0]).toMatchObject({ + type: "text", + value: "Choose a value", + }); + const error = childrenWithTag(rule, "table:error-message")[0]!; + expect(attrValue(error, "table:display")).toBe("true"); + expect(attrValue(error, "table:title")).toBe("Invalid"); + expect(attrValue(error, "table:message-type")).toBe("warning"); + expect(childrenWithTag(error, "text:p")[0]!.children[0]).toMatchObject({ + type: "text", + value: "That value is not allowed", + }); + }); + + it("omits table:display when a message's title/body exist but its own show flag was never set", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + promptTitle: "Pick one", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + expect(attrValue(help, "table:display")).toBeUndefined(); + expect(attrValue(help, "table:title")).toBe("Pick one"); + }); + + it("interns a rule with showInputMessage/showErrorMessage true as a definition distinct from an otherwise-identical rule with them left unset", () => { + const rangeAt = (row: number) => ({ + startRow: row, + startColumn: 0, + endRow: row, + endColumn: 0, + }); + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [rangeAt(0)], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + }, + { + ranges: [rangeAt(1)], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [rangeAt(2)], + type: "list", + formula1: '"x,y,z"', + showErrorMessage: true, + }, + { + ranges: [rangeAt(3)], + type: "list", + formula1: '"x,y,z"', + }, + ], + }), + ]), + ); + const rows = childrenWithTag(firstTable(pkg), "table:table-row"); + const nameOfRow = (row: number) => { + const cell = childrenWithTag(rows[row]!, "table:table-cell")[0]!; + return attrValue(cell, "table:content-validation-name"); + }; + expect(nameOfRow(0)).not.toBe(nameOfRow(1)); + expect(nameOfRow(2)).not.toBe(nameOfRow(3)); + }); + + it("interns two rules with identical written content to the SAME name, not a fresh one each time", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + expect( + childrenWithTag(contentValidations(pkg), "table:content-validation"), + ).toHaveLength(1); + const rows = childrenWithTag(firstTable(pkg), "table:table-row"); + const nameOf = (row: number) => + attrValue( + childrenWithTag(rows[row]!, "table:table-cell")[0]!, + "table:content-validation-name", + ); + expect(nameOf(0)).toBe(nameOf(1)); + }); + + it("mints names in first-encounter order across three genuinely distinct rules: val1, val2, val3", () => { + const rangeAt = (row: number) => ({ + startRow: row, + startColumn: 0, + endRow: row, + endColumn: 0, + }); + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { ranges: [rangeAt(0)], type: "list", formula1: '"a,b,c"' }, + { ranges: [rangeAt(1)], type: "list", formula1: '"x,y,z"' }, + { ranges: [rangeAt(2)], type: "list", formula1: '"p,q,r"' }, + ], + }), + ]), + ); + const names = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + ).map((rule) => attrValue(rule, "table:name")); + expect(names).toEqual(["val1", "val2", "val3"]); + }); + + it("treats allowBlank left unset and allowBlank explicitly true as the same written content, deduping to one definition", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + allowBlank: true, + }, + ], + }), + ]), + ); + expect( + childrenWithTag(contentValidations(pkg), "table:content-validation"), + ).toHaveLength(1); + }); + + it("writes no table:help-message/table:error-message element at all when display, title, and body are all absent", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(childrenWithTag(rule, "table:help-message")).toHaveLength(0); + expect(childrenWithTag(rule, "table:error-message")).toHaveLength(0); + }); + + it("splits a multi-line help/error message body into one text:p per line", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + prompt: "Line one\nLine two", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + const paragraphs = childrenWithTag(help, "text:p"); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]!.children[0]).toMatchObject({ + type: "text", + value: "Line one", + }); + expect(paragraphs[1]!.children[0]).toMatchObject({ + type: "text", + value: "Line two", + }); + }); + }); + + describe("unsupportedConditionalFormatReason refusals", () => { + const rangeOnly = [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ]; + + it("refuses containsBlanks and notContainsBlanks by name", () => { + for (const type of ["containsBlanks", "notContainsBlanks"] as const) { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [{ type, ranges: rangeOnly }], + }), + ]), + ), + ).toThrow(/no spelling for/); + } + }); + + it("refuses a rule carrying a priority", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "containsErrors", + ranges: rangeOnly, + priority: 1, + }, + ], + }), + ]), + ), + ).toThrow(/priority/); + }); + + it("refuses a rule carrying stopIfTrue", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "containsErrors", + ranges: rangeOnly, + stopIfTrue: true, + }, + ], + }), + ]), + ), + ).toThrow(/stopIfTrue/); + }); + + it("does NOT refuse a rule with priority/stopIfTrue left unset", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { type: "containsErrors", ranges: rangeOnly }, + ], + }), + ]), + ), + ).not.toThrow(); + }); + + it("refuses an aboveAverage rule carrying a stdDev count", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "aboveAverage", + ranges: rangeOnly, + stdDev: 2, + }, + ], + }), + ]), + ), + ).toThrow(/standard-deviation/); + }); + + it("does not refuse an aboveAverage rule with no stdDev", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [{ type: "aboveAverage", ranges: rangeOnly }], + }), + ]), + ), + ).not.toThrow(); + }); + + it("refuses a reversed iconSet but not a non-reversed one", () => { + const iconSet = (reverse?: boolean) => ({ + type: "iconSet" as const, + ranges: rangeOnly, + iconSetType: "3TrafficLights1", + thresholds: [ + { type: "percent" as const, value: "33" }, + { type: "percent" as const, value: "67" }, + ], + reverse, + }); + expect(() => + writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [iconSet(true)] })]), + ), + ).toThrow(/reversed icon set/); + expect(() => + writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [iconSet(false)] })]), + ), + ).not.toThrow(); + }); + + it("refuses a threshold of the unsupported 'num' cfvo type", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges: rangeOnly, + min: { type: "num", value: "0" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ), + ).toThrow(/'num' threshold/); + }); + + it("does not refuse a dataBar whose min/max are both supported cfvo types", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges: rangeOnly, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ), + ).not.toThrow(); + }); + }); + + describe("the sheet's own master page", () => { + it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { + const pkg = writeOdsContent(documentOf([sheetOf([])])); + const table = firstTable(pkg); + expect(attrValue(table, "style:master-page-name")).toBeUndefined(); + const tableStyleName = attrValue(table, "table:style-name")!; + const tableStyle = childrenWithTag( + contentAutomaticStyles(pkg), + "style:style", + ).find( + (styleElement) => + attrValue(styleElement, "style:name") === tableStyleName, + )!; + expect(attrValue(tableStyle, "style:family")).toBe("table"); + const masterPageName = attrValue(tableStyle, "style:master-page-name"); + expect(masterPageName).toBeDefined(); + + const masterPage = childrenWithTag( + masterStyles(pkg), + "style:master-page", + ).find((element) => attrValue(element, "style:name") === masterPageName)!; + expect(masterPage).toBeDefined(); + const pageLayoutName = attrValue(masterPage, "style:page-layout-name")!; + const pageLayout = childrenWithTag( + stylesAutomaticStyles(pkg), + "style:page-layout", + ).find((element) => attrValue(element, "style:name") === pageLayoutName)!; + expect(pageLayout).toBeDefined(); + }); + + it("gives each sheet its own distinct master page", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { name: "First" }), + sheetOf([], { name: "Second" }), + ]), + ); + const tables = childrenWithTag( + findChildElement( + findChildElement( + partRoot(pkg, "content.xml").children, + "office:body", + )!.children, + "office:spreadsheet", + )!, + "table:table", + ); + const styleNames = tables.map((table) => + attrValue(table, "table:style-name")!, + ); + expect(new Set(styleNames).size).toBe(2); + }); + }); + + it("writes gridlines/headers as style:print tokens, present only when true", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + gridlines: true, + headers: false, + }, + }), + ]), + ); + const pageLayout = childrenWithTag( + stylesAutomaticStyles(pkg), + "style:page-layout", + )[0]!; + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]!; + expect(attrValue(properties, "style:print")).toBe("grid"); + }); + + it("writes table:print-ranges with the sheet name qualifying both ends", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf( + [ + { + row: 0, + column: 0, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + name: "Data", + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 2, + endColumn: 2, + }, + }, + }, + ), + ]), + ); + const table = firstTable(pkg); + expect(attrValue(table, "table:print-ranges")).toBe("Data.A1:Data.C3"); + }); + + it("wraps repeated header rows/columns in table:table-header-rows/-columns", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf( + [ + { + row: 3, + column: 3, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 1 }, + }, + }, + ), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-header-rows")).toHaveLength(1); + expect(childrenWithTag(table, "table:table-header-columns")).toHaveLength( + 1, + ); + const headerRows = childrenWithTag(table, "table:table-header-rows")[0]!; + expect(childrenWithTag(headerRows, "table:table-row")).toHaveLength(2); + }); +}); + +describe("writeOdsContent: cell comments (ExaDev/documents.js#949)", () => { + it("writes office:annotation as the cell's first child, with dc:creator before dc:date before its own text:p", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { + text: "A real note", + author: "Alice", + createdAt: "2026-01-02T03:04:05", + }, + }, + ]), + ]), + ); + const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; + const cell = childrenWithTag(row, "table:table-cell")[0]!; + expect(cell.children[0]).toMatchObject({ + type: "element", + tag: "office:annotation", + }); + const annotation = findChildElement(cell.children, "office:annotation")!; + expect( + annotation.children.map((child) => child.type === "element" && child.tag), + ).toEqual(["dc:creator", "dc:date", "text:p"]); + expect(findChildElement(annotation.children, "dc:creator")).toMatchObject({ + children: [{ type: "text", value: "Alice" }], + }); + expect(findChildElement(annotation.children, "dc:date")).toMatchObject({ + children: [{ type: "text", value: "2026-01-02T03:04:05" }], + }); + const annotationParagraph = childrenWithTag(annotation, "text:p")[0]!; + expect(annotationParagraph).toMatchObject({ + children: [{ type: "text", value: "A real note" }], + }); + }); + + it("writes no office:annotation at all for a cell with no comment", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]), + ]), + ); + const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; + const cell = childrenWithTag(row, "table:table-cell")[0]!; + expect( + findChildElement(cell.children, "office:annotation"), + ).toBeUndefined(); + }); + + it("writes one text:p per '\\n'-separated line of a multi-paragraph comment, with no author/date elements when neither is present", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "First line\nSecond line" }, + }, + ]), + ]), + ); + const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; + const cell = childrenWithTag(row, "table:table-cell")[0]!; + const annotation = findChildElement(cell.children, "office:annotation")!; + expect(findChildElement(annotation.children, "dc:creator")).toBeUndefined(); + expect(findChildElement(annotation.children, "dc:date")).toBeUndefined(); + const paragraphs = childrenWithTag(annotation, "text:p"); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toMatchObject({ + children: [{ type: "text", value: "First line" }], + }); + expect(paragraphs[1]).toMatchObject({ + children: [{ type: "text", value: "Second line" }], + }); + }); +}); + +describe("writeOdsContent: data validation and conditional formatting", () => { + it("declares the calcext namespace the conditional-format elements need, on the part root", () => { + const pkg = writeOdsContent(documentOf([sheetOf([])])); + const root = partRoot(pkg, "content.xml"); + expect(attrValue(root, "xmlns:calcext")).toBe( + "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0", + ); + }); + + it("writes one document-wide table:content-validation before the tables, with a LibreOffice-shaped table:condition", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }, + ], + type: "whole", + operator: "greaterThanOrEqual", + formula1: "1", + allowBlank: false, + showErrorMessage: true, + errorStyle: "warning", + error: "Not whole", + }, + ], + }), + ]), + ); + const spreadsheet = findChildElement( + findChildElement(partRoot(pkg, "content.xml").children, "office:body")! .children, "office:spreadsheet", )!; @@ -573,93 +1468,987 @@ describe("writeOdsContent: data validation and conditional formatting", () => { ); }); - it("stamps every in-range cell with table:content-validation-name, including content-less ones", () => { - const pkg = writeOdsContent( - documentOf([ - sheetOf( - [ + it("stamps every in-range cell with table:content-validation-name, including content-less ones", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf( + [ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + displayText: "5", + }, + ], + { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }, + ], + type: "list", + formula1: '"a";"b"', + }, + ], + }, + ), + ]), + ); + const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; + const cells = childrenWithTag(row, "table:table-cell"); + expect(cells).toHaveLength(2); + expect(attrValue(cells[0]!, "table:content-validation-name")).toBe("val1"); + expect(attrValue(cells[1]!, "table:content-validation-name")).toBe("val1"); + expect(attrValue(cells[1]!, "office:value-type")).toBeUndefined(); + }); + + it("writes one calcext:conditional-format wrapper per distinct range list with calcext:condition, colour-scale, data-bar, icon-set, and date-is children", () => { + const ranges = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "cellIs", + ranges, + operator: "between", + formula1: "1", + formula2: "10", + }, + { + type: "timePeriod", + ranges, + timePeriod: "last7Days", + }, + { + type: "dataBar", + ranges, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 99 / 255, g: 190 / 255, b: 123 / 255 }, + showValue: false, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + const wrapper = childrenWithTag(table, "calcext:conditional-formats")[0]!; + const formats = childrenWithTag(wrapper, "calcext:conditional-format"); + expect(formats).toHaveLength(1); + expect(attrValue(formats[0]!, "calcext:target-range-address")).toBe( + "Sheet1.A1:Sheet1.A1", + ); + const ruleChildren = formats[0]!.children.filter( + (child): child is XmlElement => child.type === "element", + ); + const [condition, dateIs, dataBar] = ruleChildren; + expect(condition!.tag).toBe("calcext:condition"); + expect(attrValue(condition!, "calcext:value")).toBe("between(1,10)"); + expect(attrValue(condition!, "calcext:base-cell-address")).toBe( + "Sheet1.A1", + ); + expect(dateIs!.tag).toBe("calcext:date-is"); + expect(attrValue(dateIs!, "calcext:date")).toBe("last-7-days"); + expect(dataBar!.tag).toBe("calcext:data-bar"); + expect(attrValue(dataBar!, "calcext:positive-color")).toBe("#63be7b"); + expect(attrValue(dataBar!, "calcext:show-value")).toBe("false"); + expect(childrenWithTag(dataBar!, "calcext:formatting-entry")).toHaveLength( + 2, + ); + }); + + it("omits calcext:show-value on a dataBar rule that never set showValue at all", () => { + const ranges = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + const wrapper = childrenWithTag(table, "calcext:conditional-formats")[0]!; + const dataBar = childrenWithTag( + childrenWithTag(wrapper, "calcext:conditional-format")[0]!, + "calcext:data-bar", + )[0]!; + expect(attrValue(dataBar, "calcext:show-value")).toBeUndefined(); + }); + + it("writes no calcext:conditional-formats element when conditionalFormats is an empty (not undefined) array", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "calcext:conditional-formats")).toHaveLength( + 0, + ); + }); +}); + +describe("writeOdsContent: a cell's own runs -- bare newline vs. formatted line-break", () => { + // A run that is EXACTLY {text: "\n"} with every formatting field absent is the shape readCellText's own multi-text:p join synthesises, so the writer must split it into a new text:p rather than emitting it as a text:line-break. Each of the eight cases below carries the identical "\n" text but sets exactly one formatting field, which must all keep it inside a single text:p (as a text:line-break within a formatted text:span), never splitting a second paragraph -- this is the exhaustive boundary the writer's isBareNewlineRun predicate checks. + it("splits a bare '\\n' run (no formatting fields at all) into two text:p elements", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "a\nb" }, + displayText: "a\nb", + runs: [{ text: "a" }, { text: "\n" }, { text: "b" }], + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(2); + expect(buildXml([paragraphs[0]!])).toBe("a"); + expect(buildXml([paragraphs[1]!])).toBe("b"); + }); + + it.each([ + ["bold", { bold: true }], + ["italic", { italic: true }], + ["underline", { underline: true }], + ["strike", { strike: true }], + ["fontFamily", { fontFamily: "Arial" }], + ["sizePt", { sizePt: 14 }], + ["color", { color: { r: 1, g: 0, b: 0 } }], + ["hyperlink", { hyperlink: "https://example.com" }], + ] as const)( + "keeps a '\\n' run carrying only %s inside a single text:p as a text:line-break, not a paragraph split", + (_label, field) => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "a\nb" }, + displayText: "a\nb", + runs: [{ text: "a" }, { text: "\n", ...field }, { text: "b" }], + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + const lineBreaks = paragraphs[0]!.children.filter( + (child): child is XmlElement => + child.type === "element" && + (child.tag === "text:line-break" || + childrenWithTag(child, "text:line-break").length > 0), + ); + expect(lineBreaks.length).toBeGreaterThan(0); + }, + ); +}); + +describe("writeOdsContent: cellSourceRuns fallback", () => { + it("falls back to a single plain run of displayText when no runs field is present", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "hello" }, + displayText: "hello", + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + expect(buildXml([paragraphs[0]!])).toBe("hello"); + }); + + it("writes one empty text:p (planCellTextGroups' own always-one-group floor) for an empty displayText and no runs field", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + expect(buildXml([paragraphs[0]!])).toBe(""); + }); + + it("writes one text:p (not zero) for an explicit runs field containing only an empty-text run", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + runs: [{ text: "" }], + }, + ]), + ]), + ); + expect(childrenWithTag(firstCell(pkg), "text:p")).toHaveLength(1); + }); +}); + +describe("writeOdsContent: 'time' cell duration formatting boundaries", () => { + it("throws for a value that is not the canonical HH:MM:SS spelling", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "not-a-time" }, + displayText: "not-a-time", + }, + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); + + it("formats a fractional-seconds duration keeping the fraction, not rounding it away", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "01:02:03.456" }, + displayText: "01:02:03.456", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:time-value")).toBe("PT1H2M3.456S"); + }); + + it("rejects a duration with a non-digit fraction (an invalid HH:MM:SS.fraction spelling)", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ { row: 0, column: 0, - value: { kind: "number", value: 5 }, - displayText: "5", + value: { kind: "time", value: "01:02:03.abc" }, + displayText: "01:02:03.abc", }, - ], + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); + + it("rejects a duration missing its seconds component entirely", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "01:02" }, + displayText: "01:02", + }, + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); +}); + +describe("writeOdsContent: boolean and currency cell value boundaries", () => { + it("writes office:boolean-value='true' for a true boolean cell", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ { - dataValidations: [ - { - ranges: [ - { startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }, - ], - type: "list", - formula1: '"a";"b"', - }, - ], + row: 0, + column: 0, + value: { kind: "boolean", value: true }, + displayText: "TRUE", }, - ), + ]), ]), ); - const row = childrenWithTag(firstTable(pkg), "table:table-row")[0]!; - const cells = childrenWithTag(row, "table:table-cell"); - expect(cells).toHaveLength(2); - expect(attrValue(cells[0]!, "table:content-validation-name")).toBe("val1"); - expect(attrValue(cells[1]!, "table:content-validation-name")).toBe("val1"); - expect(attrValue(cells[1]!, "office:value-type")).toBeUndefined(); + expect(attrValue(firstCell(pkg), "office:boolean-value")).toBe("true"); }); - it("writes one calcext:conditional-format wrapper per distinct range list with calcext:condition, colour-scale, data-bar, icon-set, and date-is children", () => { - const ranges = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + it("writes office:boolean-value='false' for a false boolean cell", () => { const pkg = writeOdsContent( documentOf([ - sheetOf([], { - conditionalFormats: [ - { - type: "cellIs", - ranges, - operator: "between", - formula1: "1", - formula2: "10", - }, - { - type: "timePeriod", - ranges, - timePeriod: "last7Days", - }, - { - type: "dataBar", - ranges, - min: { type: "min" }, - max: { type: "max" }, - color: { r: 99 / 255, g: 190 / 255, b: 123 / 255 }, - showValue: false, + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "boolean", value: false }, + displayText: "FALSE", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:boolean-value")).toBe("false"); + }); + + it("writes no office:currency attribute when a currency cell carries no currency code", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "currency", value: 9.99 }, + displayText: "9.99", + }, + ]), + ]), + ); + const cell = firstCell(pkg); + expect(attrValue(cell, "office:value-type")).toBe("currency"); + expect(attrValue(cell, "office:currency")).toBeUndefined(); + }); + + it("writes office:currency when a currency cell carries a currency code", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "currency", value: 9.99, currency: "GBP" }, + displayText: "9.99", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:currency")).toBe("GBP"); + }); + + it("prefers exactValue's own decimal string over the double when both are present", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { + kind: "number", + value: 0.1 + 0.2, + exactValue: "0.3", }, - ], - }), + displayText: "0.3", + }, + ]), ]), ); - const table = firstTable(pkg); - const wrapper = childrenWithTag(table, "calcext:conditional-formats")[0]!; - const formats = childrenWithTag(wrapper, "calcext:conditional-format"); - expect(formats).toHaveLength(1); - expect(attrValue(formats[0]!, "calcext:target-range-address")).toBe( - "Sheet1.A1:Sheet1.A1", + expect(attrValue(firstCell(pkg), "office:value")).toBe("0.3"); + }); +}); + +// normaliseOdsContent applies every canonical* helper below identically to BOTH sides of a round-trip equality check (write.test.ts / write-round-trip.test.ts's own expectRoundTrip: normalise(actual) vs. normalise(expected)), so a mutation confined to one of these helpers changes both sides in lockstep and is invisible to that comparison. Each is pinned here directly instead, against a literal expected return value. +describe("canonical* helpers: direct unit coverage (see the note above on why)", () => { + it("canonicalColor round-trips a colour through hex unchanged", () => { + expect(canonicalColor({ r: 0.2, g: 0.4, b: 0.6 })).toStrictEqual({ + r: 0.2, + g: 0.4, + b: 0.6, + }); + }); + + it("canonicalCellFill: a solid fill's own colour", () => { + expect( + canonicalCellFill({ kind: "solid", color: { r: 1, g: 0, b: 0 } }), + ).toStrictEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalCellFill: a pattern's foreground colour, when present", () => { + expect( + canonicalCellFill({ + kind: "pattern", + patternType: "mediumGray", + foregroundColor: { r: 1, g: 0, b: 0 }, + backgroundColor: { r: 0, g: 0, b: 1 }, + }), + ).toStrictEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalCellFill: falls back to a pattern's background colour when foreground is absent", () => { + expect( + canonicalCellFill({ + kind: "pattern", + patternType: "mediumGray", + backgroundColor: { r: 0, g: 0, b: 1 }, + }), + ).toStrictEqual({ kind: "solid", color: { r: 0, g: 0, b: 1 } }); + }); + + it("canonicalCellFill: undefined when a pattern states neither colour", () => { + expect( + canonicalCellFill({ kind: "pattern", patternType: "mediumGray" }), + ).toBeUndefined(); + }); + + it("canonicalRun: keeps only the fields actually stated, one at a time", () => { + expect(canonicalRun({ text: "a" })).toStrictEqual({ text: "a" }); + expect(canonicalRun({ text: "a", bold: true })).toStrictEqual({ + text: "a", + bold: true, + }); + expect(canonicalRun({ text: "a", italic: true })).toStrictEqual({ + text: "a", + italic: true, + }); + expect(canonicalRun({ text: "a", underline: true })).toStrictEqual({ + text: "a", + underline: true, + }); + expect(canonicalRun({ text: "a", strike: true })).toStrictEqual({ + text: "a", + strike: true, + }); + expect(canonicalRun({ text: "a", fontFamily: "Arial" })).toStrictEqual({ + text: "a", + fontFamily: "Arial", + }); + expect(canonicalRun({ text: "a", sizePt: 12 })).toStrictEqual({ + text: "a", + sizePt: 12, + }); + expect( + canonicalRun({ text: "a", color: { r: 1, g: 0, b: 0 } }), + ).toStrictEqual({ + text: "a", + color: { r: 1, g: 0, b: 0 }, + }); + expect( + canonicalRun({ text: "a", hyperlink: "https://example.com" }), + ).toStrictEqual({ text: "a", hyperlink: "https://example.com" }); + }); + + it("canonicalCellValue: every value kind", () => { + expect( + canonicalCellValue({ kind: "number", value: 1, exactValue: "1.0" }), + ).toStrictEqual({ kind: "number", value: 1 }); + expect( + canonicalCellValue({ kind: "percentage", value: 0.5 }), + ).toStrictEqual({ + kind: "percentage", + value: 0.5, + }); + expect(canonicalCellValue({ kind: "currency", value: 9.99 })).toStrictEqual( + { + kind: "currency", + value: 9.99, + }, ); - const ruleChildren = formats[0]!.children.filter( - (child): child is XmlElement => child.type === "element", + expect( + canonicalCellValue({ kind: "currency", value: 9.99, currency: "GBP" }), + ).toStrictEqual({ kind: "currency", value: 9.99, currency: "GBP" }); + expect(canonicalCellValue({ kind: "boolean", value: true })).toStrictEqual({ + kind: "boolean", + value: true, + }); + expect(canonicalCellValue({ kind: "boolean", value: false })).toStrictEqual( + { + kind: "boolean", + value: false, + }, ); - const [condition, dateIs, dataBar] = ruleChildren; - expect(condition!.tag).toBe("calcext:condition"); - expect(attrValue(condition!, "calcext:value")).toBe("between(1,10)"); - expect(attrValue(condition!, "calcext:base-cell-address")).toBe( - "Sheet1.A1", + expect( + canonicalCellValue({ kind: "date", value: "2026-01-01" }), + ).toStrictEqual({ + kind: "date", + value: "2026-01-01", + }); + expect( + canonicalCellValue({ kind: "time", value: "01:02:03" }), + ).toStrictEqual({ + kind: "time", + value: "PT1H2M3S", + }); + expect(canonicalCellValue({ kind: "string", value: "hi" })).toStrictEqual({ + kind: "string", + value: "hi", + }); + expect(canonicalCellValue({ kind: "empty" })).toStrictEqual({ + kind: "empty", + }); + }); + + it("canonicalCell: a value-less, formula-less, text-less, comment-less cell vanishes entirely", () => { + expect( + canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + }), + ).toBeUndefined(); + }); + + it("canonicalCell: an otherwise-empty cell survives when it carries a comment", () => { + const cell = canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + comment: { text: "note" }, + }); + expect(cell).toBeDefined(); + expect(cell?.comment).toStrictEqual({ text: "note" }); + }); + + it("canonicalCell: an otherwise-empty cell survives when it carries a formula", () => { + const cell = canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + formula: "=1+1", + }); + expect(cell).toBeDefined(); + expect(cell?.formula).toBe("=1+1"); + }); + + it("canonicalCell: carries every optional field through when present", () => { + const cell = canonicalCell({ + row: 2, + column: 3, + value: { kind: "string", value: "x" }, + displayText: "x", + formula: "=A1", + colSpan: 2, + rowSpan: 3, + background: { kind: "solid", color: { r: 1, g: 1, b: 1 } }, + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }, + alignment: "center", + verticalAlignment: "middle", + comment: { text: "hi" }, + }); + expect(cell).toStrictEqual({ + row: 2, + column: 3, + value: { kind: "string", value: "x" }, + displayText: "x", + runs: [{ text: "x" }], + formula: "=A1", + colSpan: 2, + rowSpan: 3, + background: { kind: "solid", color: { r: 1, g: 1, b: 1 } }, + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }, + alignment: "center", + verticalAlignment: "middle", + comment: { text: "hi" }, + }); + }); + + it("canonicalCell: carries no optional field at all when none are stated, not one set to undefined", () => { + const cell = canonicalCell({ + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }); + expect(cell).toStrictEqual({ + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + runs: [{ text: "x" }], + }); + }); + + it("canonicalCells: returns [] when the sheet's used range is undefined (no cells at all)", () => { + expect( + canonicalCells( + { name: "s", cells: [], columns: [], rows: [], images: [] } as never, + undefined, + undefined, + ), + ).toStrictEqual([]); + }); + + it("canonicalColumns/canonicalRows: hidden is exactly true or undefined, never a bare boolean carrying false", () => { + expect(canonicalColumns({ columns: [] } as never, undefined)).toStrictEqual( + [], ); - expect(dateIs!.tag).toBe("calcext:date-is"); - expect(attrValue(dateIs!, "calcext:date")).toBe("last-7-days"); - expect(dataBar!.tag).toBe("calcext:data-bar"); - expect(attrValue(dataBar!, "calcext:positive-color")).toBe("#63be7b"); - expect(attrValue(dataBar!, "calcext:show-value")).toBe("false"); - expect(childrenWithTag(dataBar!, "calcext:formatting-entry")).toHaveLength( - 2, + const columns = canonicalColumns( + { + columns: [ + { index: 0, hidden: true }, + { index: 1, hidden: false }, + ], + } as never, + 1, + ); + expect(columns[0]?.hidden).toBe(true); + expect(columns[1]?.hidden).toBeUndefined(); + + expect(canonicalRows({ rows: [] } as never, undefined)).toStrictEqual([]); + const rows = canonicalRows( + { + rows: [ + { index: 0, hidden: true }, + { index: 1, hidden: false }, + ], + } as never, + 1, ); + expect(rows[0]?.hidden).toBe(true); + expect(rows[1]?.hidden).toBeUndefined(); + }); + + it("canonicalSheetImage: carries altText only when present", () => { + const base = { + kind: "image" as const, + format: "png" as const, + base64: "AA==", + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }; + expect(canonicalSheetImage(base)).toStrictEqual(base); + expect( + canonicalSheetImage({ ...base, altText: "a picture" }), + ).toStrictEqual({ + ...base, + altText: "a picture", + }); + }); + + it("canonicalImages: reorders into row-major anchor-position order, breaking ties by original index", () => { + const imageAt = (anchorRow: number, anchorColumn: number) => ({ + kind: "image" as const, + format: "png" as const, + base64: "AA==", + widthPt: 10, + heightPt: 10, + anchorRow, + anchorColumn, + offsetXPt: 0, + offsetYPt: 0, + }); + const second = imageAt(0, 5); + const first = imageAt(0, 1); + const third = imageAt(2, 0); + const result = canonicalImages({ + images: [second, third, first], + } as never); + expect(result).toStrictEqual([first, second, third]); + }); + + it("canonicalPrintSettings: carries every optional field only when present", () => { + const required = { + pageSize: PAGE_SIZE_A4, + margins: MARGINS, + gridlines: false, + headers: false, + pageOrder: "downThenOver" as const, + }; + expect(canonicalPrintSettings(required)).toStrictEqual(required); + expect( + canonicalPrintSettings({ + ...required, + printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + scalePercent: 80, + fitToPages: { width: 1, height: 1 }, + repeatRows: { start: 0, end: 0 }, + repeatColumns: { start: 0, end: 0 }, + manualBreaks: { rows: [1], columns: [1] }, + }), + ).toStrictEqual({ + ...required, + printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + scalePercent: 80, + fitToPages: { width: 1, height: 1 }, + repeatRows: { start: 0, end: 0 }, + repeatColumns: { start: 0, end: 0 }, + manualBreaks: { rows: [1], columns: [1] }, + }); + }); + + it("canonicalDataValidations: undefined passes through unchanged", () => { + expect( + canonicalDataValidations({ + cells: [], + dataValidations: undefined, + } as never), + ).toBeUndefined(); + }); + + it("canonicalDataValidations: a list/custom rule with no formula1 degrades to a bare allow-blank custom rule", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "list", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + ], + } as never); + expect(result).toStrictEqual([ + { + type: "custom", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + allowBlank: true, + }, + ]); + }); + + it("canonicalDataValidations: a list rule's operator is always forced to 'equal'", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "list", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + formula1: "A,B,C", + operator: "between", + }, + ], + } as never); + expect(result?.[0]?.operator).toBe("equal"); + }); + + it("canonicalDataValidations: a custom rule never carries an operator", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "custom", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + formula1: "A1>0", + operator: "greaterThan", + }, + ], + } as never); + expect(result?.[0]).not.toHaveProperty("operator"); + }); + + it("canonicalDataValidations: a rule whose every position is covered by a merged cell vanishes", () => { + const result = canonicalDataValidations({ + cells: [{ row: 0, column: 0, colSpan: 2, rowSpan: 1 } as never], + dataValidations: [ + { + type: "whole", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + operator: "greaterThan", + formula1: "0", + }, + ], + } as never); + expect(result).toStrictEqual([]); + }); + + it("canonicalConditionalFormatStyle: textColor wins over background when both are present", () => { + expect( + canonicalConditionalFormatStyle({ + textColor: { r: 1, g: 0, b: 0 }, + background: { r: 0, g: 0, b: 1 }, + }), + ).toStrictEqual({ textColor: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalConditionalFormatStyle: background alone, when textColor is absent", () => { + expect( + canonicalConditionalFormatStyle({ background: { r: 0, g: 0, b: 1 } }), + ).toStrictEqual({ background: { r: 0, g: 0, b: 1 } }); + }); + + it("canonicalConditionalFormatStyle: undefined for undefined input and for a style with neither colour", () => { + expect(canonicalConditionalFormatStyle(undefined)).toBeUndefined(); + expect(canonicalConditionalFormatStyle({})).toBeUndefined(); + }); + + it("canonicalConditionalFormats: undefined passes through unchanged", () => { + expect( + canonicalConditionalFormats({ conditionalFormats: undefined } as never), + ).toBeUndefined(); + }); + + const CF_RANGES = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + + it("canonicalConditionalFormats: 'cellIs' carries formula2/style only when present", () => { + const bare = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "cellIs", + ranges: CF_RANGES, + operator: "greaterThan", + formula1: "0", + }, + ], + } as never); + expect(bare?.[0]).toStrictEqual({ + type: "cellIs", + ranges: CF_RANGES, + operator: "greaterThan", + formula1: "0", + }); + const full = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "cellIs", + ranges: CF_RANGES, + operator: "between", + formula1: "0", + formula2: "10", + style: { textColor: { r: 1, g: 0, b: 0 } }, + }, + ], + } as never); + expect(full?.[0]).toStrictEqual({ + type: "cellIs", + ranges: CF_RANGES, + operator: "between", + formula1: "0", + formula2: "10", + style: { textColor: { r: 1, g: 0, b: 0 } }, + }); + }); + + it("canonicalConditionalFormats: 'top10' carries percent/bottom only when present", () => { + const result = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "top10", + ranges: CF_RANGES, + rank: 10, + percent: true, + bottom: true, + }, + ], + } as never); + expect(result?.[0]).toStrictEqual({ + type: "top10", + ranges: CF_RANGES, + rank: 10, + percent: true, + bottom: true, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "top10", ranges: CF_RANGES, rank: 10 }], + } as never); + expect(bare?.[0]).toStrictEqual({ + type: "top10", + ranges: CF_RANGES, + rank: 10, + }); + }); + + it("canonicalConditionalFormats: 'aboveAverage' carries aboveAverage/equalAverage only when present", () => { + const result = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "aboveAverage", + ranges: CF_RANGES, + aboveAverage: false, + equalAverage: true, + }, + ], + } as never); + expect(result?.[0]).toStrictEqual({ + type: "aboveAverage", + ranges: CF_RANGES, + aboveAverage: false, + equalAverage: true, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "aboveAverage", ranges: CF_RANGES }], + } as never); + expect(bare?.[0]).toStrictEqual({ + type: "aboveAverage", + ranges: CF_RANGES, + }); + }); + + it("canonicalConditionalFormats: 'dataBar'/'iconSet' carry showValue only when present", () => { + const dataBar = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "dataBar", + ranges: CF_RANGES, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 1, b: 0 }, + showValue: false, + }, + ], + } as never); + expect(dataBar?.[0]).toHaveProperty("showValue", false); + const dataBarBare = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "dataBar", + ranges: CF_RANGES, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 1, b: 0 }, + }, + ], + } as never); + expect(dataBarBare?.[0]).not.toHaveProperty("showValue"); + + const iconSet = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "iconSet", + ranges: CF_RANGES, + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + showValue: false, + }, + ], + } as never); + expect(iconSet?.[0]).toHaveProperty("showValue", false); + }); + + it("canonicalConditionalFormats: text-predicate and no-argument rule kinds carry style only when present", () => { + const withStyle = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "containsText", + ranges: CF_RANGES, + text: "x", + style: { background: { r: 1, g: 1, b: 0 } }, + }, + ], + } as never); + expect(withStyle?.[0]).toHaveProperty("style", { + background: { r: 1, g: 1, b: 0 }, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "uniqueValues", ranges: CF_RANGES }], + } as never); + expect(bare?.[0]).not.toHaveProperty("style"); }); }); diff --git a/packages/odf.js/src/typed/ods/write.ts b/packages/odf.js/src/typed/ods/write.ts index da522a036..29748d2b9 100644 --- a/packages/odf.js/src/typed/ods/write.ts +++ b/packages/odf.js/src/typed/ods/write.ts @@ -667,15 +667,12 @@ interface UsedRange { function computeUsedRange(sheet: ContentSheet): UsedRange { let maxRow: number | undefined; let maxColumn: number | undefined; + // Math.max makes a redundant "is this genuinely bigger" comparison unnecessary: setting maxRow/maxColumn to a value no larger than what it already holds is a no-op regardless of whether that comparison used > or >=, so a hand-written comparison here would be an unkillable equivalent mutant rather than a real behavioural choice. const bumpRow = (row: number): void => { - if (maxRow === undefined || row > maxRow) { - maxRow = row; - } + maxRow = maxRow === undefined ? row : Math.max(maxRow, row); }; const bumpColumn = (column: number): void => { - if (maxColumn === undefined || column > maxColumn) { - maxColumn = column; - } + maxColumn = maxColumn === undefined ? column : Math.max(maxColumn, column); }; for (const cell of sheet.cells) { @@ -1000,8 +997,13 @@ function writeRowCells( const cell = cellByPosition.get(key); const images = imagesByPosition.get(key); + const objects = objectsByPosition.get(key); const validationName = validationByPosition.get(key); - if (cell !== undefined || (images !== undefined && images.length > 0)) { + if ( + cell !== undefined || + (images !== undefined && images.length > 0) || + (objects !== undefined && objects.length > 0) + ) { const attributes: Record = {}; if (cell !== undefined) { Object.assign(attributes, writeCellValueAttributes(cell.value)); @@ -1034,7 +1036,7 @@ function writeRowCells( for (const image of images ?? []) { children.push(writeSheetImageFrame(image, state)); } - for (const object of objectsByPosition.get(key) ?? []) { + for (const object of objects ?? []) { children.push(writeSheetEmbeddedObjectFrame(object, state)); } nodes.push(el("table:table-cell", attributes, children)); @@ -1208,12 +1210,15 @@ function writeSheet(sheet: ContentSheet, state: OdsWriteState): XmlElement { // --- the canonical form: what reading this writer's own output back produces ---------------------------------------- -function canonicalColor(color: Color): Color { +// Exported alongside normaliseOdsContent purely for direct unit coverage: normaliseOdsContent applies every canonical* helper below identically to BOTH sides of a round-trip equality check (the actual, real-reader-produced document and the expected, original-document-normalised-the-same-way), so a mutation to one of these helpers alone cannot be observed through that comparison -- it changes both sides in lockstep. Each is therefore also pinned directly, against a literal expected return value, in write.test.ts. +export function canonicalColor(color: Color): Color { return rgbHexToColor(colorToRgbHex(color)); } // A cell fill written and read back through this writer: always a 'solid' ContentCellFill, since fo:background-color has no two-colour pattern-fill vocabulary at all (ExaDev/documents.js#951) -- sheetCellStyle above resolves a 'pattern' fill to resolveCellFillColor's own single representative colour before it ever reaches ODF, and undefined when that resolves to nothing (a pattern stating neither of its own colours), matching an absent background exactly. -function canonicalCellFill(fill: ContentCellFill): ContentCellFill | undefined { +export function canonicalCellFill( + fill: ContentCellFill, +): ContentCellFill | undefined { const color = resolveCellFillColor(fill); return color === undefined ? undefined @@ -1221,7 +1226,7 @@ function canonicalCellFill(fill: ContentCellFill): ContentCellFill | undefined { } // A ContentRun carrying only the fields it actually states -- the same spelled-only canonical form typed/odt/write.ts's own canonicalRun establishes for wordprocessing runs, restated here rather than imported: the two writers are independent codec modules, and this is a small, self-contained defaulting function rather than a shared abstraction worth coupling them over. -function canonicalRun(run: ContentRun): ContentRun { +export function canonicalRun(run: ContentRun): ContentRun { const canonical: ContentRun = { text: run.text }; if (run.bold !== undefined) canonical.bold = run.bold; if (run.italic !== undefined) canonical.italic = run.italic; @@ -1235,7 +1240,7 @@ function canonicalRun(run: ContentRun): ContentRun { } // The exact runs reading this writer's own cell text back produces: each planCellTextGroups group canonicalised through segmentOdfParagraphRuns (the same fixed point typed/shared/paragraph.ts's own writeOdfParagraph/readOdfParagraph pair already establishes for any ODF text:p), rejoined with a bare {text:'\n'} at every group boundary -- exactly the shape readCellText's own synthetic separator produces, regardless of what a same-valued source run originally carried (see isBareNewlineRun's own note on why that asymmetry is unavoidable). -function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { +export function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { const groups = planCellTextGroups(cell).map((group) => segmentOdfParagraphRuns(group).map(canonicalRun), ); @@ -1250,7 +1255,7 @@ function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { } // The exact ContentCellValue reading this writer's own written cell back produces. exactValue never survives -- readCellValue has no field for it, only ever reading office:value back into the nearest-double `value` -- and a 'time' cell reads back as the raw xsd:duration string this writer wrote, per this module's own top-of-file note on that forced, pre-existing asymmetry. -function canonicalCellValue(value: ContentCellValue): ContentCellValue { +export function canonicalCellValue(value: ContentCellValue): ContentCellValue { switch (value.kind) { case "number": return { kind: "number", value: Number(formatCellNumberLiteral(value)) }; @@ -1286,7 +1291,9 @@ function canonicalCellValue(value: ContentCellValue): ContentCellValue { } // One cell's canonical form, or undefined when readOdsContent's own trailing-empty-cell skip drops it entirely: a cell carrying no formula, no office:value-type-bearing value (kind 'empty'), and no rendered text is never materialised by the reader at all, regardless of what colSpan/background/borders it stated -- readTable's own skip test (`!hasValueType && formula === undefined && displayText.length === 0`) runs before any of those attributes are even considered. This is a real, forced normalisation, not a writer choice: any of those facts on such a cell is lost on the round trip because ODF's own trailing-empty-cell compression convention has nowhere else to put them. -function canonicalCell(cell: ContentSheetCell): ContentSheetCell | undefined { +export function canonicalCell( + cell: ContentSheetCell, +): ContentSheetCell | undefined { const runs = canonicalCellRuns(cell); const displayText = runs.map((run) => run.text).join(""); if ( @@ -1345,7 +1352,7 @@ function canonicalCell(cell: ContentSheetCell): ContentSheetCell | undefined { return canonical; } -function canonicalCells( +export function canonicalCells( sheet: ContentSheet, maxRow: number | undefined, maxColumn: number | undefined, @@ -1378,7 +1385,7 @@ function canonicalCells( } // Dense from 0 to maxColumn/maxRow, an undeclared position stamped with readColumnLayout/readRowLayout's own DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT default -- ContentSheetColumn/RowSchema's own "absent widthPt/heightPt means no declared size" cannot be written as a genuinely absent style, since an unstyled table:table-column/-row still resolves to that same reader-side default. A sparse input `columns`/`rows` array is therefore densified on the round trip, one entry per position, exactly as this writer's own dense table:table-column/-row output reads back. -function canonicalColumns( +export function canonicalColumns( sheet: ContentSheet, maxColumn: number | undefined, ): ContentSheetColumn[] { @@ -1400,7 +1407,7 @@ function canonicalColumns( return result; } -function canonicalRows( +export function canonicalRows( sheet: ContentSheet, maxRow: number | undefined, ): ContentSheetRow[] { @@ -1420,7 +1427,9 @@ function canonicalRows( return result; } -function canonicalSheetImage(image: ContentSheetImage): ContentSheetImage { +export function canonicalSheetImage( + image: ContentSheetImage, +): ContentSheetImage { const canonical: ContentSheetImage = { kind: "image", format: image.format, @@ -1439,7 +1448,7 @@ function canonicalSheetImage(image: ContentSheetImage): ContentSheetImage { } // Images read back in row-major anchor-position document order (top-to-bottom, then left-to-right), the order readTable's own cell walk discovers them in -- never the input array's own order, which this writer's per-position placement does not preserve when several images share no ordering relationship across positions. -function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { +export function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { return sheet.images .map((image, originalIndex) => ({ image, originalIndex })) .sort( @@ -1451,7 +1460,7 @@ function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { .map(({ image }) => canonicalSheetImage(image)); } -function canonicalPrintSettings( +export function canonicalPrintSettings( printSettings: ContentSheetPrintSettings, ): ContentSheetPrintSettings { const canonical: ContentSheetPrintSettings = { @@ -1484,7 +1493,7 @@ function canonicalPrintSettings( // What a sheet's dataValidations read back as, per the read side's own established behaviour rather than chosen here: rules sharing one interned definition merge into one rule carrying the union of their ranges; every range expands to one 1x1 range per stamped cell (readOdsContent's own collect step, one entry per referencing cell, never merged); a position covered by another cell's span carries no reference and so drops out; rules order and range order follow the row-major walk order of first reference; allowBlank is always explicit (the reader's own default); the display flags appear only when true (the reader sets them only on table:display="true"); a list rule always reads an operator of "equal" and a custom rule never reads one (data-validation.ts's own CONDITION_INFOS fixed mappings); a list or custom rule with no formula1 has no condition to write and reads back as a bare custom rule; and a rule whose every position sat under a span is referenced by nothing and vanishes. // The merge key for canonicalisation is the rule's WRITTEN content -- see canonicalValidationKey's own note above. -function canonicalDataValidations( +export function canonicalDataValidations( sheet: ContentSheet, ): ContentSheetDataValidation[] | undefined { if (sheet.dataValidations === undefined) { @@ -1558,7 +1567,7 @@ function canonicalDataValidations( } // What one conditional-format style reads back as: the two colour properties that actually round-trip through a minted named style, or no style field at all when neither is present (the read side resolves no style from a style element carrying no colour properties -- a source-only style is indistinguishable from none). -function canonicalConditionalFormatStyle( +export function canonicalConditionalFormatStyle( style: ContentSheetConditionalFormatStyle | undefined, ): | { textColor: Color; background?: never } @@ -1574,7 +1583,7 @@ function canonicalConditionalFormatStyle( } // What a sheet's conditionalFormats read back as: the writer's own emission order preserved (the read side promotes each wrapper's children in document order), each rule's quarantined source dropped, and each style narrowed per canonicalConditionalFormatStyle. The precedence fields never appear here because the writer refuses a rule carrying them before any of this runs. -function canonicalConditionalFormats( +export function canonicalConditionalFormats( sheet: ContentSheet, ): ContentSheetConditionalFormat[] | undefined { if (sheet.conditionalFormats === undefined) { @@ -1682,7 +1691,7 @@ function canonicalConditionalFormats( }); } -function canonicalSheet(sheet: ContentSheet): ContentSheet { +export function canonicalSheet(sheet: ContentSheet): ContentSheet { const { maxRow, maxColumn } = computeUsedRange(sheet); const canonical: ContentSheet = { name: sheet.name, diff --git a/packages/odf.js/src/typed/shared/a1.test.ts b/packages/odf.js/src/typed/shared/a1.test.ts index 2e4086958..0d2cc6011 100644 --- a/packages/odf.js/src/typed/shared/a1.test.ts +++ b/packages/odf.js/src/typed/shared/a1.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it } from "vitest"; -import { columnIndexToLetters, cellReference, TableCursor } from "./a1"; +import { + columnIndexToLetters, + columnLettersToIndex, + cellReference, + TableCursor, +} from "./a1"; + +describe("columnLettersToIndex", () => { + it("converts uppercase column letters back to a 0-based index", () => { + expect(columnLettersToIndex("A")).toBe(0); + expect(columnLettersToIndex("Z")).toBe(25); + expect(columnLettersToIndex("AA")).toBe(26); + }); + + it("returns undefined for input containing anything but uppercase letters", () => { + expect(columnLettersToIndex("a")).toBeUndefined(); + expect(columnLettersToIndex("A1")).toBeUndefined(); + expect(columnLettersToIndex("")).toBeUndefined(); + }); + + it("returns undefined for mixed-case input even though every character is a letter", () => { + // document-schema.js's own columnLettersToIndex uppercases its input before validating, so it alone can't distinguish "aA" or "Aa" from "AA" -- these two cases exist specifically to pin the ^ and $ anchors in this module's own uppercase-only guard, each anchor's removal otherwise lets exactly one of these two strings reach (and be silently accepted by) the schema helper. + expect(columnLettersToIndex("aA")).toBeUndefined(); + expect(columnLettersToIndex("Aa")).toBeUndefined(); + }); +}); describe("columnIndexToLetters", () => { it("converts single-letter columns", () => { @@ -92,13 +117,13 @@ describe("TableCursor", () => { expect(cursor.nextCell()).toBe("A31985"); }); - it("throws for a zero or negative repeat count on either advance method", () => { + it("throws for a zero or negative repeat count on either advance method, naming its own caller in the message", () => { const cursor = new TableCursor(); - expect(() => cursor.nextCell(0)).toThrow(/positive integer/); - expect(() => cursor.nextCell(-1)).toThrow(/positive integer/); + expect(() => cursor.nextCell(0)).toThrow("TableCursor.nextCell:"); + expect(() => cursor.nextCell(-1)).toThrow("TableCursor.nextCell:"); expect(() => { cursor.nextRow(0); - }).toThrow(/positive integer/); + }).toThrow("TableCursor.nextRow:"); }); it("throws for a non-integer repeat count", () => { diff --git a/packages/odf.js/src/typed/shared/border.test.ts b/packages/odf.js/src/typed/shared/border.test.ts new file mode 100644 index 000000000..987eb51a6 --- /dev/null +++ b/packages/odf.js/src/typed/shared/border.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { parseBorderEdge, formatBorderEdge } from "./border"; + +describe("parseBorderEdge", () => { + it("parses a real border's three tokens", () => { + expect(parseBorderEdge("0.05pt solid #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("tolerates surrounding whitespace around the whole value", () => { + expect(parseBorderEdge(" 0.05pt solid #000000 ")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("collapses a run of several spaces between tokens into one separator", () => { + expect(parseBorderEdge("0.05pt solid #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("returns undefined for a token count other than three", () => { + expect(parseBorderEdge("0.05pt solid")).toBeUndefined(); + expect(parseBorderEdge("0.05pt solid #000000 extra")).toBeUndefined(); + }); + + it("treats 'none' or 'hidden' as an explicit no-border marker", () => { + expect(parseBorderEdge("0.05pt none #000000")).toEqual({ none: true }); + expect(parseBorderEdge("0.05pt hidden #000000")).toEqual({ none: true }); + }); + + it("returns undefined for an unparseable length or colour", () => { + expect(parseBorderEdge("notalength solid #000000")).toBeUndefined(); + expect(parseBorderEdge("0.05pt solid notacolor")).toBeUndefined(); + }); + + it("returns undefined for a zero or negative width, a non-border", () => { + expect(parseBorderEdge("0pt solid #000000")).toBeUndefined(); + expect(parseBorderEdge("-0.05pt solid #000000")).toBeUndefined(); + }); + + it("leaves style unset for a style token ODF allows but this schema has no member for", () => { + expect(parseBorderEdge("0.05pt groove #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05 }, + }); + }); +}); + +describe("formatBorderEdge", () => { + it("formats width, style, and colour as the three-token shorthand", () => { + expect( + formatBorderEdge({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 0.05, + style: "dashed", + }), + ).toBe("0.05pt dashed #000000"); + }); + + it("defaults an absent style to 'solid'", () => { + expect( + formatBorderEdge({ color: { r: 0, g: 0, b: 0 }, widthPt: 0.05 }), + ).toBe("0.05pt solid #000000"); + }); +}); diff --git a/packages/odf.js/src/typed/shared/canonicalise.test.ts b/packages/odf.js/src/typed/shared/canonicalise.test.ts new file mode 100644 index 000000000..63249afa4 --- /dev/null +++ b/packages/odf.js/src/typed/shared/canonicalise.test.ts @@ -0,0 +1,610 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentImageBlock, + ContentParagraph, + ContentRun, + ContentTable, + ContentTableCell, + LayoutMetadata, + RunConstructExtent, +} from "document-schema.js"; +import type { ListPlanState } from "./list"; +import { odfBookmarkAnchorDescriptor } from "./constructs"; +import { + canonicalCell, + canonicalImage, + canonicalMetadata, + canonicalParagraph, + canonicalRun, + canonicalTable, +} from "./canonicalise"; + +// This suite pins typed/shared/canonicalise.ts's own paragraph/table/metadata/image helpers directly against a literal expected value. Every odt/odp/odg/draw writer's own round-trip suite applies these SAME functions identically to both sides of its equality check (normalise(actual) vs. normalise(expected)), so a mutation confined to one of these helpers changes both sides in lockstep and is invisible to that comparison -- only a direct, one-sided assertion (as here) can observe it. See canonicalise.ts's own top-of-file note, and ods/write.test.ts's identical "canonical* helpers: direct unit coverage" section for the sibling ODS-specific case this mirrors. + +const RUN: ContentRun = { text: "hello" }; + +function paragraph( + overrides: Partial = {}, +): ContentParagraph { + return { kind: "paragraph", runs: [RUN], ...overrides }; +} + +describe("canonicalRun", () => { + // toStrictEqual throughout this block, not toEqual: toEqual ignores an explicit undefined-valued property, so a mutant that turns "if (run.bold !== undefined)" into "if (true)" -- setting canonical.bold = undefined unconditionally instead of leaving the key absent -- would read as equal to the field-omitted expectation under toEqual and survive unnoticed. toStrictEqual treats an explicit `bold: undefined` key as genuinely different from the key being absent altogether. + it("keeps only text when no other field is stated", () => { + expect(canonicalRun({ text: "plain" })).toStrictEqual({ text: "plain" }); + }); + + it("carries bold through when stated", () => { + expect(canonicalRun({ text: "x", bold: true })).toStrictEqual({ + text: "x", + bold: true, + }); + }); + + it("carries italic through when stated", () => { + expect(canonicalRun({ text: "x", italic: true })).toStrictEqual({ + text: "x", + italic: true, + }); + }); + + it("carries underline through when stated", () => { + expect(canonicalRun({ text: "x", underline: true })).toStrictEqual({ + text: "x", + underline: true, + }); + }); + + it("carries strike through when stated", () => { + expect(canonicalRun({ text: "x", strike: true })).toStrictEqual({ + text: "x", + strike: true, + }); + }); + + it("carries fontFamily through when stated", () => { + expect(canonicalRun({ text: "x", fontFamily: "Arial" })).toStrictEqual({ + text: "x", + fontFamily: "Arial", + }); + }); + + it("carries sizePt through when stated", () => { + expect(canonicalRun({ text: "x", sizePt: 12 })).toStrictEqual({ + text: "x", + sizePt: 12, + }); + }); + + it("quantises color through canonicalColor's own hex-pair round trip when stated", () => { + expect( + canonicalRun({ text: "x", color: { r: 0.9, g: 0, b: 0 } }), + ).toStrictEqual({ + text: "x", + color: { r: 230 / 255, g: 0, b: 0 }, + }); + }); + + it("carries hyperlink through when stated", () => { + expect( + canonicalRun({ text: "x", hyperlink: "https://example.com" }), + ).toStrictEqual({ + text: "x", + hyperlink: "https://example.com", + }); + }); + + it("carries every field at once, none clobbering another", () => { + expect( + canonicalRun({ + text: "x", + bold: true, + italic: true, + underline: true, + strike: true, + fontFamily: "Arial", + sizePt: 12, + color: { r: 0, g: 0, b: 0 }, + hyperlink: "https://example.com", + }), + ).toStrictEqual({ + text: "x", + bold: true, + italic: true, + underline: true, + strike: true, + fontFamily: "Arial", + sizePt: 12, + color: { r: 0, g: 0, b: 0 }, + hyperlink: "https://example.com", + }); + }); +}); + +describe("canonicalParagraph", () => { + it("keeps only the run text when no other field is stated", () => { + expect(canonicalParagraph(paragraph(), undefined)).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + }); + }); + + it("defaults allowConstructs to false when the third argument is omitted", () => { + const withConstruct = paragraph({ + constructs: [ + { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 0, + }, + ], + }); + expect(() => canonicalParagraph(withConstruct, undefined)).toThrow( + /run-level construct extents/, + ); + }); + + it("allowConstructs=false refuses a paragraph carrying a non-empty constructs list", () => { + const withConstruct = paragraph({ + constructs: [ + { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 0, + }, + ], + }); + expect(() => canonicalParagraph(withConstruct, undefined, false)).toThrow( + /a paragraph/, + ); + }); + + it("allowConstructs=false accepts an explicit empty constructs array", () => { + expect( + canonicalParagraph(paragraph({ constructs: [] }), undefined, false), + ).toEqual({ kind: "paragraph", runs: [{ text: "hello" }] }); + }); + + it("allowConstructs=true carries a construct through, remapped onto the canonical run list", () => { + const extent: RunConstructExtent = { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 1, + }; + const result = canonicalParagraph( + paragraph({ constructs: [extent] }), + undefined, + true, + ); + expect(result.constructs).toEqual([ + { descriptor: odfBookmarkAnchorDescriptor("b1"), startRun: 0, endRun: 1 }, + ]); + }); + + it("allowConstructs=true with an explicit empty constructs array carries no constructs field", () => { + const result = canonicalParagraph( + paragraph({ constructs: [] }), + undefined, + true, + ); + expect(result.constructs).toBeUndefined(); + }); + + it("headingLevel derives styleId as Heading", () => { + expect( + canonicalParagraph(paragraph({ headingLevel: 2 }), undefined), + ).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + headingLevel: 2, + styleId: "Heading2", + }); + }); + + it("alignment survives when stated", () => { + expect( + canonicalParagraph(paragraph({ alignment: "center" }), undefined), + ).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + alignment: "center", + }); + }); + + it("preformatted survives when stated, including a literal false", () => { + expect( + canonicalParagraph(paragraph({ preformatted: true }), undefined), + ).toMatchObject({ preformatted: true }); + expect( + canonicalParagraph(paragraph({ preformatted: false }), undefined), + ).toMatchObject({ preformatted: false }); + }); + + it("list membership renumbers onto the given canonical numId", () => { + expect( + canonicalParagraph( + paragraph({ list: { numId: "src-list", level: 3 } }), + "list1", + ), + ).toMatchObject({ list: { numId: "list1", level: 3 } }); + }); + + it("list membership is dropped when the caller supplies no canonical numId", () => { + const result = canonicalParagraph( + paragraph({ list: { numId: "src-list", level: 0 } }), + undefined, + ); + expect(result.list).toBeUndefined(); + }); + + it("list membership is dropped when the paragraph itself carries none, even with a numId supplied", () => { + const result = canonicalParagraph(paragraph(), "list1"); + expect(result.list).toBeUndefined(); + }); + + it("spacingBeforePt/spacingAfterPt/lineSpacing survive when stated", () => { + expect( + canonicalParagraph( + paragraph({ spacingBeforePt: 6, spacingAfterPt: 12, lineSpacing: 1.5 }), + undefined, + ), + ).toMatchObject({ + spacingBeforePt: 6, + spacingAfterPt: 12, + lineSpacing: 1.5, + }); + }); + + it("indentLeftPt/indentFirstLinePt survive when stated", () => { + expect( + canonicalParagraph( + paragraph({ indentLeftPt: 18, indentFirstLinePt: -18 }), + undefined, + ), + ).toMatchObject({ indentLeftPt: 18, indentFirstLinePt: -18 }); + }); + + it("pageBreakBefore/pageBreakAfter survive as literal booleans, including false", () => { + expect( + canonicalParagraph(paragraph({ pageBreakBefore: true }), undefined), + ).toMatchObject({ pageBreakBefore: true }); + expect( + canonicalParagraph(paragraph({ pageBreakBefore: false }), undefined), + ).toMatchObject({ pageBreakBefore: false }); + expect( + canonicalParagraph(paragraph({ pageBreakAfter: true }), undefined), + ).toMatchObject({ pageBreakAfter: true }); + expect( + canonicalParagraph(paragraph({ pageBreakAfter: false }), undefined), + ).toMatchObject({ pageBreakAfter: false }); + }); + + it("a field with no stated value at all is genuinely absent from the result", () => { + const result = canonicalParagraph(paragraph(), undefined); + expect(Object.keys(result)).toEqual(["kind", "runs"]); + }); +}); + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +describe("canonicalCell", () => { + it("a covered cell is always an empty cell, whatever its own placeholder content", () => { + const cell: ContentTableCell = { + blocks: [paragraph()], + colSpan: 3, + rowSpan: 2, + }; + expect(canonicalCell(cell, true, freshListState())).toEqual({ blocks: [] }); + }); + + it("colSpan survives only when the source cell states one", () => { + const withSpan: ContentTableCell = { blocks: [], colSpan: 2 }; + expect(canonicalCell(withSpan, false, freshListState())).toMatchObject({ + colSpan: 2, + }); + const withoutSpan: ContentTableCell = { blocks: [] }; + expect( + canonicalCell(withoutSpan, false, freshListState()), + ).not.toHaveProperty("colSpan"); + }); + + it("rowSpan survives only when the source cell states one", () => { + const withSpan: ContentTableCell = { blocks: [], rowSpan: 4 }; + expect(canonicalCell(withSpan, false, freshListState())).toMatchObject({ + rowSpan: 4, + }); + const withoutSpan: ContentTableCell = { blocks: [] }; + expect( + canonicalCell(withoutSpan, false, freshListState()), + ).not.toHaveProperty("rowSpan"); + }); + + it("background is resolved through canonicalCellFill when stated", () => { + const cell: ContentTableCell = { + blocks: [], + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }; + expect(canonicalCell(cell, false, freshListState())).toMatchObject({ + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }); + }); + + it("refuses a block kind that is neither a paragraph nor a nested table, naming the real kind", () => { + const image: ContentImageBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 10, + heightPt: 10, + }; + const cell: ContentTableCell = { blocks: [image] }; + expect(() => canonicalCell(cell, false, freshListState())).toThrow( + /a "image" block/, + ); + }); + + it("a nested table block recurses through canonicalTable", () => { + const nested: ContentTable = { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }; + const cell: ContentTableCell = { blocks: [nested] }; + const result = canonicalCell(cell, false, freshListState()); + expect(result.blocks).toEqual([ + { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }, + ]); + }); +}); + +describe("canonicalTable", () => { + // Every non-anchor grid position carries its own marker colSpan: 1 -- a field the non-covered path (canonicalCell) always preserves and the covered path (canonicalCell's `if (covered) return { blocks: [] }` branch) always strips, regardless of what the source cell stated. This is what makes "genuinely covered" and "genuinely uncovered but otherwise empty" distinguishable in the result: an uncovered marked cell keeps { blocks: [], colSpan: 1 }, a covered one collapses to bare { blocks: [] }. + function tableWithSpan(colSpan: number, rowSpan: number): ContentTable { + return { + kind: "table", + columnWidthsPt: [10, 10, 10], + rows: [ + { + cells: [ + { blocks: [], colSpan, rowSpan }, + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + ], + }, + { + cells: [ + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + ], + }, + ], + }; + } + + it("columnWidthsPt is copied, not aliased", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [12, 34], + rows: [], + }; + const result = canonicalTable(table, freshListState()); + expect(result.columnWidthsPt).toEqual([12, 34]); + expect(result.columnWidthsPt).not.toBe(table.columnWidthsPt); + }); + + it("a row's own heightPt survives only when stated", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [], + rows: [{ cells: [], heightPt: 20 }, { cells: [] }], + }; + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]).toEqual({ cells: [], heightPt: 20 }); + // toStrictEqual, not toEqual: toEqual ignores an explicit undefined-valued heightPt key, so a mutant that always takes the "state a heightPt" branch (`{ cells, heightPt: row.heightPt }`) for a row with none would set heightPt: undefined and still read as equal to { cells }. + expect(result.rows[1]).toStrictEqual({ cells: [] }); + }); + + it("a cell that is itself covered never marks further cells covered from its own stated span", () => { + // (0,0) spans two columns, covering (0,1). (0,1) is itself covered, but its OWN source cell states colSpan: 3 (as a real document's covered-position placeholder legitimately might) -- that stated span must never be consulted, because the covering pass is skipped entirely once a cell is already known covered. If it were consulted, (0,1)'s own colSpan: 3 would reach through (0,2) and (0,3), incorrectly marking both covered too. + const table: ContentTable = { + kind: "table", + columnWidthsPt: [10, 10, 10, 10], + rows: [ + { + cells: [ + { blocks: [], colSpan: 2 }, + { blocks: [], colSpan: 3 }, + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + ], + }, + ], + }; + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]!.cells[1]).toEqual({ blocks: [] }); + expect(result.rows[0]!.cells[2]).toEqual({ blocks: [], colSpan: 1 }); + expect(result.rows[0]!.cells[3]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("a colSpan=1,rowSpan=1 cell (the default) covers no other grid position at all", () => { + // r !== rowIndex || c !== columnIndex must be false only for the anchor cell itself, so a 1x1 span marks nothing else covered -- every OTHER cell in the table keeps its own marker colSpan: 1, proving it was never forced through the covered branch. + const table = tableWithSpan(1, 1); + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]!.cells[0]).toMatchObject({ colSpan: 1, rowSpan: 1 }); + expect(result.rows[0]!.cells[1]).toEqual({ blocks: [], colSpan: 1 }); + expect(result.rows[1]!.cells[0]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("rowSpan=2 covers exactly one row beyond the anchor, never two (< not <=)", () => { + const table = tableWithSpan(1, 2); + const result = canonicalTable(table, freshListState()); + // The anchor cell (row 0, col 0) keeps its own span fields (not covered). + expect(result.rows[0]!.cells[0]).toMatchObject({ rowSpan: 2 }); + // Row 1, col 0 is covered by the rowSpan=2 anchor -- its own marker colSpan: 1 is stripped by the covered branch, even though the SOURCE cell at that grid position stated one. + expect(result.rows[1]!.cells[0]).toEqual({ blocks: [] }); + // Row 1, col 1 is NOT covered -- the anchor's own colSpan is 1, so its reach into the rows below stays exactly one column wide, never one column further -- so its own marker colSpan: 1 survives untouched. + expect(result.rows[1]!.cells[1]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("colSpan=2 covers exactly one column beyond the anchor, never two (< not <=)", () => { + const table = tableWithSpan(2, 1); + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]!.cells[0]).toMatchObject({ colSpan: 2 }); + // Column 1 of row 0 is covered by the colSpan=2 anchor -- its own marker colSpan: 1 is stripped. + expect(result.rows[0]!.cells[1]).toEqual({ blocks: [] }); + // Column 2 of row 0 is NOT covered -- colSpan=2 reaches only one column beyond the anchor, not two -- so its own marker colSpan: 1 survives untouched. + expect(result.rows[0]!.cells[2]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("each cell is its own list-run scope: two adjacent cells sharing one incoming numId still canonicalise to different numIds", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [10, 10], + rows: [ + { + cells: [ + { + blocks: [ + paragraph({ list: { numId: "shared-source-id", level: 0 } }), + ], + }, + { + blocks: [ + paragraph({ list: { numId: "shared-source-id", level: 0 } }), + ], + }, + ], + }, + ], + }; + const result = canonicalTable(table, freshListState()); + const firstCellParagraph = result.rows[0]!.cells[0]! + .blocks[0] as ContentParagraph; + const secondCellParagraph = result.rows[0]!.cells[1]! + .blocks[0] as ContentParagraph; + expect(firstCellParagraph.list?.numId).toBeDefined(); + expect(secondCellParagraph.list?.numId).toBeDefined(); + expect(secondCellParagraph.list?.numId).not.toEqual( + firstCellParagraph.list?.numId, + ); + }); + + it("closes the list plan after the whole table, so a sibling block after a nested table never inherits its trailing list run", () => { + // Simulates exactly the caller shape canonicalCell's own block map produces: a nested table followed by a sibling paragraph in the SAME enclosing cell, both threaded through one shared listState. The sibling deliberately reuses the SAME raw numId ("shared") the inner table's own last paragraph carried: the earlier version of this test used two distinct numIds ("inner" then "outer"), which mints a fresh run either way and can never distinguish "closed" from "left open" -- only a coinciding incoming numId can, since planListMembership only opens a genuinely new run when the incoming key differs from whatever is currently open. + const listState = freshListState(); + const nested: ContentTable = { + kind: "table", + columnWidthsPt: [10], + rows: [ + { + cells: [ + { blocks: [paragraph({ list: { numId: "shared", level: 0 } })] }, + ], + }, + ], + }; + const nestedResult = canonicalTable(nested, listState); + const nestedNumId = ( + nestedResult.rows[0]!.cells[0]!.blocks[0] as ContentParagraph + ).list?.numId; + // If canonicalTable failed to close the list plan on the way out, listState.openNumId would still read "shared" here -- so this next cell's own paragraph, carrying the identical raw "shared" numId, would be treated as CONTINUING the inner table's own run (planListMembership only mints a fresh canonical numId when the incoming key differs from the one still open) and canonicalise to the SAME numId the table's own paragraph got, despite the two having nothing to do with each other -- exactly the odp text-box "raw numIds happen to coincide" scenario this function's own top-of-file note names as the reason the boundary must be forced. + const after = canonicalCell( + { blocks: [paragraph({ list: { numId: "shared", level: 0 } })] }, + false, + listState, + ); + const afterParagraph = after.blocks[0] as ContentParagraph; + expect(afterParagraph.list?.numId).toBeDefined(); + expect(afterParagraph.list?.numId).not.toBe(nestedNumId); + }); +}); + +describe("canonicalMetadata", () => { + it("every field survives when stated", () => { + const metadata: LayoutMetadata = { + title: "T", + author: "A", + subject: "S", + keywords: ["a", "b"], + creator: "LibreOffice", + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-01-02T00:00:00Z", + }; + expect(canonicalMetadata(metadata)).toEqual(metadata); + }); + + it("every field is genuinely absent, not defaulted, when the source states none", () => { + // toStrictEqual, not toEqual: toEqual ignores an explicit undefined-valued property, so a mutant that turns any single "if (metadata.X !== undefined)" guard into "if (true)" -- setting that one field to undefined unconditionally instead of leaving the key absent -- would still read as equal to {} under toEqual and survive unnoticed for every one of the six fields below. + expect(canonicalMetadata({})).toStrictEqual({}); + }); + + it("an empty keywords array reads back as absent, not as an empty array", () => { + expect(canonicalMetadata({ keywords: [] })).toEqual({}); + }); + + it("keywords is copied, not aliased, when non-empty", () => { + const keywords = ["x"]; + const result = canonicalMetadata({ keywords }); + expect(result.keywords).toEqual(["x"]); + expect(result.keywords).not.toBe(keywords); + }); + + it("each field is independently optional -- one at a time", () => { + expect(canonicalMetadata({ title: "only title" })).toEqual({ + title: "only title", + }); + expect(canonicalMetadata({ author: "only author" })).toEqual({ + author: "only author", + }); + expect(canonicalMetadata({ subject: "only subject" })).toEqual({ + subject: "only subject", + }); + expect(canonicalMetadata({ creator: "only creator" })).toEqual({ + creator: "only creator", + }); + expect(canonicalMetadata({ createdIso: "only created" })).toEqual({ + createdIso: "only created", + }); + expect(canonicalMetadata({ modifiedIso: "only modified" })).toEqual({ + modifiedIso: "only modified", + }); + }); +}); + +describe("canonicalImage", () => { + const base: ContentImageBlock = { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 100, + heightPt: 50, + }; + + it("carries format/base64/size verbatim and drops reader-only fields", () => { + expect( + canonicalImage({ + ...base, + sourcePath: "media/image1.png", + anchorRunIndex: 2, + anchorOffset: 3, + }), + ).toEqual(base); + }); + + it("altText survives only when stated", () => { + expect(canonicalImage({ ...base, altText: "a caption" })).toEqual({ + ...base, + altText: "a caption", + }); + expect(canonicalImage(base)).toEqual(base); + expect(canonicalImage(base)).not.toHaveProperty("altText"); + }); +}); diff --git a/packages/odf.js/src/typed/shared/canonicalise.ts b/packages/odf.js/src/typed/shared/canonicalise.ts index 15b5f0774..0df390a1f 100644 --- a/packages/odf.js/src/typed/shared/canonicalise.ts +++ b/packages/odf.js/src/typed/shared/canonicalise.ts @@ -89,16 +89,15 @@ export function canonicalParagraph( const { canonical: segmentedRuns, boundaryMap } = segmentOdfParagraphRunsMapped( paragraph.runs, + // The false branch needs no boundary set at all: segmentOdfParagraphRunsMapped's own merge loop only ever tests protectedBoundaries.has(index) for index in [0, runs.length) -- index 0 never merges regardless (there is no preceding group yet) and index === runs.length is never reached by that loop -- so a two-element {0, runs.length} set here would carry no member the merge decision ever actually consults. The true branch's own {0, runs.length} pair is equally inert for the same reason; only the construct extents' own interior startRun/endRun values (which DO fall inside that range) do any work, so this is the one boundary source worth constructing. allowConstructs - ? new Set([ - 0, - paragraph.runs.length, - ...(paragraph.constructs ?? []).flatMap((extent) => [ + ? new Set( + (paragraph.constructs ?? []).flatMap((extent) => [ extent.startRun, extent.endRun, ]), - ]) - : new Set([0, paragraph.runs.length]), + ) + : new Set(), ); const canonical: ContentParagraph = { kind: "paragraph", @@ -153,7 +152,7 @@ export function canonicalParagraph( } // A covered grid position is a table:covered-table-cell in ODF, which carries no content, no span and no style of its own -- so whatever an incoming placeholder happened to hold, reading one back yields exactly an empty cell. A cell's own blocks mirror readTableCell's own recursive scope (typed/shared/table.ts): a paragraph's list membership is renumbered onto `listState` exactly as a body-level paragraph's is (planListMembership, threaded by the caller across the whole document so a list minted inside a cell gets as unique an identity as one minted anywhere else), and a nested table recurses back into canonicalTable itself. Any other block kind is refused by name, matching every writer's own fidelity-construct stance. -function canonicalCell( +export function canonicalCell( cell: ContentTableCell, covered: boolean, listState: ListPlanState, @@ -200,13 +199,11 @@ function canonicalCell( return canonical; } -// The one canonical ContentTable a written-and-reread table equals, wherever writeOdfTable places it (odt's own top-level tables, or one nested inside an odp/odg shape's draw:frame) -- every mapping forced by ODF's own table:table content model rather than chosen here, matching typed/shared/table.ts's own writeOdfTable/readOdfTable as the single writer/reader pair every caller shares. -// `listState` is the caller's own document-wide ListPlanState (typed/odt/write.ts's planDocument, typed/odp/write.ts's own presentation-wide state -- see each caller's own top-of-file note), threaded through every cell so a list minted inside this table -- including one nested inside a cell of a table nested inside one of THIS table's own cells -- is numbered in the identical document-encounter order readOdfTable's own listIdState mints it in on the way back in. Closed on entry and after every cell (never merely between rows): each cell is its own list-run scope, exactly as writeCellBlocks' own openList/closeList never persists across a writeCellBlocks call, so two adjacent cells can never canonicalise to the same numId even when both carry an identical incoming one. +// The one canonical ContentTable a written-and-reread table equals, wherever writeOdfTable places it (odt's own top-level tables, or one nested inside an odp/odg shape's draw:frame) -- every mapping forced by ODF's own table:table content model rather than chosen here, matching typed/shared/table.ts's own writeOdfTable/readOdfTable as the single writer/reader pair every caller shares. `listState` is the caller's own document-wide ListPlanState (typed/odt/write.ts's planDocument, typed/odp/write.ts's own presentation-wide state -- see each caller's own top-of-file note), threaded through every cell so a list minted inside this table -- including one nested inside a cell of a table nested inside one of THIS table's own cells -- is numbered in the identical document-encounter order readOdfTable's own listIdState mints it in on the way back in. Closed before every cell's own canonicalCell call (each cell is its own list-run scope, so two adjacent cells can never canonicalise to the same numId even when both carry an identical incoming one) and once more after the whole table, for whatever sibling block follows this table in the caller's own block list -- a close between cells or immediately after canonicalCell's own return would only ever be overwritten by one of those two before anything could observe it, so only these two calls do real work. export function canonicalTable( table: ContentTable, listState: ListPlanState, ): ContentTable { - closeListPlan(listState); const covered = new Set(); const canonical: ContentTable = { kind: "table", @@ -218,18 +215,20 @@ export function canonicalTable( if (!isCovered) { const colSpan = cell.colSpan ?? 1; const rowSpan = cell.rowSpan ?? 1; - for (let r = rowIndex; r < rowIndex + rowSpan; r += 1) { + // The anchor's own position (rowIndex, columnIndex) is never marked covered -- excluded structurally by starting each loop one past it, rather than by a runtime check every OTHER iteration would also have to pay for and a mutation of which is unobservable (the anchor's own key is never looked up again once this cell's own isCovered above has already been read). + // + // Genuinely irreducible equivalent mutant on either "+ 1" start bound below (mutated to "- 1"): row/columnIndex are always the non-negative position a real .map() callback supplies, so a "- 1" start only ever adds two extra covered.add() calls -- one for a fictional negative-index key no real cell position can ever equal, and one for the anchor's own key, already established above as never looked up again. Both are unobservable for any real table, regardless of the anchor's own row/column position, because cells are visited once each in a single left-to-right, top-to-bottom pass and never revisited. + for (let c = columnIndex + 1; c < columnIndex + colSpan; c += 1) { + covered.add(`${rowIndex},${c}`); + } + for (let r = rowIndex + 1; r < rowIndex + rowSpan; r += 1) { for (let c = columnIndex; c < columnIndex + colSpan; c += 1) { - if (r !== rowIndex || c !== columnIndex) { - covered.add(`${r},${c}`); - } + covered.add(`${r},${c}`); } } } closeListPlan(listState); - const canonicalCellValue = canonicalCell(cell, isCovered, listState); - closeListPlan(listState); - return canonicalCellValue; + return canonicalCell(cell, isCovered, listState); }); return row.heightPt === undefined ? { cells } diff --git a/packages/odf.js/src/typed/shared/cascade.test.ts b/packages/odf.js/src/typed/shared/cascade.test.ts index 47ad09c82..8a994f444 100644 --- a/packages/odf.js/src/typed/shared/cascade.test.ts +++ b/packages/odf.js/src/typed/shared/cascade.test.ts @@ -5,6 +5,7 @@ import { el, txt } from "../../xml/fragment"; import { childrenWithTag } from "../../xml/query"; import { findStyleElement, + findNamedStylePartElement, resolveStyle, resolveStyleElementChain, } from "./cascade"; @@ -56,6 +57,57 @@ function paragraphProps(attrs: Record): XmlElement { return el("style:paragraph-properties", attrs); } +describe("collectStyles: element dispatch (via resolveStyle)", () => { + it("ignores an element that is neither style:style nor style:default-style, even one carrying a real style:family attribute", () => { + const decoy = el("style:page-layout", { "style:family": "paragraph" }, [ + textProps({ "fo:font-weight": "bold" }), + ]); + const pkg: Package = { parts: { "styles.xml": stylesPackage([decoy]) } }; + expect(resolveStyle(undefined, "paragraph", pkg)).toEqual({ + properties: {}, + diagnostics: [], + }); + }); +}); + +describe("findNamedStylePartElement", () => { + function drawResource( + tag: string, + name: string, + extra: Record = {}, + ): XmlElement { + return el(tag, { "draw:name": name, ...extra }); + } + + it("finds a real draw resource by (tag, draw:name)", () => { + const gradient = drawResource("draw:gradient", "Gradient 1"); + const pkg: Package = { + parts: { "styles.xml": stylesPackage([gradient]) }, + }; + expect(findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1")).toBe( + gradient, + ); + }); + + it("does not match a same-named resource of a different tag", () => { + const hatch = drawResource("draw:hatch", "Gradient 1"); + const pkg: Package = { parts: { "styles.xml": stylesPackage([hatch]) } }; + expect( + findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1"), + ).toBeUndefined(); + }); + + it("does not match a same-tag resource of a different name", () => { + const gradient = drawResource("draw:gradient", "Gradient 2"); + const pkg: Package = { + parts: { "styles.xml": stylesPackage([gradient]) }, + }; + expect( + findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1"), + ).toBeUndefined(); + }); +}); + describe("resolveStyle: no styleName", () => { it("resolves to an empty bag when there is no default-style and no styleName", () => { const pkg: Package = { parts: {} }; diff --git a/packages/odf.js/src/typed/shared/cascade.ts b/packages/odf.js/src/typed/shared/cascade.ts index 3d567a126..79203ca89 100644 Binary files a/packages/odf.js/src/typed/shared/cascade.ts and b/packages/odf.js/src/typed/shared/cascade.ts differ diff --git a/packages/odf.js/src/typed/shared/constructs.test.ts b/packages/odf.js/src/typed/shared/constructs.test.ts new file mode 100644 index 000000000..a8b7a4dff --- /dev/null +++ b/packages/odf.js/src/typed/shared/constructs.test.ts @@ -0,0 +1,1424 @@ +import { describe, expect, it } from "vitest"; +import type { + ConstructDescriptor, + ContentBlock, + ContentControlDescriptor, + DefinitionEntry, + DivisionDescriptor, + ProvenanceDescriptor, + RunConstructExtent, + SourceResidue, +} from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement, XmlNode } from "../../model/node"; +import { el, txt } from "../../xml/fragment"; +import { + addOdfPackageResidue, + canonicalOdfConstructDescriptor, + collectOdfDataStyleDefinitions, + collectOdfFieldMasterDefinitions, + collectOdfFontFaceDefinitions, + collectOdfNamedExpressions, + collectOdfNonContentPartResidue, + collectOdfProvenanceRegions, + insertOdfConstructMarkers, + isEmbeddedObjectPart, + odfDivisionDescriptor, + odfIndexControlDescriptor, + odfIndexWrapperTag, + odfMarkerHalfEventIndex, + odfRunConstructWriteKind, + pairOdfMarkerHalves, + parseOdfFieldInstruction, + resolveOdfMarkerEvents, + writeOdfAnnotationHalf, + writeOdfChangePoint, + writeOdfDivision, + writeOdfIndexWrapper, + writeOdfPackageResidue, + writeOdfTrackedChanges, + type OdfConstructExtent, + type OdfMarkerEvent, + type OdfMarkerHalf, +} from "./constructs"; + +// Every fixture here is a programmatic package/element built with el/txt, matching the sibling odt/constructs.test.ts's own fixture-gate convention. + +function part(nodes: XmlNode[]): Package { + return { parts: { "settings.xml": { kind: "xml", nodes } } }; +} + +describe("isEmbeddedObjectPart", () => { + it("quarantines an Object-N directory's own part path", () => { + expect(isEmbeddedObjectPart("Object 1/content.xml")).toBe(true); + expect(isEmbeddedObjectPart("Object 12/content.xml")).toBe(true); + }); + it("does not match a path whose first segment merely ends with the Object-N shape", () => { + expect(isEmbeddedObjectPart("XObject 1/content.xml")).toBe(false); + }); + it("does not match a path whose first segment has trailing content after the digits", () => { + expect(isEmbeddedObjectPart("Object 1x/content.xml")).toBe(false); + }); + it("does not match a path with no digits at all", () => { + expect(isEmbeddedObjectPart("Object/content.xml")).toBe(false); + }); +}); + +describe("addOdfPackageResidue", () => { + it("concatenates onto an already-existing key rather than overwriting it", () => { + const out: Record = { + k: { format: "odt", xml: "" }, + }; + addOdfPackageResidue(out, "k", "odt", el("b", {})); + expect(out.k?.xml).toBe(""); + }); +}); + +describe("collectOdfNonContentPartResidue", () => { + it("quarantines nothing for a non-content part whose nodes carry no element at all", () => { + const pkg = part([{ type: "declaration", attributes: [] }]); + const out: Record = {}; + collectOdfNonContentPartResidue(pkg, "odt", out); + expect(out).toEqual({}); + }); + it("quarantines a non-content part carrying a real element", () => { + const pkg = part([el("config:config-item-set", {}, [])]); + const out: Record = {}; + collectOdfNonContentPartResidue(pkg, "odt", out); + expect(out["settings.xml"]?.xml).toContain("config:config-item-set"); + }); +}); + +describe("writeOdfPackageResidue", () => { + const baseline: SourceResidue = { format: "odt", xml: "" }; + + it("does nothing when source is undefined", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", undefined); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry whose residue format does not match the writer's own format", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { + "settings.xml": { format: "ods", xml: "" }, + }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry whose key does not end .xml", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "settings.bin": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry keyed at one of this writer's own consumed part paths", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "content.xml": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry keyed inside an embedded object's own Object-N directory", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "Object 1/content.xml": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("restores an eligible entry as a real xml part with the exact declaration attributes", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "settings.xml": baseline }); + const restored = pkg.parts["settings.xml"]; + if (restored?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const [declaration] = restored.nodes; + if (declaration?.type !== "declaration") { + throw new Error("expected a leading declaration node"); + } + expect(declaration.attributes).toEqual([ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ]); + }); + + it("filters non-element nodes out of the parsed residue body, keeping only the real element", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { + "settings.xml": { format: "odt", xml: "" }, + }); + const restored = pkg.parts["settings.xml"]; + if (restored?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const [, body] = restored.nodes; + expect(body).toEqual({ + type: "element", + tag: "foo", + attributes: [], + children: [], + }); + }); +}); + +function sectionPackage(styles: XmlElement[]): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, styles), + ]), + ], + }, + }, + }; +} + +describe("odfDivisionDescriptor", () => { + it("carries no name property when text:name is absent", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("name"); + }); + + it('reads text:protected="false" as an explicit false, not an absent flag', () => { + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:protected": "false" }), + sectionPackage([]), + ); + expect(descriptor.protected).toBe(false); + }); + + it("carries no protected property when text:protected is absent", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("protected"); + }); + + it("carries no columnCount property when the section carries no resolvable column count", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("resolves a section's own column count only from the exact style matching both family and name", () => { + const styles = [ + // right name, wrong family -- must not match + el( + "style:style", + { "style:family": "paragraph", "style:name": "Sect1" }, + [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "9" }), + ]), + ], + ), + // right family, wrong name -- must not match + el("style:style", { "style:family": "section", "style:name": "Other" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "9" }), + ]), + ]), + // right family and name -- the real match + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "3" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor.columnCount).toBe(3); + }); + + it("treats a zero column count as no fact", () => { + const styles = [ + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "0" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("treats a negative column count as no fact", () => { + const styles = [ + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "-5" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("carries no linked.sectionName when the section-source has no text:section-name", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}, [ + el("text:section-source", { "xlink:href": "chapter.odt" }), + ]), + sectionPackage([]), + ); + expect(descriptor.linked).not.toHaveProperty("sectionName"); + }); +}); + +describe("odfIndexControlDescriptor", () => { + it("carries no tag property when the wrapper has no text:name", () => { + const descriptor = odfIndexControlDescriptor( + el("text:table-of-content", {}), + ); + expect(descriptor).not.toHaveProperty("tag"); + }); +}); + +function paragraphWithSiblings( + siblings: XmlNode[], + halfIndex: number, +): { paragraph: XmlElement; half: XmlElement } { + const half = el("text:bookmark-start", { "text:name": "b" }); + const children = [...siblings]; + children.splice(halfIndex, 0, half); + const paragraph = el("text:p", {}, children); + return { paragraph, half }; +} + +describe("isContentBearingNode (via odfMarkerHalfEventIndex)", () => { + // A half is judged NOT at a paragraph edge (interior) exactly when a genuinely content-bearing sibling precedes it -- so each case below plants exactly one such sibling before the half and checks the half stops qualifying as "leading". + const contentBearingBefore: { label: string; sibling: XmlNode }[] = [ + { label: "a non-empty text node", sibling: txt("hi") }, + { label: "a field element", sibling: el("text:date", {}) }, + { label: "text:s", sibling: el("text:s", {}) }, + { label: "text:tab", sibling: el("text:tab", {}) }, + { label: "text:line-break", sibling: el("text:line-break", {}) }, + { label: "text:span", sibling: el("text:span", {}) }, + { label: "text:a", sibling: el("text:a", {}) }, + { label: "text:note", sibling: el("text:note", {}) }, + { label: "office:annotation", sibling: el("office:annotation", {}) }, + ]; + + it.each(contentBearingBefore)( + "$label preceding the half, with another one trailing it, makes the half genuinely interior", + ({ sibling }) => { + // A trailing text:span (itself always content-bearing) pins the half off the trailing edge too, so a content-bearing leading sibling is the only thing that can still make this interior (neither leading nor trailing). + const { paragraph, half } = paragraphWithSiblings( + [sibling, el("text:span", {})], + 1, + ); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }, + ); + + it("an empty text node preceding the half does not move it off the leading edge", () => { + const { paragraph, half } = paragraphWithSiblings([txt("")], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + + it("a non-content-bearing element (e.g. another bookmark half) preceding the half does not move it off the leading edge", () => { + const decoy = el("text:bookmark-end", { "text:name": "other" }); + const { paragraph, half } = paragraphWithSiblings([decoy], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + + it("a comment node (neither text nor element) preceding the half does not move it off the leading edge", () => { + const comment: XmlNode = { type: "comment", value: "c" }; + const { paragraph, half } = paragraphWithSiblings([comment], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + + it("returns undefined when the half's own recorded parent is not the paragraph passed in, even though the half is a genuine child of that other parent", () => { + // The half's own recorded parent is a real container that DOES hold it as a child (so a bypassed guard would not accidentally bail out on the later indexOf === -1 check instead) -- only the mismatch against the paragraph argument itself should short-circuit this. + const half = el("text:bookmark-start", { "text:name": "b" }); + const other = el("text:p", {}, [half]); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: other, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + const paragraph = el("text:p", {}); + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }); + + it("returns undefined when the half element is not actually among its own recorded parent's children", () => { + const paragraph = el("text:p", {}, [txt("x")]); + const orphan = el("text:bookmark-start", { "text:name": "b" }); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: orphan, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }); +}); + +function half(overrides: Partial): OdfMarkerHalf { + const element = el("text:bookmark-start", {}); + return { + kind: "bookmark", + side: "start", + key: "k", + element, + parent: el("text:p", {}), + runPosition: 0, + order: 0, + descriptor: () => undefined, + ...overrides, + }; +} + +describe("pairOdfMarkerHalves", () => { + const paragraph = el("text:p", {}); + + // Every start/end half below carries a genuinely RESOLVING descriptor -- so if a bypassed length check let the pairing proceed anyway, it would actually build an extent from starts[0]/ends[0], not merely fall through some other guard (an unresolved descriptor) that would mask the very check under test. + const resolvingDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + + it("drops a key with no start half at all", () => { + const end = half({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); + const { extents } = pairOdfMarkerHalves([end], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a key with two start halves", () => { + const startA = half({ + side: "start", + element: el("text:bookmark-start", { id: "a" }), + descriptor: () => resolvingDescriptor, + }); + const startB = half({ + side: "start", + element: el("text:bookmark-start", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const end = half({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); + const { extents } = pairOdfMarkerHalves([startA, startB, end], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a key with two end halves", () => { + const start = half({ + side: "start", + element: el("text:bookmark-start", {}), + descriptor: () => resolvingDescriptor, + }); + const endA = half({ + side: "end", + element: el("text:bookmark-end", { id: "a" }), + descriptor: () => resolvingDescriptor, + }); + const endB = half({ + side: "end", + element: el("text:bookmark-end", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const { extents } = pairOdfMarkerHalves([start, endA, endB], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a pair whose end precedes its start", () => { + const start = half({ + side: "start", + runPosition: 3, + descriptor: () => ({ kind: "anchor", anchorType: "bookmark", name: "k" }), + }); + const end = half({ + side: "end", + runPosition: 1, + element: el("text:bookmark-end", {}), + }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([]); + }); + + it("keeps a pair whose end sits at exactly the same run position as its start (a genuine zero-width range)", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = half({ + side: "start", + runPosition: 2, + descriptor: () => descriptor, + }); + const end = half({ + side: "end", + runPosition: 2, + element: el("text:bookmark-end", {}), + }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([{ descriptor, startRun: 2, endRun: 2 }]); + }); + + it("drops a pair whose descriptor resolves to undefined", () => { + const start = half({ side: "start", descriptor: () => undefined }); + const end = half({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([]); + }); + + it("marks both halves of a completed pair as paired, not only the start", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const start = half({ + side: "start", + element: startElement, + descriptor: () => descriptor, + }); + const end = half({ side: "end", element: endElement }); + const { paired } = pairOdfMarkerHalves([start, end], paragraph); + expect(paired.has(startElement)).toBe(true); + expect(paired.has(endElement)).toBe(true); + }); + + it("drops a pair whose both halves are block-scoped (that is the block-marker path's own extent)", () => { + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const container = el("text:p", {}, [startElement, endElement]); + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = half({ + side: "start", + element: startElement, + parent: container, + runPosition: 0, + descriptor: () => descriptor, + }); + const end = half({ + side: "end", + element: endElement, + parent: container, + runPosition: 0, + }); + const { extents } = pairOdfMarkerHalves([start, end], container); + expect(extents).toEqual([]); + }); +}); + +function event(overrides: Partial): OdfMarkerEvent { + return { + kind: "bookmark", + side: "start", + key: "k", + index: 0, + qualified: true, + order: 0, + descriptor: () => undefined, + element: el("text:bookmark-start", {}), + ...overrides, + }; +} + +describe("resolveOdfMarkerEvents", () => { + // Every start/end event below carries a genuinely RESOLVING descriptor -- so if a bypassed length check let the pairing proceed anyway, it would actually build an extent from starts[0]/ends[0], not merely fall through some other guard (an unresolved descriptor) that would mask the very check under test. + const resolvingDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + + it("drops a key with no qualified start", () => { + const end = event({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); + const { extents } = resolveOdfMarkerEvents([end]); + expect(extents).toEqual([]); + }); + + it("drops a key with two qualified starts", () => { + const startA = event({ + element: el("text:bookmark-start", { id: "a" }), + descriptor: () => resolvingDescriptor, + }); + const startB = event({ + element: el("text:bookmark-start", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const end = event({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); + const { extents } = resolveOdfMarkerEvents([startA, startB, end]); + expect(extents).toEqual([]); + }); + + it("drops a key with two qualified ends", () => { + const start = event({ descriptor: () => resolvingDescriptor }); + const endA = event({ + side: "end", + element: el("text:bookmark-end", { id: "a" }), + descriptor: () => resolvingDescriptor, + }); + const endB = event({ + side: "end", + element: el("text:bookmark-end", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const { extents } = resolveOdfMarkerEvents([start, endA, endB]); + expect(extents).toEqual([]); + }); + + it("drops a pair whose end index precedes its start index", () => { + const start = event({ + index: 3, + descriptor: () => ({ kind: "anchor", anchorType: "bookmark", name: "k" }), + }); + const end = event({ + side: "end", + index: 1, + element: el("text:bookmark-end", {}), + }); + const { extents } = resolveOdfMarkerEvents([start, end]); + expect(extents).toEqual([]); + }); + + it("keeps a pair whose end index equals its start index (a point extent)", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = event({ index: 4, order: 7, descriptor: () => descriptor }); + const end = event({ + side: "end", + index: 4, + element: el("text:bookmark-end", {}), + }); + const { extents } = resolveOdfMarkerEvents([start, end]); + expect(extents).toEqual([ + { startIndex: 4, endIndex: 4, order: 7, descriptor }, + ]); + }); + + it("marks both halves of a completed pair as paired, not only the start", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const start = event({ + element: startElement, + descriptor: () => descriptor, + }); + const end = event({ side: "end", element: endElement }); + const { paired } = resolveOdfMarkerEvents([start, end]); + expect(paired.has(startElement)).toBe(true); + expect(paired.has(endElement)).toBe(true); + }); +}); + +describe("insertOdfConstructMarkers", () => { + it("returns every defined block, unmodified, when there are no extents at all", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]; + expect(insertOdfConstructMarkers(blocks, [])).toEqual(blocks); + }); + + it("orders two constructs opening at the same index outermost-first, by descending end index", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + ]; + const outer: OdfConstructExtent = { + startIndex: 0, + endIndex: 1, + order: 0, + descriptor: { kind: "division" }, + }; + const inner: OdfConstructExtent = { + startIndex: 0, + endIndex: 0, + order: 1, + descriptor: { kind: "division", name: "inner" }, + }; + // Passed inner-first, deliberately the wrong order, so a real sort is what puts the outer extent ahead of the inner one -- a comparator collapsed to always-equal (a stable sort's no-op) would leave this input order untouched instead. + const result = insertOdfConstructMarkers(blocks, [inner, outer]); + // The outer extent (endIndex 1) must open before the inner one (endIndex 0), which itself closes immediately (a point extent) before the outer's own block. + expect(result).toEqual([ + { kind: "constructStart", descriptor: outer.descriptor }, + { kind: "constructStart", descriptor: inner.descriptor }, + { kind: "constructEnd" }, + blocks[0], + { kind: "constructEnd" }, + ]); + }); + + it("sorts primarily by ascending start index, not merely by end index", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]; + // A long-running extent starting first but ending LAST, and a short point extent starting second but ending FIRST -- a comparator that fell back to comparing end index (as it would if the start-index clause were dropped from the OR chain) would sort these in the opposite order, and would additionally reject the long extent outright as improperly nested inside the point extent. + const long: OdfConstructExtent = { + startIndex: 0, + endIndex: 2, + order: 0, + descriptor: { kind: "division" }, + }; + const point: OdfConstructExtent = { + startIndex: 1, + endIndex: 1, + order: 0, + descriptor: { kind: "division", name: "point" }, + }; + const result = insertOdfConstructMarkers(blocks, [point, long]); + expect(result).toEqual([ + { kind: "constructStart", descriptor: long.descriptor }, + blocks[0], + { kind: "constructStart", descriptor: point.descriptor }, + { kind: "constructEnd" }, + blocks[1], + { kind: "constructEnd" }, + ]); + }); +}); + +describe("collectOdfProvenanceRegions", () => { + it("skips a text:changed-region whose only child is not a recognised change kind", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:format-change-irrelevant", {}), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.size).toBe(0); + }); + + it("finds the real change element by its own tag, not merely the first child element", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("some:decoy", {}), + el("text:insertion", {}), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.change).toBe("insertion"); + }); + + it("finds office:change-info by its own tag, not merely the first child of the change element", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("some:decoy", {}), + el("office:change-info", {}, [ + el("dc:creator", {}, [txt("Real Author")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.author).toBe("Real Author"); + }); + + it("finds dc:creator by its own tag, not merely the first child of office:change-info", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("office:change-info", {}, [ + el("some:decoy", {}), + el("dc:creator", {}, [txt("Real Author")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.author).toBe("Real Author"); + }); + + it("finds dc:date by its own tag, not merely the first child of office:change-info", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("office:change-info", {}, [ + el("some:decoy", {}), + el("dc:date", {}, [txt("2024-01-01T00:00:00Z")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.dateIso).toBe("2024-01-01T00:00:00Z"); + }); +}); + +describe("collectOdfFieldMasterDefinitions (readOdfFieldMasterEntry)", () => { + it("skips a declaration with no text:name at all", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [el("text:variable-decls", {}, [el("text:variable-decl", {})])], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no valueType/value/stringValue/formula when their attributes are absent", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:variable-decls", {}, [ + el("text:variable-decl", { "text:name": "v1" }), + ]), + ], + out, + ); + const entry = out["variable:v1"]; + expect(entry).not.toHaveProperty("valueType"); + expect(entry).not.toHaveProperty("value"); + expect(entry).not.toHaveProperty("stringValue"); + expect(entry).not.toHaveProperty("formula"); + }); + + it("reads office:string-value specifically, not some other attribute", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:variable-decls", {}, [ + el("text:variable-decl", { + "text:name": "v1", + "office:string-value": "hello", + }), + ]), + ], + out, + ); + expect(out["variable:v1"]?.stringValue).toBe("hello"); + }); + + it("omits displayOutlineLevel for a negative value", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:sequence-decls", {}, [ + el("text:sequence-decl", { + "text:name": "s1", + "text:display-outline-level": "-1", + }), + ]), + ], + out, + ); + expect(out["sequence:s1"]).not.toHaveProperty("displayOutlineLevel"); + }); + + it("keeps displayOutlineLevel for a valid non-negative integer", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:sequence-decls", {}, [ + el("text:sequence-decl", { + "text:name": "s1", + "text:display-outline-level": "2", + }), + ]), + ], + out, + ); + expect(out["sequence:s1"]).toHaveProperty("displayOutlineLevel", 2); + }); +}); + +describe("collectOdfDataStyleDefinitions", () => { + it("skips a data style element with no style:name", () => { + const out: Record = {}; + collectOdfDataStyleDefinitions([el("number:date-style", {})], out); + expect(out).toEqual({}); + }); +}); + +describe("collectOdfFontFaceDefinitions", () => { + it("skips a font face with a name but no font family", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [el("style:font-face", { "style:name": "F1" })], + out, + ); + expect(out).toEqual({}); + }); + + it("skips a font face with a font family but no name", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [el("style:font-face", { "svg:font-family": "Arial" })], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no familyGeneric/pitch when their attributes are absent", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [ + el("style:font-face", { + "style:name": "F1", + "svg:font-family": "Arial", + }), + ], + out, + ); + const entry = out["fontFace:F1"]; + expect(entry).not.toHaveProperty("familyGeneric"); + expect(entry).not.toHaveProperty("pitch"); + }); +}); + +describe("collectOdfNamedExpressions", () => { + it("skips a child with no table:name, even though it is otherwise complete enough to mint an entry", () => { + // table:cell-range-address is present so a bypassed name guard would actually reach out[...] = entry, rather than being masked by the inner "no cell-range-address" guard further down. + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { "table:cell-range-address": "$A$1:$A$2" }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("skips a named-range with no table:cell-range-address", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { "table:name": "n1" }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no baseCellAddress for a named-range when it is absent", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { + "table:name": "n1", + "table:cell-range-address": "$A$1:$A$2", + }), + ]), + ], + out, + ); + expect(out["named-range:n1"]).not.toHaveProperty("baseCellAddress"); + }); + + it("carries baseCellAddress for a named-range when present", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { + "table:name": "n1", + "table:cell-range-address": "$A$1:$A$2", + "table:base-cell-address": "$A$1", + }), + ]), + ], + out, + ); + expect(out["named-range:n1"]?.baseCellAddress).toBe("$A$1"); + }); + + it("does not treat a child of neither known tag as a named-expression even when it carries an expression attribute", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:not-a-real-tag", { + "table:name": "n1", + "table:expression": "1+1", + }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no baseCellAddress for a named-expression when it is absent", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { + "table:name": "e1", + "table:expression": "1+1", + }), + ]), + ], + out, + ); + expect(out["named-expression:e1"]).not.toHaveProperty("baseCellAddress"); + }); + + it("skips a named-expression with no table:expression", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { "table:name": "e1" }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("carries baseCellAddress for a named-expression when present", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { + "table:name": "e1", + "table:expression": "1+1", + "table:base-cell-address": "$A$1", + }), + ]), + ], + out, + ); + expect(out["named-expression:e1"]?.baseCellAddress).toBe("$A$1"); + }); +}); + +describe("parseOdfFieldInstruction", () => { + it("finds the real top-level element, skipping a leading non-element node", () => { + const element = parseOdfFieldInstruction(''); + expect(element.tag).toBe("foo"); + }); + + it("throws when the instruction parses back to no element at all", () => { + expect(() => parseOdfFieldInstruction("")).toThrow( + /did not parse back to a single element/, + ); + }); +}); + +describe("canonicalOdfConstructDescriptor", () => { + it("passes a non-index contentControl through unchanged", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "richText", + }; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); + + it("passes an index contentControl with no source through unchanged, without dereferencing a source that isn't there", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "index", + }; + expect(() => canonicalOdfConstructDescriptor(descriptor)).not.toThrow(); + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); + + it("passes a non-index contentControl through unchanged even when it does carry a source (the controlType check is its own real gate, not implied by the source check alone)", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "richText", + source: { format: "odt", xml: "" }, + }; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); + + it("passes a non-contentControl descriptor through unchanged even when it happens to carry contentControl-shaped fields (the kind check is its own real gate, not implied by the controlType/source checks alone)", () => { + const descriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "b", + controlType: "index", + source: { format: "odt", xml: "" }, + } as unknown as ConstructDescriptor; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); +}); + +describe("writeOdfChangePoint", () => { + it("writes the exact point-change element and id", () => { + const result = writeOdfChangePoint("id1"); + expect(result.tag).toBe("text:change"); + expect(result.attributes).toEqual([ + { name: "text:change-id", value: "id1" }, + ]); + }); +}); + +describe("writeOdfAnnotationHalf", () => { + it("writes a dc:date child when the entry carries a dateIso", () => { + const result = writeOdfAnnotationHalf( + { name: "c1" }, + { kind: "comment", body: [], dateIso: "2024-01-01T00:00:00Z" }, + ); + const dateEl = result.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "dc:date", + ); + if (dateEl === undefined) { + throw new Error("expected a dc:date child"); + } + const [text] = dateEl.children; + expect(text).toEqual({ type: "text", value: "2024-01-01T00:00:00Z" }); + }); + + it("writes no dc:date child when the entry carries no dateIso", () => { + const result = writeOdfAnnotationHalf( + { name: "c1" }, + { kind: "comment", body: [] }, + ); + expect( + result.children.some( + (child) => child.type === "element" && child.tag === "dc:date", + ), + ).toBe(false); + }); +}); + +describe("writeOdfTrackedChanges", () => { + it("wraps every region in a text:tracked-changes element, spelled exactly", () => { + expect(writeOdfTrackedChanges([]).tag).toBe("text:tracked-changes"); + }); + + it("throws for a change kind with no ODF region spelling (moveFrom/moveTo, refused by every real caller before reaching here)", () => { + const regions = [ + { + id: "r1", + descriptor: { + kind: "provenance", + change: "moveFrom", + } as unknown as ProvenanceDescriptor & { + change: "insertion" | "deletion" | "formatChange"; + }, + }, + ]; + expect(() => writeOdfTrackedChanges(regions)).toThrow( + /has no ODF region spelling/, + ); + }); + + it("writes no office:change-info at all when neither author nor date is present", () => { + const region = el("text:tracked-changes-region-probe", {}); + const result = writeOdfTrackedChanges([ + { id: "r1", descriptor: { kind: "provenance", change: "insertion" } }, + ]); + const [regionEl] = result.children; + if (regionEl?.type !== "element") { + throw new Error("expected a text:changed-region element"); + } + const [changeEl] = regionEl.children; + if (changeEl?.type !== "element") { + throw new Error("expected a change element"); + } + expect(changeEl.children).toEqual([]); + void region; + }); +}); + +describe("odfRunConstructWriteKind", () => { + function extent( + descriptor: RunConstructExtent["descriptor"], + startRun: number, + endRun: number, + ): RunConstructExtent { + return { descriptor, startRun, endRun }; + } + + it("distinguishes a zero-width bookmark point from a ranged bookmark", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "b", + }; + expect(odfRunConstructWriteKind(extent(descriptor, 2, 2))).toBe( + "bookmarkPoint", + ); + expect(odfRunConstructWriteKind(extent(descriptor, 2, 4))).toBe( + "bookmarkRange", + ); + }); + + it("refuses a footnote anchor with no definition key at all", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("refuses a footnote anchor whose definition key is not actually in the definitions table", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + definition: "note:n1", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("writes a footnote, an endnote, and a comment as note/note/comment, once their definition resolves", () => { + const noteDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + definition: "note:n1", + }; + const endnoteDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "endnote", + name: "n2", + definition: "note:n2", + }; + const commentDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "comment", + name: "c1", + definition: "comment:c1", + }; + const definitions: Record = { + "note:n1": { kind: "footnote", body: [] }, + "note:n2": { kind: "endnote", body: [] }, + "comment:c1": { kind: "comment", body: [] }, + }; + expect( + odfRunConstructWriteKind(extent(noteDescriptor, 0, 0), definitions), + ).toBe("note"); + expect( + odfRunConstructWriteKind(extent(endnoteDescriptor, 0, 0), definitions), + ).toBe("note"); + expect( + odfRunConstructWriteKind(extent(commentDescriptor, 0, 0), definitions), + ).toBe("comment"); + }); + + it("refuses an anchor whose type is none of footnote/endnote/comment, even with a resolving definition (bookmark is excluded by the earlier branch; nothing else in AnchorType reaches this far)", () => { + const descriptor = { + kind: "anchor", + anchorType: "notARealAnchorType", + name: "n1", + definition: "note:n1", + } as unknown as RunConstructExtent["descriptor"]; + const definitions: Record = { + "note:n1": { kind: "footnote", body: [] }, + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), definitions), + ).toBeUndefined(); + }); + + it("refuses a moveFrom/moveTo provenance change (no ODF spelling exists for either)", () => { + const moveFrom: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "moveFrom", + }; + const moveTo: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "moveTo", + }; + const changeIds = new Map([ + [moveFrom, "id1"], + [moveTo, "id2"], + ]); + expect( + odfRunConstructWriteKind(extent(moveFrom, 0, 0), {}, changeIds), + ).toBeUndefined(); + expect( + odfRunConstructWriteKind(extent(moveTo, 0, 0), {}, changeIds), + ).toBeUndefined(); + }); + + it("refuses a provenance change with no minted region id at all", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "insertion", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("distinguishes a zero-width change point from a ranged change, once minted", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "insertion", + }; + const changeIds = new Map([ + [descriptor, "id1"], + ]); + expect( + odfRunConstructWriteKind(extent(descriptor, 3, 3), {}, changeIds), + ).toBe("changePoint"); + expect( + odfRunConstructWriteKind(extent(descriptor, 3, 5), {}, changeIds), + ).toBe("changeRange"); + }); +}); + +describe("writeOdfDivision", () => { + it("carries no text:section-name when the linked division names none", () => { + const descriptor: DivisionDescriptor = { + kind: "division", + linked: { href: "chapter.odt" }, + }; + const result = writeOdfDivision(descriptor, [], { + mintSectionStyleName: () => "S1", + }); + const sourceEl = result.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "text:section-source", + ); + expect(sourceEl?.attributes).toEqual([ + { name: "xlink:type", value: "simple" }, + { name: "xlink:href", value: "chapter.odt" }, + ]); + }); +}); + +describe("odfIndexWrapperTag / writeOdfIndexWrapper", () => { + function descriptorWithSourceXml(xml: string): ContentControlDescriptor { + return { + kind: "contentControl", + controlType: "index", + source: { format: "odt", xml }, + }; + } + + it("finds the real *-source element, skipping a leading non-element node", () => { + expect( + odfIndexWrapperTag( + descriptorWithSourceXml(""), + ), + ).toBe("text:table-of-content"); + }); + + it("throws when the residue's own top-level element does not end in -source", () => { + expect(() => + odfIndexWrapperTag(descriptorWithSourceXml("")), + ).toThrow(); + }); + + it("really checks for the -source suffix specifically, not merely that the tag has some suffix", () => { + // Blindly slicing the last 7 characters off "text:bibliography-sourcX" (a tag that does NOT end in "-source") lands exactly on the real "text:bibliography" wrapper tag -- so a weakened endsWith check that let this through would silently succeed instead of throwing. + expect(() => + odfIndexWrapperTag( + descriptorWithSourceXml(""), + ), + ).toThrow(); + }); + + it("throws when the stripped tag is not one of the seven recognised index wrapper tags", () => { + expect(() => + odfIndexWrapperTag(descriptorWithSourceXml("")), + ).toThrow(); + }); + + it("writes the bare *-source child and the recovered wrapper tag together", () => { + const descriptor = descriptorWithSourceXml( + "", + ); + const result = writeOdfIndexWrapper(descriptor, []); + expect(result.tag).toBe("text:table-of-content"); + expect( + result.children.some( + (child) => + child.type === "element" && + child.tag === "text:table-of-content-source", + ), + ).toBe(true); + }); +}); diff --git a/packages/odf.js/src/typed/shared/constructs.ts b/packages/odf.js/src/typed/shared/constructs.ts index 28e20b9e3..16f71ef4b 100644 --- a/packages/odf.js/src/typed/shared/constructs.ts +++ b/packages/odf.js/src/typed/shared/constructs.ts @@ -147,8 +147,8 @@ export const ODF_CONSUMED_PART_PATHS: ReadonlySet = new Set([ "META-INF/manifest.xml", ]); -// An embedded sub-document's own parts -- the "Object N" directory convention every real producer's draw:object href actually names (confirmed against real LibreOffice output: "Object 1/content.xml", "Object 1/styles.xml", "Object 1/settings.xml" under a draw:object xlink:href="./Object 1"). Those parts are consumed by the embedded-object readers into their own whole ContentDocuments, so quarantining them too would put one sub-document in two channels at once. This helper cannot see hrefs, so it excludes the whole convention-shaped range rather than ever double-carrying a sub-document; the cost of a false exclusion is only a residue row the semantic channel already carries, while the cost of a false inclusion is the double-carry itself. -function isEmbeddedObjectPart(path: string): boolean { +// An embedded sub-document's own parts -- the "Object N" directory convention every real producer's draw:object href actually names (confirmed against real LibreOffice output: "Object 1/content.xml", "Object 1/styles.xml", "Object 1/settings.xml" under a draw:object xlink:href="./Object 1"). Those parts are consumed by the embedded-object readers into their own whole ContentDocuments, so quarantining them too would put one sub-document in two channels at once. This helper cannot see hrefs, so it excludes the whole convention-shaped range rather than ever double-carrying a sub-document; the cost of a false exclusion is only a residue row the semantic channel already carries, while the cost of a false inclusion is the double-carry itself. Exported so the regex's own three boundary facts (must start with "Object ", must be only digits after it, must end there) can each be pinned directly -- a black-box test through collectOdfNonContentPartResidue/writeOdfPackageResidue could only ever observe "quarantined or not", which cannot distinguish a loosened anchor from the correct one. +export function isEmbeddedObjectPart(path: string): boolean { const [firstSegment] = path.split("/"); return firstSegment !== undefined && /^Object \d+$/.test(firstSegment); } @@ -572,9 +572,7 @@ export function insertOdfConstructMarkers( blocks: readonly ContentBlock[], extents: readonly OdfConstructExtent[], ): ContentBlock[] { - if (extents.length === 0) { - return [...blocks]; - } + // No early return for an empty extents list: with nested/openingAt both empty, the loop below already reduces to "copy every defined block in order", which is exactly `[...blocks]` -- a dedicated guard was a redundant, behaviourally unobservable shortcut for the same result. const nested = acceptProperlyNestedOdfExtents(extents); const openingAt = new Map(); for (const extent of nested) { diff --git a/packages/odf.js/src/typed/shared/expression.test.ts b/packages/odf.js/src/typed/shared/expression.test.ts new file mode 100644 index 000000000..4de7f25de --- /dev/null +++ b/packages/odf.js/src/typed/shared/expression.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { skipExpression, takeExpression } from "./expression"; + +describe("skipExpression", () => { + it("stops at the first unnested occurrence of endChar", () => { + expect(skipExpression("abc,def", 0, ",")).toBe(3); + }); + + it("runs to the end of the string when endChar never occurs", () => { + expect(skipExpression("abcdef", 0, ",")).toBe(6); + }); + + it("a comma or paren INSIDE a nested (...) is not the end", () => { + expect(skipExpression("(a,b),c", 0, ",")).toBe(5); + }); + + it("a brace nests exactly like a paren, and does not itself end the expression", () => { + expect(skipExpression("{a,b},c", 0, ",")).toBe(5); + }); + + it("braces and parens can nest inside each other", () => { + expect(skipExpression("(a{b,c}d),e", 0, ",")).toBe(9); + }); + + it("advances past an empty brace pair correctly, not merely re-consuming already-processed content that happens to reach the same answer", () => { + // An immediately-closing "{}" isolates the brace branch's own trailing +1 from the recursive call's return value: rewinding by 2 instead (the mutation this pins) resets index to the opening "{" itself, causing skipExpression to re-open the identical brace pair forever. + expect(skipExpression("{},c", 0, ",")).toBe(2); + }); + + it("a comma inside a double-quoted string is not the end", () => { + expect(skipExpression('"a,b",c', 0, ",")).toBe(5); + }); + + it("a comma inside a single-quoted string is not the end", () => { + expect(skipExpression("'a,b',c", 0, ",")).toBe(5); + }); + + it("an unterminated quoted string runs to the end of the text", () => { + expect(skipExpression('"unterminated', 0, ",")).toBe(13); + }); + + it("starts scanning exactly at the given start index, not from 0", () => { + expect(skipExpression("xx,abc,def", 3, ",")).toBe(6); + }); +}); + +describe("takeExpression", () => { + it("extracts and trims the expression up to endChar, advancing past it", () => { + expect(takeExpression(" abc ,rest", 0, ",")).toEqual({ + value: "abc", + nextIndex: 6, + }); + }); + + it("an empty expression (nothing but whitespace) yields undefined, never an empty string", () => { + expect(takeExpression(" ,rest", 0, ",")).toEqual({ + value: undefined, + nextIndex: 4, + }); + }); + + it("a genuinely empty span (no characters at all before endChar) yields undefined", () => { + expect(takeExpression(",rest", 0, ",")).toEqual({ + value: undefined, + nextIndex: 1, + }); + }); + + it("a non-empty, already-trimmed expression is returned exactly", () => { + expect(takeExpression("abc)", 0, ")")).toEqual({ + value: "abc", + nextIndex: 4, + }); + }); +}); diff --git a/packages/odf.js/src/typed/shared/expression.ts b/packages/odf.js/src/typed/shared/expression.ts index ba4f28812..204f459e4 100644 --- a/packages/odf.js/src/typed/shared/expression.ts +++ b/packages/odf.js/src/typed/shared/expression.ts @@ -13,21 +13,25 @@ export function skipExpression( return index; } if (ch === "(") { - index = skipExpression(text, index + 1, ")") + 1; + index = Math.min(skipExpression(text, index + 1, ")") + 1, text.length); continue; } if (ch === "{") { - index = skipExpression(text, index + 1, "}") + 1; + index = Math.min(skipExpression(text, index + 1, "}") + 1, text.length); continue; } if (ch === '"' || ch === "'") { const closeQuote = text.indexOf(ch, index + 1); - index = (closeQuote === -1 ? text.length : closeQuote) + 1; + index = Math.min( + (closeQuote === -1 ? text.length : closeQuote) + 1, + text.length, + ); continue; } index += 1; } - return text.length; + // Every advance above is clamped to text.length, so a natural loop exit always leaves index exactly at text.length -- returning index rather than a hardcoded text.length keeps the loop's own boundary condition load-bearing (an off-by-one there would surface here as a wrong return value) instead of masked by a fallback that would produce the same answer either way. + return index; } // Extracts and trims one expression, advancing past its own terminator. Returns undefined (never an empty string) when the expression is empty, matching both source functions' own "empty means failure" contract for an operand. diff --git a/packages/odf.js/src/typed/shared/forms.test.ts b/packages/odf.js/src/typed/shared/forms.test.ts new file mode 100644 index 000000000..1b3874c94 --- /dev/null +++ b/packages/odf.js/src/typed/shared/forms.test.ts @@ -0,0 +1,411 @@ +import type { + ContentBlock, + ContentControlDescriptor, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { el, txt } from "../../xml/fragment"; +import { readOdfFormControlConstructs, readOdfFormDefinitions } from "./forms"; + +// readOdfFormControlConstructs always emits a "contentControl"-kind descriptor (never field/anchor/link/provenance/division, the other members of ConstructDescriptor), but its own return type is the general ContentBlock, so every assertion below needs this narrowing to reach a control's own controlType/tag/value/checked/options fields. A single helper rather than repeating `block?.kind === "constructStart" ? block.descriptor... : undefined` inline: TypeScript does not carry a narrowing from one array-index expression (e.g. `blocks[2]`) to a second, separate access of the same index, so the inline form type-errors the moment the true branch re-reads the array. +function contentControlDescriptor( + block: ContentBlock | undefined, +): ContentControlDescriptor | undefined { + if ( + block?.kind !== "constructStart" || + block.descriptor.kind !== "contentControl" + ) { + return undefined; + } + return block.descriptor; +} + +describe("readOdfFormDefinitions", () => { + it("an office:forms element with no form:form children reads as an empty list", () => { + expect(readOdfFormDefinitions(el("office:forms"))).toStrictEqual([]); + }); + + it("a bare form:form with none of its optional attributes reads with every optional field absent", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form")]), + ); + expect(definition).toStrictEqual({ controls: [], subForms: [] }); + expect(definition).not.toHaveProperty("name"); + expect(definition).not.toHaveProperty("command"); + expect(definition).not.toHaveProperty("commandType"); + expect(definition).not.toHaveProperty("datasource"); + expect(definition).not.toHaveProperty("filter"); + expect(definition).not.toHaveProperty("order"); + }); + + it("reads every one of form:form's own attributes when all are present", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", { + "form:name": "SalesForm", + "form:command": "SALES", + "form:command-type": "table", + "form:datasource": "Bibliography", + "form:filter": "ID > 0", + "form:order": "ID ASC", + }), + ]), + ); + expect(definition).toMatchObject({ + name: "SalesForm", + command: "SALES", + commandType: "table", + datasource: "Bibliography", + filter: "ID > 0", + order: "ID ASC", + }); + }); + + it("a nested form:form becomes a subForm, not a control, and is read recursively", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", { "form:name": "Outer" }, [ + el("form:form", { "form:name": "Inner" }), + ]), + ]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toHaveLength(1); + expect(definition?.subForms[0]?.name).toBe("Inner"); + }); + + it("form:properties is neither a control nor a subForm", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form", {}, [el("form:properties")])]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toStrictEqual([]); + }); + + it("a text node child of office:forms or form:form is skipped, not treated as a form:form or control", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + txt("stray text"), + el("form:form", {}, [txt("more stray text")]), + ]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toStrictEqual([]); + }); + + it("a non-form:* child of office:forms is not read as a form definition", () => { + expect( + readOdfFormDefinitions(el("office:forms", {}, [el("draw:frame")])), + ).toStrictEqual([]); + }); + + it("reads every one of a control's own optional attributes when present, on a control with children of its own", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", {}, [ + el( + "form:grid", + { + "form:name": "grid1", + "form:control-implementation": + "ooo:com.sun.star.form.component.GridControl", + "form:data-field": "ITEMS", + "form:id": "control9", + "form:label": "Items", + }, + [el("form:column", { "form:name": "col1" })], + ), + ]), + ]), + ); + expect(definition?.controls).toStrictEqual([ + { + tag: "form:grid", + name: "grid1", + controlImplementation: "ooo:com.sun.star.form.component.GridControl", + dataField: "ITEMS", + id: "control9", + label: "Items", + controls: [{ tag: "form:column", name: "col1", controls: [] }], + }, + ]); + }); + + it("a control with none of its optional attributes reads with every optional field absent", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form", {}, [el("form:text")])]), + ); + expect(definition?.controls).toStrictEqual([ + { tag: "form:text", controls: [] }, + ]); + }); +}); + +describe("readOdfFormControlConstructs", () => { + it("an office:forms element with no form:form children emits nothing, including for a non-form:* child", () => { + expect( + readOdfFormControlConstructs(el("office:forms"), "odt"), + ).toStrictEqual([]); + expect( + readOdfFormControlConstructs( + el("office:forms", {}, [el("draw:frame")]), + "odt", + ), + ).toStrictEqual([]); + }); + + it("a bare form:form with no name and no properties emits a group construct pair with no tag or source", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form")]), + "odt", + ); + expect(blocks).toStrictEqual([ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "group" }, + }, + { kind: "constructEnd" }, + ]); + }); + + it("a named form:form with form:properties emits a group construct carrying the name as tag and the properties as source, in the reader's own format", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", { "form:name": "SalesForm" }, [ + el("form:properties", {}, [el("form:property")]), + ]), + ]), + "odb", + ); + expect(blocks[0]).toStrictEqual({ + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "group", + tag: "SalesForm", + source: { + format: "odb", + xml: "", + }, + }, + }); + expect(blocks[1]).toStrictEqual({ kind: "constructEnd" }); + }); + + it("a nested form:form is emitted recursively, in document order after its own parent's own start/end pair", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", { "form:name": "Outer" }, [ + el("form:form", { "form:name": "Inner" }), + ]), + ]), + "odt", + ); + expect( + blocks.map((b) => contentControlDescriptor(b)?.tag ?? b.kind), + ).toStrictEqual(["Outer", "constructEnd", "Inner", "constructEnd"]); + }); + + it("a text node child of a form:form is skipped, and form:properties is not itself emitted as a control", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [txt("stray text"), el("form:properties")]), + ]), + "odt", + ); + expect(blocks).toHaveLength(2); + }); + + it.each([ + ["form:text", "plainText", {}], + ["form:textarea", "plainText", {}], + ["form:formatted-text", "plainText", {}], + ["form:password", "plainText", {}], + ["form:file", "plainText", {}], + ["form:listbox", "dropDown", { options: [] }], + ["form:combobox", "comboBox", {}], + ["form:checkbox", "checkbox", { checked: false }], + ["form:radio", "checkbox", { checked: false }], + ["form:button", "button", {}], + ["form:image-frame", "picture", {}], + ["form:fixed-text", "richText", {}], + ["form:frame", "richText", {}], + ["form:grid", "group", {}], + ["form:hidden", "richText", {}], + ] as const)( + "maps a bare %s control to controlType %s", + (tag, controlType, extra) => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el(tag)])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType, ...extra }, + }); + }, + ); + + it("an unrecognised form:* tag degrades to richText with the whole element quarantined as residue, not just its properties", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:unknown-kind", { "form:name": "mystery" }, [ + el("form:properties"), + ]), + ]), + ]), + "odp", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "richText", + tag: "mystery", + source: { + format: "odp", + xml: '', + }, + }, + }); + }); + + it("a mapped control's value prefers form:current-value over form:value, and falls back to form:value alone", () => { + const withCurrentValue = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:text", { + "form:current-value": "live", + "form:value": "stale", + }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(withCurrentValue[2])?.value).toBe("live"); + + const withValueOnly = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [el("form:text", { "form:value": "stale" })]), + ]), + "odt", + ); + expect(contentControlDescriptor(withValueOnly[2])?.value).toBe("stale"); + }); + + it("a mapped control with neither form:current-value nor form:value carries no value field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:text")])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "plainText" }, + }); + }); + + it("form:checkbox and form:radio carry a checked field derived from form:current-state, both true and false", () => { + const checked = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:checkbox", { "form:current-state": "checked" }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(checked[2])?.checked).toBe(true); + + const uncheckedRadio = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:radio", { "form:current-state": "unchecked" }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(uncheckedRadio[2])?.checked).toBe(false); + + const missingState = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:checkbox")])]), + "odt", + ); + expect(contentControlDescriptor(missingState[2])?.checked).toBe(false); + }); + + it("a control that is neither checkbox nor radio never carries a checked field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:button", { "form:current-state": "checked" }), + ]), + ]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "button" }, + }); + }); + + it("form:listbox reads its form:option children as label-preferring-over-value pairs, in order, skipping an option with neither", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:listbox", {}, [ + el("form:option", { "form:label": "One", "form:value": "1" }), + el("form:option", { "form:value": "2" }), + el("form:option", {}), + ]), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(blocks[2])?.options).toStrictEqual([ + "One", + "2", + ]); + }); + + it("a control that is not a listbox never carries an options field, even with form:option-shaped children", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:combobox", {}, [el("form:option", { "form:label": "One" })]), + ]), + ]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "comboBox" }, + }); + }); + + it("a mapped control's own form:properties becomes its source residue, in the reader's own format", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:button", {}, [ + el("form:properties", {}, [el("form:property")]), + ]), + ]), + ]), + "odm", + ); + expect(contentControlDescriptor(blocks[2])?.source).toStrictEqual({ + format: "odm", + xml: "", + }); + }); + + it("a mapped control with no form:properties child carries no source field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:button")])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "button" }, + }); + }); +}); diff --git a/packages/odf.js/src/typed/shared/image.test.ts b/packages/odf.js/src/typed/shared/image.test.ts new file mode 100644 index 000000000..ed37f3c85 --- /dev/null +++ b/packages/odf.js/src/typed/shared/image.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { imageExtension } from "./image"; + +describe("imageExtension", () => { + it("maps each ContentImageBlock format to its own real file extension", () => { + expect(imageExtension("png")).toBe("png"); + expect(imageExtension("jpeg")).toBe("jpg"); + expect(imageExtension("svg")).toBe("svg"); + expect(imageExtension("gif")).toBe("gif"); + }); +}); diff --git a/packages/odf.js/src/typed/shared/list.test.ts b/packages/odf.js/src/typed/shared/list.test.ts new file mode 100644 index 000000000..018c0d4af --- /dev/null +++ b/packages/odf.js/src/typed/shared/list.test.ts @@ -0,0 +1,480 @@ +import { describe, expect, it } from "vitest"; +import type { ContentParagraph } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue, childrenWithTag } from "../../xml/query"; +import { + resolveOdfListKind, + mintOdfListNumId, + buildOdfListStyle, + writeOdfList, + listKindOf, + canonicalNumId, + planListMembership, + closeListPlan, + readOdfListParagraphs, + NO_NUM_ID_KEY, + type OdfListIdState, + type OdfListEntry, + type ListPlanState, +} from "./list"; + +function packageWithAutomaticStyles(...listStyles: XmlElement[]): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, listStyles), + ]), + ], + }, + }, + }; +} + +describe("resolveOdfListKind", () => { + it("undefined style name resolves to undefined", () => { + expect(resolveOdfListKind({ parts: {} }, undefined)).toBeUndefined(); + }); + + it("undefined style name resolves to undefined even when a real, matchable list-style exists", () => { + // A text:list-style with no style:name attribute at all would make attrValue(el, "style:name") itself return undefined -- coincidentally equal to an undefined styleName -- if this function didn't short-circuit on an undefined styleName before ever reaching the lookup. Giving it a level-1 ordered child means a version that skipped the short-circuit would resolve "ordered" here instead of undefined. + const style = el("text:list-style", {}, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, undefined)).toBeUndefined(); + }); + + it("resolves an ordered list style from its level-1 text:list-level-style-number", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("ordered"); + }); + + it("resolves a bullet list style from its level-1 text:list-level-style-bullet", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-bullet", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("bullet"); + }); + + it("resolves a bullet list style from its level-1 text:list-level-style-image (also a bullet kind)", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-image", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("bullet"); + }); + + it("only a level-1 child counts -- a level-2-only number style resolves to undefined, not ordered", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("only a level-1 child counts for a bullet style too -- a level-2-only bullet resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-bullet", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("only a level-1 child counts for an image style too -- a level-2-only image resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-image", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("a matching style with no recognised level-1 child resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, []); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("an unresolvable style name (no matching text:list-style anywhere) resolves to undefined", () => { + const pkg = packageWithAutomaticStyles(); + expect(resolveOdfListKind(pkg, "does-not-exist")).toBeUndefined(); + }); + + it("finds a list style in styles.xml's office:styles when content.xml has none", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:styles", {}, [style]), + ]), + ], + }, + }, + }; + expect(resolveOdfListKind(pkg, "L1")).toBe("ordered"); + }); +}); + +describe("mintOdfListNumId", () => { + it("mints an unprefixed numId when the list carries no resolvable style", () => { + const state: OdfListIdState = { next: 1 }; + const numId = mintOdfListNumId({ parts: {} }, el("text:list"), state); + expect(numId).toBe("list1"); + expect(state.next).toBe(2); + }); + + it("mints an ordered:-prefixed numId when the list's style resolves to ordered", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + const state: OdfListIdState = { next: 3 }; + const numId = mintOdfListNumId( + pkg, + el("text:list", { "text:style-name": "L1" }), + state, + ); + expect(numId).toBe("ordered:list3"); + expect(state.next).toBe(4); + }); + + it("advances the counter by exactly one per call, regardless of resolution", () => { + const state: OdfListIdState = { next: 1 }; + mintOdfListNumId({ parts: {} }, el("text:list"), state); + mintOdfListNumId({ parts: {} }, el("text:list"), state); + expect(state.next).toBe(3); + }); +}); + +describe("buildOdfListStyle", () => { + it("builds ten levels, each one indent step deeper than the last", () => { + const style = buildOdfListStyle("L1", "bullet"); + const levels = childrenWithTag(style, "text:list-level-style-bullet"); + expect(levels).toHaveLength(10); + expect(attrValue(levels[0]!, "text:level")).toBe("1"); + expect(attrValue(levels[9]!, "text:level")).toBe("10"); + const props1 = childrenWithTag( + levels[0]!, + "style:list-level-properties", + )[0]!; + const props2 = childrenWithTag( + levels[1]!, + "style:list-level-properties", + )[0]!; + expect(attrValue(props1, "text:space-before")).toBe("18pt"); + expect(attrValue(props2, "text:space-before")).toBe("36pt"); + // Every level shares the identical min-label-width -- one indent step, not scaled by level. + expect(attrValue(props1, "text:min-label-width")).toBe("18pt"); + expect(attrValue(props2, "text:min-label-width")).toBe("18pt"); + }); + + it("an ordered style's own levels carry the real numbering attributes", () => { + const style = buildOdfListStyle("L1", "ordered"); + const levels = childrenWithTag(style, "text:list-level-style-number"); + expect(levels).toHaveLength(10); + expect(attrValue(levels[0]!, "style:num-suffix")).toBe("."); + expect(attrValue(levels[0]!, "style:num-format")).toBe("1"); + }); + + it("an ordered style's own levels also carry the indent properties, exactly as a bullet style's do", () => { + const style = buildOdfListStyle("L1", "ordered"); + const levels = childrenWithTag(style, "text:list-level-style-number"); + const props = childrenWithTag( + levels[0]!, + "style:list-level-properties", + )[0]!; + expect(attrValue(props, "text:space-before")).toBe("18pt"); + expect(attrValue(props, "text:min-label-width")).toBe("18pt"); + }); + + it("a bullet style's own levels carry the real bullet character, never the numbering attributes", () => { + const style = buildOdfListStyle("L1", "bullet"); + const levels = childrenWithTag(style, "text:list-level-style-bullet"); + expect(attrValue(levels[0]!, "text:bullet-char")).toBe("•"); + }); + + it("the root element is text:list-style carrying the given style name", () => { + const style = buildOdfListStyle("MyList", "ordered"); + expect(style.tag).toBe("text:list-style"); + expect(attrValue(style, "style:name")).toBe("MyList"); + }); +}); + +describe("writeOdfList", () => { + function entry(level: number, id: string): OdfListEntry { + return { level, element: el("text:p", { id }) }; + } + + it("a single flat run of level-0 entries becomes sibling text:list-item elements", () => { + const root = writeOdfList([entry(0, "a"), entry(0, "b")], "L1"); + expect(root.tag).toBe("text:list"); + expect(attrValue(root, "text:style-name")).toBe("L1"); + expect(childrenWithTag(root, "text:list-item")).toHaveLength(2); + }); + + it("omits text:style-name entirely when no style name is given", () => { + const root = writeOdfList([entry(0, "a")], undefined); + expect(attrValue(root, "text:style-name")).toBeUndefined(); + }); + + it("a deeper entry nests inside the previous item's own text:list", () => { + const root = writeOdfList([entry(0, "a"), entry(1, "b")], undefined); + const items = childrenWithTag(root, "text:list-item"); + expect(items).toHaveLength(1); + const nestedList = childrenWithTag(items[0]!, "text:list")[0]!; + expect(childrenWithTag(nestedList, "text:list-item")).toHaveLength(1); + }); + + it("returning to a shallower level after a deeper one closes the nested list", () => { + const root = writeOdfList( + [entry(0, "a"), entry(1, "b"), entry(0, "c")], + undefined, + ); + // Two top-level items: the first holds the nested level-1 item, the second is the level-0 "c" entry that closed the nesting back out. + expect(childrenWithTag(root, "text:list-item")).toHaveLength(2); + }); + + it("a jump of more than one level opens the intervening lists inside empty items", () => { + const root = writeOdfList([entry(2, "a")], undefined); + const level0Item = childrenWithTag(root, "text:list-item")[0]!; + const level1List = childrenWithTag(level0Item, "text:list")[0]!; + const level1Item = childrenWithTag(level1List, "text:list-item")[0]!; + const level2List = childrenWithTag(level1Item, "text:list")[0]!; + expect(childrenWithTag(level2List, "text:list-item")).toHaveLength(1); + }); + + it("a fractional or negative level is clamped to a whole non-negative depth", () => { + const root = writeOdfList([entry(-5, "a")], undefined); + // Clamped to level 0 -- a single top-level item, no nested text:list at all. + expect(childrenWithTag(root, "text:list-item")).toHaveLength(1); + expect(childrenWithTag(root, "text:list")).toHaveLength(0); + }); +}); + +describe("listKindOf", () => { + it("undefined numId resolves to undefined kind", () => { + expect(listKindOf(undefined)).toBeUndefined(); + }); + + it("an ordered:-prefixed numId resolves to ordered", () => { + expect(listKindOf("ordered:list3")).toBe("ordered"); + }); + + it("a bullet:-prefixed numId resolves to bullet", () => { + expect(listKindOf("bullet:list3")).toBe("bullet"); + }); + + it("an unprefixed numId resolves to undefined", () => { + expect(listKindOf("list3")).toBeUndefined(); + }); +}); + +describe("canonicalNumId", () => { + it("an unprefixed incoming numId mints an unprefixed canonical label", () => { + expect(canonicalNumId("anything", 1)).toBe("list1"); + }); + + it("an ordered:-prefixed incoming numId mints an ordered:-prefixed canonical label", () => { + expect(canonicalNumId("ordered:src", 2)).toBe("ordered:list2"); + }); + + it("a bullet:-prefixed incoming numId mints a bullet:-prefixed canonical label", () => { + expect(canonicalNumId("bullet:src", 3)).toBe("bullet:list3"); + }); + + it("an undefined incoming numId mints an unprefixed canonical label", () => { + expect(canonicalNumId(undefined, 4)).toBe("list4"); + }); +}); + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +describe("planListMembership / closeListPlan", () => { + it("undefined membership closes the run and returns undefined", () => { + const state: ListPlanState = { + next: 5, + openNumId: "x", + openCanonicalNumId: "list4", + }; + expect(planListMembership(undefined, state)).toBeUndefined(); + expect(state.openNumId).toBeUndefined(); + expect(state.openCanonicalNumId).toBeUndefined(); + }); + + it("a fresh incoming numId mints a fresh canonical numId and advances the counter", () => { + const state = freshListState(); + const result = planListMembership({ numId: "src-a", level: 0 }, state); + expect(result).toBe("list1"); + expect(state.next).toBe(2); + }); + + it("consecutive paragraphs sharing one incoming numId extend the same run", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + const second = planListMembership({ numId: "src-a", level: 1 }, state); + expect(second).toBe(first); + expect(state.next).toBe(2); + }); + + it("a changed incoming numId mints a new canonical numId", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + const second = planListMembership({ numId: "src-b", level: 0 }, state); + expect(second).not.toBe(first); + expect(state.next).toBe(3); + }); + + it("a membership carrying no incoming numId still opens a real run of its own, keyed on the sentinel", () => { + const state = freshListState(); + const first = planListMembership({ level: 0 }, state); + expect(first).toBeDefined(); + expect(state.openNumId).toBe(NO_NUM_ID_KEY); + // Two consecutive bare-numId paragraphs still extend the same run. + const second = planListMembership({ level: 1 }, state); + expect(second).toBe(first); + }); + + it("closeListPlan clears both openNumId and openCanonicalNumId", () => { + const state: ListPlanState = { + next: 1, + openNumId: "x", + openCanonicalNumId: "list0", + }; + closeListPlan(state); + expect(state.openNumId).toBeUndefined(); + expect(state.openCanonicalNumId).toBeUndefined(); + }); + + it("after closeListPlan, the same incoming numId mints a genuinely new run rather than extending the old one", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + closeListPlan(state); + const second = planListMembership({ numId: "src-a", level: 0 }, state); + expect(second).not.toBe(first); + }); +}); + +describe("readOdfListParagraphs", () => { + function paragraphReader(): (element: XmlElement) => ContentParagraph { + return (element) => ({ + kind: "paragraph", + runs: [{ text: attrValue(element, "id") ?? "" }], + }); + } + + it("reads a text:p item, attaching the given membership", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "a" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]!.list).toEqual({ numId: "list1", level: 0 }); + }); + + it("reads a text:h item exactly the same way as a text:p item", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:h", { id: "a" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + }); + + it("skips a non-text:list-item child of the list element", () => { + const list = el("text:list", {}, [ + el("text:list-header", {}, [el("text:p", { id: "skip-me" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toEqual([]); + }); + + it("skips a non-element child inside a list item", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + { type: "text", value: "stray text" }, + el("text:p", { id: "a" }), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + }); + + it("ignores an item child that is neither text:p/text:h nor text:list, even one shaped like a nested list inside", () => { + // The nested tag ("text:list-header") carries its own text:list-item/text:p descendants specifically so a version that recursed into ANY non-text:p/text:h child (rather than only a genuine text:list) would find and surface this paragraph -- an empty decoy element couldn't tell the two apart. + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + el("text:list-header", {}, [ + el("text:list-item", {}, [el("text:p", { id: "decoy" })]), + ]), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toEqual([]); + }); + + it("recurses into a nested text:list, incrementing level but keeping the SAME numId", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + el("text:p", { id: "outer" }), + el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "inner" })]), + ]), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]!.list).toEqual({ numId: "list1", level: 0 }); + expect(paragraphs[1]!.list).toEqual({ numId: "list1", level: 1 }); + }); + + it("preserves document order across multiple items and nesting", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "first" })]), + el("text:list-item", {}, [el("text:p", { id: "second" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs.map((p) => p.runs[0]!.text)).toEqual(["first", "second"]); + }); +}); diff --git a/packages/odf.js/src/typed/shared/list.ts b/packages/odf.js/src/typed/shared/list.ts index cc0212e5d..921f0b735 100644 --- a/packages/odf.js/src/typed/shared/list.ts +++ b/packages/odf.js/src/typed/shared/list.ts @@ -168,8 +168,9 @@ export function writeOdfList( while (openLists.length - 1 < level) { const enclosing = openLists[openLists.length - 1]!; const lastChild = enclosing.children[enclosing.children.length - 1]; + // No separate check that lastChild's own tag is "text:list-item": every element this function ever pushes onto an "enclosing" list's children is one, via the enclosing.children.push(host) call a few lines below, so an element found here already carries no other tag to distinguish from it. let host: XmlElement; - if (lastChild?.type === "element" && lastChild.tag === "text:list-item") { + if (lastChild?.type === "element") { host = lastChild; } else { host = el("text:list-item"); diff --git a/packages/odf.js/src/typed/shared/masterpage.test.ts b/packages/odf.js/src/typed/shared/masterpage.test.ts index 929bec1f5..cc8093c35 100644 --- a/packages/odf.js/src/typed/shared/masterpage.test.ts +++ b/packages/odf.js/src/typed/shared/masterpage.test.ts @@ -98,6 +98,61 @@ describe("resolveDrawPageSize", () => { expect(resolveDrawPageSize(page, pkg)).toBeUndefined(); }); + it("does not resolve a nameless style:master-page when the page itself has no draw:master-page-name", () => { + // A style:master-page with no style:name at all would make attrValue(element, "style:name") itself resolve to undefined -- coincidentally equal to an undefined masterPageName -- if findMasterPageElement didn't short-circuit before ever reaching the search. + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:automatic-styles", {}, [ + el("style:page-layout", { "style:name": "PM1" }, [ + el("style:page-layout-properties", { + "fo:page-width": "720pt", + "fo:page-height": "540pt", + }), + ]), + ]), + el("office:master-styles", {}, [ + el("style:master-page", { "style:page-layout-name": "PM1" }), + ]), + ]), + ], + }, + }, + }; + expect(resolveDrawPageSize(el("draw:page"), pkg)).toBeUndefined(); + }); + + it("does not resolve a nameless style:page-layout when the master page itself has no style:page-layout-name", () => { + // Mirrors the case above one link further down the chain: a style:page-layout with no style:name at all would coincidentally match an undefined pageLayoutName if findPageLayoutElement didn't short-circuit first. + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:automatic-styles", {}, [ + el("style:page-layout", {}, [ + el("style:page-layout-properties", { + "fo:page-width": "720pt", + "fo:page-height": "540pt", + }), + ]), + ]), + el("office:master-styles", {}, [ + el("style:master-page", { "style:name": "Default" }), + ]), + ]), + ], + }, + }, + }; + const page = el("draw:page", { "draw:master-page-name": "Default" }); + expect(resolveDrawPageSize(page, pkg)).toBeUndefined(); + }); + it("returns undefined when there is no styles.xml part at all", () => { const page = el("draw:page", { "draw:master-page-name": "Default" }); expect(resolveDrawPageSize(page, { parts: {} })).toBeUndefined(); diff --git a/packages/odf.js/src/typed/shared/metadata.test.ts b/packages/odf.js/src/typed/shared/metadata.test.ts index 374b8f905..4dfa944f3 100644 --- a/packages/odf.js/src/typed/shared/metadata.test.ts +++ b/packages/odf.js/src/typed/shared/metadata.test.ts @@ -6,6 +6,9 @@ import { readOdfMetadata, hasOdfMetadata, patchOdfMetadata, + writeOdfMetadata, + buildOdfMetaNodes, + ensureNamespaceDeclared, META_PART, } from "./metadata"; @@ -78,7 +81,8 @@ describe("readOdfMetadata", () => { }); it("returns an empty object for a well-formed but entirely empty office:meta -- an empty office:meta is valid ODF, not an error", () => { - expect(readOdfMetadata(metaPackage([]))).toEqual({}); + // toStrictEqual, not toEqual: toEqual ignores explicit undefined-valued properties, so it can't tell a genuinely absent key apart from one of the six field guards below wrongly firing and setting metadata. = undefined -- toStrictEqual treats that as a real, distinguishable difference from {}. + expect(readOdfMetadata(metaPackage([]))).toStrictEqual({}); }); // dc:title / meta:initial-creator / dc:subject / dc:date / meta:generator values below are copied verbatim (real LibreOffice 26.2.5.2 output) from Modern_business_letter_serif.ott and CV.ott, two of LibreOffice's own bundled templates under /Applications/LibreOffice.app/Contents/Resources/template/**; meta:creation-date's value is likewise a real LibreOffice-produced timestamp copied from the same template. See this module's own top-of-file note on how meta:initial-creator vs. dc:creator, and meta:keyword's one-element-per-keyword shape, were confirmed against those real files. @@ -215,6 +219,109 @@ describe("readOdfMetadata", () => { }); }); +describe("buildOdfMetaNodes / writeOdfMetadata", () => { + it("an entirely empty LayoutMetadata writes no field element at all", () => { + const nodes = buildOdfMetaNodes({}, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect(officeMetaOf(pkg).children).toEqual([]); + }); + + it("writes exactly one element per stated field, one field at a time", () => { + const fields: [keyof Parameters[0], string][] = [ + ["title", "dc:title"], + ["subject", "dc:subject"], + ["author", "meta:initial-creator"], + ["creator", "meta:generator"], + ["createdIso", "meta:creation-date"], + ["modifiedIso", "dc:date"], + ["language", "dc:language"], + ]; + for (const [field, tag] of fields) { + const nodes = buildOdfMetaNodes({ [field]: "value" }, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect( + officeMetaOf(pkg).children.map((c) => + c.type === "element" ? c.tag : c.type, + ), + ).toEqual([tag]); + } + }); + + it("writes one meta:keyword element per keyword, in order", () => { + const nodes = buildOdfMetaNodes({ keywords: ["a", "b", "c"] }, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect(readOdfMetadata(pkg).keywords).toEqual(["a", "b", "c"]); + }); + + it("the declaration node states XML 1.0 UTF-8, and the root is office:document-meta at the given version", () => { + const nodes = buildOdfMetaNodes({}, "1.3"); + expect(nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + const root = nodes.find( + (node): node is XmlElement => node.type === "element", + ); + expect(root?.tag).toBe("office:document-meta"); + expect( + root?.attributes.find((a) => a.name === "office:version")?.value, + ).toBe("1.3"); + }); + + it("writeOdfMetadata sets the package's meta.xml part, readable back through readOdfMetadata", () => { + const pkg: Package = { parts: {} }; + writeOdfMetadata(pkg, { title: "Written title" }, "1.3"); + expect(readOdfMetadata(pkg).title).toBe("Written title"); + }); + + it("writeOdfMetadata replaces an existing meta.xml part outright, rather than merging", () => { + const pkg = metaPackage([el("dc:title", {}, [txt("Stale title")])]); + writeOdfMetadata(pkg, { subject: "Fresh subject" }, "1.3"); + const metadata = readOdfMetadata(pkg); + expect(metadata.title).toBeUndefined(); + expect(metadata.subject).toBe("Fresh subject"); + }); +}); + +describe("ensureNamespaceDeclared", () => { + it("does nothing for a tag with no colon at all", () => { + // A colonless tag has no prefix to declare a namespace for at all -- this deliberately picks a tag ("dcX") whose LAST character, if the leading-colon guard were skipped, would slice down to the real prefix "dc" and wrongly declare xmlns:dc; the correct behaviour is to return before ever reaching that slice. + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "dcX"); + expect(root.attributes).toEqual([]); + }); + + it("does nothing for a recognised prefix's own namespace when it is already declared", () => { + const root = el("office:document-meta", { + "xmlns:dc": "http://purl.org/dc/elements/1.1/", + }); + ensureNamespaceDeclared(root, "dc:title"); + expect(root.attributes.filter((a) => a.name === "xmlns:dc")).toHaveLength( + 1, + ); + }); + + it("declares the namespace for a recognised prefix that is not yet declared", () => { + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "meta:initial-creator"); + expect(root.attributes).toEqual([ + { + name: "xmlns:meta", + value: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", + }, + ]); + }); + + it("does nothing for a prefix outside the dc:/meta: vocabulary this module can newly introduce", () => { + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "office:unknown-field"); + expect(root.attributes).toEqual([]); + }); +}); + describe("hasOdfMetadata", () => { it("is true for a package carrying a real meta.xml XML part", () => { expect(hasOdfMetadata(metaPackage())).toBe(true); @@ -296,13 +403,15 @@ describe("patchOdfMetadata", () => { ]); }); - it("removes every meta:keyword element when patched with an empty array, rather than leaving a stale one", () => { + it("removes every meta:keyword element when patched with an empty array, rather than leaving a stale one, and leaves every OTHER element in office:meta untouched", () => { const pkg = metaPackage([ + el("dc:title", {}, [txt("Untouched title")]), el("meta:keyword", {}, [txt("alpha")]), el("meta:keyword", {}, [txt("beta")]), ]); patchOdfMetadata(pkg, { keywords: [] }); expect(readOdfMetadata(pkg).keywords).toBeUndefined(); + expect(readOdfMetadata(pkg).title).toBe("Untouched title"); expect( officeMetaOf(pkg).children.some( (c) => c.type === "element" && c.tag === "meta:keyword", @@ -329,6 +438,19 @@ describe("patchOdfMetadata", () => { expect(readOdfMetadata(pkg).author).toBe("New author"); }); + it("declares xmlns:meta on office:document-meta when patching keywords into a meta.xml that only ever declared dc:", () => { + // The keywords loop calls ensureNamespaceDeclared itself (unlike title/author/subject, whose declaration goes through setElementText), so this pins that call's own "meta:keyword" prefix argument directly, distinct from the author-path test above. + const pkg = metaPackage([el("dc:title", {}, [txt("Existing title")])], { + "xmlns:office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", + "xmlns:dc": "http://purl.org/dc/elements/1.1/", + }); + patchOdfMetadata(pkg, { keywords: ["alpha"] }); + const root = documentMetaRootOf(pkg); + expect(root.attributes.find((a) => a.name === "xmlns:meta")?.value).toBe( + "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", + ); + }); + it("does not duplicate an xmlns declaration the root already carries", () => { const pkg = metaPackage([], { "xmlns:office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", @@ -347,6 +469,15 @@ describe("patchOdfMetadata", () => { }).toThrow(/has no 'meta\.xml' XML part/); }); + it("throws when meta.xml is an XML part with no root element at all", () => { + const pkg: Package = { + parts: { [META_PART]: { kind: "xml", nodes: [] } }, + }; + expect(() => { + patchOdfMetadata(pkg, { title: "x" }); + }).toThrow(/has no root element/); + }); + it("throws when meta.xml has no office:meta element", () => { const pkg: Package = { parts: { diff --git a/packages/odf.js/src/typed/shared/metadata.ts b/packages/odf.js/src/typed/shared/metadata.ts index 711c4b4d3..cd8bb52d5 100644 --- a/packages/odf.js/src/typed/shared/metadata.ts +++ b/packages/odf.js/src/typed/shared/metadata.ts @@ -183,7 +183,7 @@ const META_NAMESPACE_URI_FOR_PREFIX: Readonly> = { }; // Ensures `root` (office:document-meta) declares the xmlns binding a newly appended element's prefix needs -- the ODF-side mirror of ooxml.js's own ensureNamespaceDeclared. A legally-minimal meta.xml declaring only office:+dc: (a producer that has only ever written dc:title) would otherwise gain an unbound meta:initial-creator/meta:keyword child on its first author/keywords patch -- a fatal XML namespace well-formedness error real consumers (LibreOffice) reject outright. -function ensureNamespaceDeclared(root: XmlElement, tag: string): void { +export function ensureNamespaceDeclared(root: XmlElement, tag: string): void { const colonIndex = tag.indexOf(":"); if (colonIndex === -1) { return; diff --git a/packages/odf.js/src/typed/shared/paragraph.test.ts b/packages/odf.js/src/typed/shared/paragraph.test.ts index 31c7ceeb8..984758073 100644 --- a/packages/odf.js/src/typed/shared/paragraph.test.ts +++ b/packages/odf.js/src/typed/shared/paragraph.test.ts @@ -826,6 +826,26 @@ describe("readOdfParagraph: run-level construct extents (fields, bookmarks)", () }); }); + it("mints sequentially INCREASING names across multiple unnamed notes, not the same name reused or a decreasing counter", () => { + const note = (text: string) => + el("text:note", { "text:note-class": "footnote" }, [ + el("text:note-citation", {}, [txt(text)]), + ]); + const p = el("text:p", {}, [note("1"), note("2")]); + const sink: OdfDefinitionsSink = { + entries: {}, + nextNoteOrdinal: 1, + nextAnnotationOrdinal: 1, + }; + const paragraph = readOdfParagraph(p, { parts: {} }, { definitions: sink }); + expect( + paragraph.constructs?.map((c) => + c.descriptor.kind === "anchor" ? c.descriptor.name : undefined, + ), + ).toEqual(["note1", "note2"]); + expect(Object.keys(sink.entries)).toEqual(["note:note1", "note:note2"]); + }); + it("reads an unnamed office:annotation as a point comment anchor at its run position, with its body and author in the definitions sink", () => { const annotation = el("office:annotation", {}, [ el("dc:creator", {}, [txt("C. Reviewer")]), @@ -862,6 +882,22 @@ describe("readOdfParagraph: run-level construct extents (fields, bookmarks)", () }); }); + it("mints sequentially INCREASING names across multiple unnamed annotations, not the same name reused or a decreasing counter", () => { + const annotation = (text: string) => + el("office:annotation", {}, [el("text:p", {}, [txt(text)])]); + const p = el("text:p", {}, [annotation("first"), annotation("second")]); + const sink: OdfDefinitionsSink = { + entries: {}, + nextNoteOrdinal: 1, + nextAnnotationOrdinal: 1, + }; + readOdfParagraph(p, { parts: {} }, { definitions: sink }); + expect(Object.keys(sink.entries)).toEqual([ + "comment:annotation1", + "comment:annotation2", + ]); + }); + it("assembles an annotation body's paragraphs and list items in document order, not paragraphs-then-lists", () => { const annotation = el("office:annotation", {}, [ el("dc:creator", {}, [txt("C. Reviewer")]), diff --git a/packages/odf.js/src/typed/shared/table.test.ts b/packages/odf.js/src/typed/shared/table.test.ts index 04d730d00..475e84753 100644 --- a/packages/odf.js/src/typed/shared/table.test.ts +++ b/packages/odf.js/src/typed/shared/table.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; +import type { ContentTable, ContentTableCell } from "document-schema.js"; import { el, txt } from "../../xml/fragment"; -import { readOdfTable } from "./table"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import { + readOdfTable, + readCellStyleDecoration, + writeOdfTable, + type OdfTableWriteContext, +} from "./table"; // Grammar verified against a real LibreOffice-generated .odp: a presentation's own draw:frame-wrapped table uses table:table/table:table-column/table:table-row/table:table-cell/table:covered-table-cell, column width via table:table-column's own table:style-name -> a style:family="table-column" style:style's style:table-column-properties/@style:column-width, row height the analogous table:family="table-row"/style:table-row-properties/@style:row-height -- and, notably, a real saved table frame carries an EXTRA sibling draw:image (an .svm fallback preview) alongside table:table, which shapes.ts's own readDrawFrameContent (not this module) is responsible for not mistaking for the frame's real content. @@ -376,3 +384,555 @@ describe("readOdfTable: overall shape", () => { }); }); }); + +describe("readOdfTable: repeat-count edge cases (readRepeatCount)", () => { + it("a zero repeated count is invalid and falls back to a single entry, not zero entries", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "0" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a negative repeated count is invalid and falls back to a single entry", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "-3" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a non-numeric repeated count is invalid and falls back to a single entry", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "abc" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a genuinely positive repeated count on a row is honoured in full, not truncated", () => { + const table = el("table:table", {}, [ + el("table:table-row", { "table:number-rows-repeated": "4" }, [cell("x")]), + ]); + expect(readOdfTable(table, { parts: {} }).rows).toHaveLength(4); + }); +}); + +describe("readCellStyleDecoration", () => { + function cellPropsStyle(attrs: Record): XmlElement { + return el("style:table-cell-properties", attrs); + } + + it("returns everything undefined for an empty element list", () => { + expect(readCellStyleDecoration([])).toEqual({ + background: undefined, + borders: undefined, + alignment: undefined, + verticalAlignment: undefined, + }); + }); + + it("returns everything undefined when the style element carries neither a table-cell-properties nor a paragraph-properties child", () => { + const styleElement = el("style:style", {}); + expect(readCellStyleDecoration([styleElement])).toEqual({ + background: undefined, + borders: undefined, + alignment: undefined, + verticalAlignment: undefined, + }); + }); + + it.each(["top", "middle", "bottom"] as const)( + "resolves style:vertical-align=%s", + (value) => { + const styleElement = el("style:style", {}, [ + cellPropsStyle({ "style:vertical-align": value }), + ]); + expect(readCellStyleDecoration([styleElement]).verticalAlignment).toBe( + value, + ); + }, + ); + + it('leaves verticalAlignment undefined for "automatic", the one enumerated ODF value ContentSheetCell has no member for', () => { + const styleElement = el("style:style", {}, [ + cellPropsStyle({ "style:vertical-align": "automatic" }), + ]); + expect( + readCellStyleDecoration([styleElement]).verticalAlignment, + ).toBeUndefined(); + }); + + it.each(["left", "center", "right", "justify"] as const)( + "resolves fo:text-align=%s from a sibling style:paragraph-properties child", + (value) => { + const styleElement = el("style:style", {}, [ + el("style:paragraph-properties", { "fo:text-align": value }), + ]); + expect(readCellStyleDecoration([styleElement]).alignment).toBe(value); + }, + ); + + it('leaves alignment undefined for a fo:text-align value this package does not model (e.g. ODF\'s own "start")', () => { + const styleElement = el("style:style", {}, [ + el("style:paragraph-properties", { "fo:text-align": "start" }), + ]); + expect(readCellStyleDecoration([styleElement]).alignment).toBeUndefined(); + }); + + it("folds background/alignment/verticalAlignment across a multi-element chain, a later element overriding an earlier one", () => { + const base = el("style:style", {}, [ + cellPropsStyle({ + "fo:background-color": "#ff0000", + "style:vertical-align": "top", + }), + el("style:paragraph-properties", { "fo:text-align": "left" }), + ]); + const override = el("style:style", {}, [ + cellPropsStyle({ + "fo:background-color": "#00ff00", + "style:vertical-align": "bottom", + }), + el("style:paragraph-properties", { "fo:text-align": "right" }), + ]); + const decoration = readCellStyleDecoration([base, override]); + expect(decoration.background).toEqual({ + kind: "solid", + color: { r: 0, g: 1, b: 0 }, + }); + expect(decoration.verticalAlignment).toBe("bottom"); + expect(decoration.alignment).toBe("right"); + }); + + it("accumulates per-edge borders across a multi-element chain rather than only keeping the last element's own edges", () => { + const withLeft = el("style:style", {}, [ + cellPropsStyle({ "fo:border-left": "1pt solid #000000" }), + ]); + const withTop = el("style:style", {}, [ + cellPropsStyle({ "fo:border-top": "2pt dashed #ffffff" }), + ]); + const decoration = readCellStyleDecoration([withLeft, withTop]); + expect(decoration.borders?.left).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + style: "solid", + }); + expect(decoration.borders?.top).toEqual({ + color: { r: 1, g: 1, b: 1 }, + widthPt: 2, + style: "dashed", + }); + }); +}); + +describe("writeOdfTable", () => { + // Returns the write context alongside the minted elements, read back from the SAME automaticStyles element object registry.intern() pushes into -- the identical pattern styles/registry.test.ts's own automaticStylesOf establishes, rather than reaching into the registry's own private fields. + function writeContext(): { + context: OdfTableWriteContext; + mintedStyles: () => XmlElement[]; + } { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + let nextTable = 1; + return { + context: { + registry, + mintTableName: () => `Table${nextTable++}`, + mintListStyleName: (kind) => `L${kind}`, + }, + mintedStyles: () => + automaticStyles.children.filter( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:style", + ), + }; + } + + function paragraphCell(text: string): ContentTableCell { + return { blocks: [{ kind: "paragraph", runs: [{ text }] }] }; + } + + // attrValue itself requires a real XmlElement; every caller here is reading an attribute off a `.find`/array-index result that is legitimately `XmlElement | undefined` under noUncheckedIndexedAccess, so this short-circuits the same way optional chaining does rather than asserting the element is present. + function attr( + element: XmlElement | undefined, + name: string, + ): string | undefined { + return element === undefined ? undefined : attrValue(element, name); + } + + function elementsWithTag(nodes: XmlElement["children"], tag: string) { + return nodes.filter( + (n): n is XmlElement => n.type === "element" && n.tag === tag, + ); + } + + it("mints a document-unique table:name from the context on every call", () => { + const { context } = writeContext(); + const table: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [], + }; + const first = writeOdfTable(table, context); + const second = writeOdfTable(table, context); + expect(attrValue(first, "table:name")).toBe("Table1"); + expect(attrValue(second, "table:name")).toBe("Table2"); + }); + + it("writes one table:table-column per column width, with no style-name for a non-positive width", () => { + const table: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [0, 100], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const columns = elementsWithTag(written.children, "table:table-column"); + expect(columns).toHaveLength(2); + expect(attr(columns[0], "table:style-name")).toBeUndefined(); + expect(attr(columns[1], "table:style-name")).toBeDefined(); + }); + + it("writes a table:style-name on a row only when it carries a heightPt", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { cells: [paragraphCell("a")] }, + { cells: [paragraphCell("b")], heightPt: 20 }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const rows = elementsWithTag(written.children, "table:table-row"); + expect(attr(rows[0], "table:style-name")).toBeUndefined(); + expect(attr(rows[1], "table:style-name")).toBeDefined(); + }); + + it("writes a style:width on the table's own style only when the columns state a positive total width", () => { + const withWidth: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [50, 50], + }; + const withoutWidth: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [], + }; + const { context: ctxWith, mintedStyles: stylesWith } = writeContext(); + writeOdfTable(withWidth, ctxWith); + const tableStyleWith = stylesWith().find( + (s) => attrValue(s, "style:family") === "table", + ); + const propsWith = tableStyleWith?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-properties", + ); + expect(attr(propsWith, "style:width")).toBeDefined(); + + const { context: ctxWithout, mintedStyles: stylesWithout } = writeContext(); + writeOdfTable(withoutWidth, ctxWithout); + const tableStyleWithout = stylesWithout().find( + (s) => attrValue(s, "style:family") === "table", + ); + const propsWithout = tableStyleWithout?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-properties", + ); + expect(attr(propsWithout, "style:width")).toBeUndefined(); + }); + + it("marks a colSpan'd cell's own covered neighbour, writing it as table:covered-table-cell rather than repeating the anchor's content", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "anchor" }] }], + colSpan: 2, + }, + paragraphCell("skipped"), + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const rowCells = + row === undefined + ? [] + : row.children.filter((n): n is XmlElement => n.type === "element"); + expect(rowCells[0]?.tag).toBe("table:table-cell"); + expect(attr(rowCells[0], "table:number-columns-spanned")).toBe("2"); + expect(rowCells[1]?.tag).toBe("table:covered-table-cell"); + }); + + it("marks a rowSpan'd cell's own covered neighbour in the row below, writing it as table:covered-table-cell", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "anchor" }] }], + rowSpan: 2, + }, + paragraphCell("sibling"), + ], + }, + { cells: [paragraphCell("covered"), paragraphCell("plain")] }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const rows = elementsWithTag(written.children, "table:table-row"); + const row1Cells = + rows[1] === undefined + ? [] + : rows[1].children.filter((n): n is XmlElement => n.type === "element"); + expect(row1Cells[0]?.tag).toBe("table:covered-table-cell"); + expect(row1Cells[1]?.tag).toBe("table:table-cell"); + }); + + it("writes table:number-columns-spanned/table:number-rows-spanned only when the cell actually states a span", () => { + const table: ContentTable = { + kind: "table", + rows: [{ cells: [paragraphCell("plain")] }], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + expect(attr(writtenCell, "table:number-columns-spanned")).toBeUndefined(); + expect(attr(writtenCell, "table:number-rows-spanned")).toBeUndefined(); + }); + + it("writes a table:style-name on a cell only when it carries background or borders", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + paragraphCell("plain"), + { + blocks: [{ kind: "paragraph", runs: [{ text: "filled" }] }], + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const cells = + row === undefined + ? [] + : row.children.filter((n): n is XmlElement => n.type === "element"); + expect(attr(cells[0], "table:style-name")).toBeUndefined(); + expect(attr(cells[1], "table:style-name")).toBeDefined(); + }); + + it("writes each per-edge border only for edges the cell actually states, leaving the others absent", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "bordered" }] }], + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context, mintedStyles } = writeContext(); + writeOdfTable(table, context); + const cellStyleEl = mintedStyles().find( + (s) => attrValue(s, "style:family") === "table-cell", + ); + const props = cellStyleEl?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-cell-properties", + ); + expect(attr(props, "fo:border-left")).toBeDefined(); + expect(attr(props, "fo:border-top")).toBeUndefined(); + expect(attr(props, "fo:border-right")).toBeUndefined(); + expect(attr(props, "fo:border-bottom")).toBeUndefined(); + }); + + it("groups consecutive same-list paragraphs into one text:list, closing it when membership changes", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "item1" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "item2" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { kind: "paragraph", runs: [{ text: "plain" }] }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const cellChildren = + writtenCell === undefined + ? [] + : writtenCell.children.filter( + (n): n is XmlElement => n.type === "element", + ); + expect(cellChildren).toHaveLength(2); + expect(cellChildren[0]?.tag).toBe("text:list"); + expect(cellChildren[0]?.children).toHaveLength(2); + expect(cellChildren[1]?.tag).toBe("text:p"); + }); + + it("closes an open list and starts a fresh one when membership switches to a different numId", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "ordered:list2", level: 0 }, + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const cellChildren = + writtenCell === undefined + ? [] + : writtenCell.children.filter( + (n): n is XmlElement => n.type === "element", + ); + expect(cellChildren).toHaveLength(2); + expect(cellChildren[0]?.tag).toBe("text:list"); + expect(cellChildren[1]?.tag).toBe("text:list"); + }); + + it("writes a nested table found inside a cell by recursing into writeOdfTable, minting its own table:name off the same document-wide counter", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "table", + rows: [{ cells: [paragraphCell("nested")] }], + columnWidthsPt: [], + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + expect(attrValue(written, "table:name")).toBe("Table1"); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const nestedTable = + writtenCell === undefined + ? undefined + : writtenCell.children.find( + (n): n is XmlElement => + n.type === "element" && n.tag === "table:table", + ); + expect( + nestedTable === undefined + ? undefined + : attrValue(nestedTable, "table:name"), + ).toBe("Table2"); + }); + + it("refuses to write a cell block kind readTableCell could never read back, naming the offending kind", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + expect(() => writeOdfTable(table, context)).toThrow(/image/); + }); +}); diff --git a/packages/odf.js/src/typed/shared/table.ts b/packages/odf.js/src/typed/shared/table.ts index 3f13527de..09cba17e0 100644 --- a/packages/odf.js/src/typed/shared/table.ts +++ b/packages/odf.js/src/typed/shared/table.ts @@ -51,8 +51,9 @@ function readRepeatCount(element: XmlElement, attrName: string): number { if (raw === undefined) { return 1; } + // Number.parseInt always returns an integer or NaN, and NaN > 0 is false like every other NaN comparison, so a separate Number.isInteger guard adds nothing a plain > 0 check doesn't already cover on its own. const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; + return parsed > 0 ? parsed : 1; } // A column with no resolvable width (no table:style-name, no matching style, or a style with no style:table-column-properties/@style:column-width) defaults to 0pt, mirroring ooxml.js's own readTable (`emuToPt(Number(attr(col, 'w') ?? '0'))`) -- an established, deliberate sibling-reader convention, not a fallback invented here. diff --git a/packages/odf.js/src/typed/shared/text.test.ts b/packages/odf.js/src/typed/shared/text.test.ts index 53f2223de..4802dcdeb 100644 --- a/packages/odf.js/src/typed/shared/text.test.ts +++ b/packages/odf.js/src/typed/shared/text.test.ts @@ -29,6 +29,22 @@ describe("getOdfSpaceCount", () => { getOdfSpaceCount(el("text:s", { "text:c": "not-a-number" })), ).toThrow(/malformed/); }); + + it("throws for a negative text:c", () => { + expect(() => getOdfSpaceCount(el("text:s", { "text:c": "-1" }))).toThrow( + /malformed/, + ); + }); + + it("throws for a text:c that isn't parseInt's own canonical spelling of its value, e.g. a leading zero", () => { + expect(() => getOdfSpaceCount(el("text:s", { "text:c": "05" }))).toThrow( + /malformed/, + ); + }); + + it("accepts a text:c of exactly zero, a valid (if degenerate) space count", () => { + expect(getOdfSpaceCount(el("text:s", { "text:c": "0" }))).toBe(0); + }); }); describe("measureOdfNodeLength / sumOdfNodeLength", () => { @@ -59,6 +75,13 @@ describe("measureOdfNodeLength / sumOdfNodeLength", () => { expect(measureOdfNodeLength(el("text:title"))).toBe(0); }); + it("ignores a zero-width marker's own children rather than recursing into them", () => { + // A bookmark carries no length of its own, but it's still an element that could, in principle, carry children — this pins that measureOdfNodeLength genuinely returns 0 for the whole node rather than merely happening to see an empty children array (el("text:bookmark") above has none, so that case alone can't tell "recurses into an empty list" apart from "never recurses at all"). + expect(measureOdfNodeLength(el("text:bookmark", {}, [txt("hidden")]))).toBe( + 0, + ); + }); + it("sums a flat node list", () => { expect( sumOdfNodeLength([txt("ab"), el("text:s", { "text:c": "2" }), txt("c")]), @@ -151,6 +174,16 @@ describe("decodeOdfText", () => { expect(decodeOdfText(paragraph)).toBe("ab"); }); + it("ignores a zero-width marker's own children rather than recursing into them", () => { + // As with measureOdfNodeLength above, an empty-children marker can't tell "recursed into nothing" apart from "never recursed" — this one carries real text so a wrongly-recursing implementation would leak it into the decoded output. + const paragraph = paragraphOf( + txt("a"), + el("text:bookmark-start", { "text:name": "mark" }, [txt("hidden")]), + txt("b"), + ); + expect(decodeOdfText(paragraph)).toBe("ab"); + }); + it("contributes nothing for a comment or CDATA node", () => { const paragraph = el("text:p", {}, [ txt("a"), diff --git a/packages/odf.js/src/typed/shared/text.ts b/packages/odf.js/src/typed/shared/text.ts index 9f05eaed3..1deee03b0 100644 --- a/packages/odf.js/src/typed/shared/text.ts +++ b/packages/odf.js/src/typed/shared/text.ts @@ -205,8 +205,9 @@ export function segmentOdfText( index += 1; continue; } + // No separate `end < text.length` bound: past the string's own end, `text[end]` is undefined, which is never `=== SPACE`, so the loop already stops there on its own -- a length check would only ever produce a result this comparison already produces. let end = index; - while (end < text.length && text[end] === SPACE) { + while (text[end] === SPACE) { end += 1; } const spaces = text.slice(index, end); diff --git a/packages/odf.js/src/typed/shared/transform.test.ts b/packages/odf.js/src/typed/shared/transform.test.ts index f2755c345..ccf2cded8 100644 --- a/packages/odf.js/src/typed/shared/transform.test.ts +++ b/packages/odf.js/src/typed/shared/transform.test.ts @@ -60,6 +60,21 @@ describe("parseOdfTransform", () => { expect(parseOdfTransform("")).toEqual([]); expect(parseOdfTransform("matrix(1 0 0 1 0 0)")).toEqual([]); }); + + it("skips rotate() called with no argument at all, rather than treating it as angle zero", () => { + expect(parseOdfTransform("rotate()")).toEqual([]); + }); + + it("does not parse an unmodelled function's own args as translate's, even when they'd otherwise look like valid lengths", () => { + // scale's own two arguments ("10pt 10pt") are deliberately unit-bearing here, unlike the other "skips a function this module does not model" case above (whose "2 2" scale args fail to parse as lengths either way) — this is the case that actually distinguishes "genuinely skipped because it isn't translate" from "accidentally parsed as translate and happened to succeed". + expect(parseOdfTransform("scale(10pt 10pt)")).toEqual([]); + }); + + it("collapses a run of several spaces between translate's own two arguments into one separator", () => { + expect(parseOdfTransform("translate(10pt 20pt)")).toEqual([ + { kind: "translate", xPt: 10, yPt: 20 }, + ]); + }); }); describe("applyOdfTransform: matches the real LibreOffice-rendered bounding box", () => { diff --git a/packages/odf.js/src/typed/shared/transform.ts b/packages/odf.js/src/typed/shared/transform.ts index 44d47d4bb..81dcab9d1 100644 --- a/packages/odf.js/src/typed/shared/transform.ts +++ b/packages/odf.js/src/typed/shared/transform.ts @@ -18,22 +18,21 @@ export interface OdfPoint { } const FUNCTION_PATTERN = /([a-zA-Z]+)\s*\(\s*([^)]*?)\s*\)/g; +// A function called with no arguments at all (e.g. "rotate()") has no tokens to extract, and every consumer below already treats a missing token (angleArg/xArg both undefined) and an unparseable one (a genuinely non-empty but garbage string) identically -- typed as the empty tuple `readonly []` so a content mutation here is a type error rather than a silent, unobservable survivor, exactly like build.ts's own NO_ORDERED_CONTENT. +const NO_ARGS: readonly [] = []; // Parses a draw:transform attribute value into its function list, in document order. A function this module doesn't model (scale/skewX/skewY/matrix), or one whose arguments don't parse (a malformed angle, a translate length outside the ODF `length` grammar), is skipped rather than aborting the whole parse -- the remaining, well-formed functions still contribute, matching this package's general "degrade a single unsupported feature, don't fail the whole read" policy. export function parseOdfTransform(value: string): OdfTransformFunction[] { const functions: OdfTransformFunction[] = []; for (const match of value.matchAll(FUNCTION_PATTERN)) { - const name = match[1]; - const argsRaw = match[2]; - if (name === undefined || argsRaw === undefined) { - continue; - } - const args = argsRaw.split(/\s+/).filter((arg) => arg.length > 0); + // FUNCTION_PATTERN's two capture groups are both plain, non-optional captures with no alternation that could skip them, so a successful match always populates both -- never undefined at runtime, only in the indexed-access type. + const name = match[1]!; + const argsRaw = match[2]!; + // Not argsRaw.split(/\s+/).filter(...): FUNCTION_PATTERN's own surrounding \s* already trims argsRaw of leading/trailing whitespace, so the only way split would otherwise misbehave is the classic "".split(...) === [""] case for a function called with no arguments at all (e.g. "rotate()") — handled explicitly here instead of by filtering every split result. + const args = argsRaw.length === 0 ? NO_ARGS : argsRaw.split(/\s+/); if (name === "rotate") { const angleArg = args[0]; - if (angleArg === undefined) { - continue; - } + // No separate `angleArg === undefined` guard: Number(undefined) is NaN, which the isFinite check below already rejects identically to a genuinely present but unparseable angle, so a missing argument needs no check of its own. const angleRad = Number(angleArg); if (!Number.isFinite(angleRad)) { continue; diff --git a/packages/odf.js/src/typed/shared/units.test.ts b/packages/odf.js/src/typed/shared/units.test.ts index 2f6ff3c3c..12644d2ae 100644 --- a/packages/odf.js/src/typed/shared/units.test.ts +++ b/packages/odf.js/src/typed/shared/units.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parseOdfLength, formatOdfLength } from "./units"; +import { + parseOdfLength, + formatOdfLength, + isLengthUnit, + parseOdfAngleDeg, + expandExponential, +} from "./units"; // The cm-based fixtures below ("real LibreOffice output") are copied verbatim from a real style:paragraph-properties element produced by `soffice --headless --convert-to odt` (LibreOffice 26.2.5.2), the same fixture referenced by src/styles/properties.test.ts -- see that file's own top-of-file note. @@ -36,6 +42,98 @@ describe("parseOdfLength", () => { }); }); +describe("isLengthUnit", () => { + it("accepts every one of the six real ODF length units", () => { + for (const unit of ["cm", "mm", "in", "pt", "pc", "px"]) { + expect(isLengthUnit(unit)).toBe(true); + } + }); + + it("rejects a unit outside the ODF length grammar", () => { + expect(isLengthUnit("em")).toBe(false); + expect(isLengthUnit("")).toBe(false); + expect(isLengthUnit("PT")).toBe(false); + }); +}); + +describe("expandExponential", () => { + it("passes non-exponent text through unchanged", () => { + expect(expandExponential("12")).toBe("12"); + expect(expandExponential("abc")).toBe("abc"); + }); + + it("pointIndex exactly 0 (the <= 0 boundary): a leading zero with no extra padding digits", () => { + expect(expandExponential("1e-1")).toBe("0.1"); + }); + + it("pointIndex strictly negative: leading zero padding beyond the single digit", () => { + expect(expandExponential("1e-2")).toBe("0.01"); + }); + + it("pointIndex exactly equal to digits.length (the >= boundary): no trailing zero padding needed", () => { + expect(expandExponential("1e0")).toBe("1"); + }); + + it("pointIndex strictly greater than digits.length: trailing zero padding", () => { + expect(expandExponential("1e1")).toBe("10"); + }); + + it("pointIndex strictly between 0 and digits.length: a real decimal point insertion", () => { + expect(expandExponential("1.5e0")).toBe("1.5"); + }); + + it("carries a negative sign through every branch", () => { + expect(expandExponential("-1.5e2")).toBe("-150"); + expect(expandExponential("-1e-1")).toBe("-0.1"); + }); + + it("accepts an uppercase E", () => { + expect(expandExponential("1E1")).toBe("10"); + }); + + it("does not match a valid exponential form buried inside a larger, non-exponential string (the leading ^ anchor)", () => { + expect(expandExponential("garbage1e5")).toBe("garbage1e5"); + }); + + it("does not match a valid exponential prefix followed by trailing garbage (the trailing $ anchor)", () => { + expect(expandExponential("1e5xxx")).toBe("1e5xxx"); + }); + + it("requires the integer part to be entirely digits up to the exponent marker, not just its first digit", () => { + expect(expandExponential("12e5")).toBe("1200000"); + }); +}); + +describe("parseOdfAngleDeg", () => { + it("a bare number with no unit suffix is already degrees", () => { + expect(parseOdfAngleDeg("90")).toBe(90); + expect(parseOdfAngleDeg("-45")).toBe(-45); + expect(parseOdfAngleDeg("0.5")).toBe(0.5); + expect(parseOdfAngleDeg(".5")).toBe(0.5); + }); + + it('an explicit "deg" suffix is a no-op conversion', () => { + expect(parseOdfAngleDeg("90deg")).toBe(90); + }); + + it("converts grad to degrees: 400 grad is a full turn, matching 360 degrees", () => { + expect(parseOdfAngleDeg("400grad")).toBe(360); + expect(parseOdfAngleDeg("200grad")).toBe(180); + expect(parseOdfAngleDeg("100grad")).toBe(90); + }); + + it("converts rad to degrees: pi radians is a half turn, matching 180 degrees", () => { + expect(parseOdfAngleDeg(`${Math.PI}rad`)).toBeCloseTo(180, 9); + expect(parseOdfAngleDeg(`${Math.PI / 2}rad`)).toBeCloseTo(90, 9); + }); + + it("returns undefined for a malformed angle", () => { + expect(parseOdfAngleDeg("auto")).toBeUndefined(); + expect(parseOdfAngleDeg("90degrees")).toBeUndefined(); + expect(parseOdfAngleDeg("")).toBeUndefined(); + }); +}); + describe("formatOdfLength", () => { it('defaults to "pt" when no unit is given', () => { expect(formatOdfLength(12)).toBe("12pt"); diff --git a/packages/odf.js/src/typed/shared/units.ts b/packages/odf.js/src/typed/shared/units.ts index 8b0dd15db..2e1b8b34b 100644 --- a/packages/odf.js/src/typed/shared/units.ts +++ b/packages/odf.js/src/typed/shared/units.ts @@ -33,7 +33,7 @@ function unitToPtFactor(unit: LengthUnit): number { } } -function isLengthUnit(value: string): value is LengthUnit { +export function isLengthUnit(value: string): value is LengthUnit { return ( value === "cm" || value === "mm" || @@ -50,30 +50,25 @@ export function parseOdfLength(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - const unit = match[2]; - if (numeric === undefined || unit === undefined || !isLengthUnit(unit)) { - return undefined; - } + // Both groups are MANDATORY alternatives in LENGTH_PATTERN (neither carries its own `?`), and group 2 is itself restricted to exactly the six LengthUnit spellings -- so numeric/unit can never be undefined, and unit can never fail isLengthUnit, once match is non-null; only TypeScript's own RegExpExecArray typing can't express that. Asserting rather than re-checking a condition the regex has already made unreachable, exactly like expandExponential below. + const numeric = match[1]!; + const unit = match[2] as LengthUnit; return Number(numeric) * unitToPtFactor(unit); } // JavaScript's own Number-to-string switches to EXPONENT notation outside a fixed magnitude window (below 1e-6, or at/above 1e21) -- `${-7.1e-15}` is "-7.1e-15", not "-0.0000000000000071". The ODF `length` datatype has NO exponent form at all (see LENGTH_PATTERN above, and the OASIS grammar it encodes), so a bare template-literal stringification silently emits spec-invalid ODF for any small-magnitude length. That is not a theoretical range: a rotated shape's own draw:transform translate() components are trig-derived (typed/draw/write-shapes.ts's frameGeometryAttrs), so a shape rotated about a point near the page origin routinely lands a component at 1e-15-ish rounding dust rather than a clean 0. The consequence on the way back in is silent and total: parseOdfTransform drops a translate() whose components don't parse (so the shape moves to the pivot), and parseBox returns undefined for an unrotated frame whose svg:x/svg:y don't parse (so readDrawFrame drops the shape entirely). // // The fix belongs here, on the write side, not in LENGTH_PATTERN: widening the reader to accept an exponent would make this package read its own invalid output back correctly while every other ODF consumer still saw a length outside the datatype. expandExponential below re-positions the decimal point in the digits Number-to-string ALREADY chose (the shortest round-tripping representation), so it is an exact re-spelling rather than a rounding step -- and since those digits never carry a trailing fractional zero, neither does the result, matching the plain-stringification style of every ordinary value. -function expandExponential(text: string): string { +export function expandExponential(text: string): string { const match = /^(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(text); if (match === null) { return text; } - const [, sign, integerDigits, fractionDigits, exponent] = match; - if ( - sign === undefined || - integerDigits === undefined || - exponent === undefined - ) { - return text; - } + // Every group but the third (the optional fractional digits) is a MANDATORY alternative in this pattern -- (-?) always matches (possibly empty), (\d+) and ([+-]?\d+) are plain quantifiers with no `?` of their own -- so sign/integerDigits/exponent can never actually be undefined once `match` itself is non-null; only TypeScript's own RegExpExecArray typing can't express that. Asserting rather than re-checking a condition the regex has already made unreachable keeps this a real branch (the fractional-digits one below) rather than a dead one no input can ever exercise. + const sign = match[1]!; + const integerDigits = match[2]!; + const fractionDigits = match[3]; + const exponent = match[4]!; const digits = `${integerDigits}${fractionDigits ?? ""}`; // Where the decimal point lands within `digits` once the exponent is applied: left of every digit (a pure fraction needing leading zeros), right of every digit (an integer needing trailing zeros), or between two of them. const pointIndex = integerDigits.length + Number(exponent); @@ -106,11 +101,8 @@ export function parseOdfAngleDeg(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - if (numeric === undefined) { - return undefined; - } - const raw = Number(numeric); + // match[1]'s own group has no `?` quantifier of its own (only the alternation inside it does), so it always matches once `match` itself is non-null -- the same mandatory-group guarantee expandExponential's own sign/integerDigits/exponent rely on above. + const raw = Number(match[1]!); switch (match[2]) { case "grad": return raw * DEGREES_PER_GRAD; diff --git a/packages/odf.js/src/util/base64.test.ts b/packages/odf.js/src/util/base64.test.ts new file mode 100644 index 000000000..128cb91bf --- /dev/null +++ b/packages/odf.js/src/util/base64.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { base64ToBytes, bytesToBase64 } from "./base64"; + +function bytesOfAscii(text: string): Uint8Array { + return new Uint8Array(Array.from(text, (c) => c.charCodeAt(0))); +} + +// The classic Wikipedia "Man"/"Many hands..." progressive vectors, computed via Node's own Buffer.from(s, "utf8").toString("base64"): one entry per length mod 4 (0, 1, 2, 3 leftover bytes) so every branch of bytesToBase64's per-3-byte padding logic gets a case where it is exercised both true and false. +const KNOWN_VECTORS: readonly [string, string][] = [ + ["", ""], + ["M", "TQ=="], + ["Ma", "TWE="], + ["Man", "TWFu"], + ["Many", "TWFueQ=="], + ["Many ", "TWFueSA="], + ["Many h", "TWFueSBo"], + ["Many ha", "TWFueSBoYQ=="], + ["Many han", "TWFueSBoYW4="], + ["Many hand", "TWFueSBoYW5k"], +]; + +describe("bytesToBase64", () => { + it.each(KNOWN_VECTORS)("encodes %j to %j", (text, expected) => { + expect(bytesToBase64(bytesOfAscii(text))).toBe(expected); + }); +}); + +describe("base64ToBytes", () => { + it.each(KNOWN_VECTORS)( + "decodes %2$j back to the bytes of %1$j", + (text, encoded) => { + expect(base64ToBytes(encoded)).toEqual(bytesOfAscii(text)); + }, + ); + + it("strips whitespace interspersed in the input before decoding", () => { + expect(base64ToBytes("TW Fu\n")).toEqual(bytesOfAscii("Man")); + }); + + it("throws when a '=' padding character appears in the first position of a 4-char group", () => { + expect(() => base64ToBytes("=BCD")).toThrow("invalid base64 input"); + }); + + it("throws when a '=' padding character appears in the second position of a 4-char group", () => { + expect(() => base64ToBytes("A=CD")).toThrow("invalid base64 input"); + }); + + it("bounds a malformed, non-4-multiple-length input to its declared scratch size rather than growing to fit it", () => { + // clean.length here is 5, one char short of a second full 4-char group: the loop's second iteration reads two out-of-range indices via charCodeAt (NaN, decoding to a byte anyway) and would write a 4th, 5th and 6th output byte past the 3-byte buffer ((5*3)/4|0 == 3) this input's own length declares, a Uint8Array silently drops writes past its own length rather than growing, so the result is exactly the first full group's 3 bytes, not whatever the malformed second group's partial contents happen to decode to. + expect(base64ToBytes("TWFuT")).toEqual(bytesOfAscii("Man")); + }); +}); diff --git a/packages/odf.js/src/util/base64.ts b/packages/odf.js/src/util/base64.ts index 3fe3feaa0..c44610443 100644 --- a/packages/odf.js/src/util/base64.ts +++ b/packages/odf.js/src/util/base64.ts @@ -16,8 +16,9 @@ export function bytesToBase64(bytes: Uint8Array): string { const len = bytes.length; for (let i = 0; i < len; i = i + 3) { const b0 = bytes[i]!; - const b1 = i + 1 < len ? bytes[i + 1]! : 0; - const b2 = i + 2 < len ? bytes[i + 2]! : 0; + // No separate `i + 1 < len` / `i + 2 < len` fallback to 0 here: past the array's own end, bytes[i + 1]/bytes[i + 2] are undefined, and `undefined >> n` coerces to 0 identically to the explicit fallback -- the padding decision below (the "=" ternaries) is what actually gates whether this position is ever rendered at all. + const b1 = bytes[i + 1]!; + const b2 = bytes[i + 2]!; out += TABLE.charAt(b0 >> 2); out += TABLE.charAt(((b0 & 0x03) << 4) | (b1 >> 4)); out += i + 1 < len ? TABLE.charAt(((b1 & 0x0f) << 2) | (b2 >> 6)) : "="; diff --git a/packages/odf.js/src/xml/build.test.ts b/packages/odf.js/src/xml/build.test.ts new file mode 100644 index 000000000..551b24705 --- /dev/null +++ b/packages/odf.js/src/xml/build.test.ts @@ -0,0 +1,161 @@ +import { XMLBuilder } from "fast-xml-parser"; +import { describe, expect, it, vi } from "vitest"; +import type { XmlNode } from "../model/node"; +import { buildXml, toOrderedNode } from "./build"; + +describe("buildXml", () => { + it("serialises a text node as bare text", () => { + expect(buildXml([{ type: "text", value: "hello" }])).toBe("hello"); + }); + + it("serialises a comment node, preserving its text verbatim", () => { + expect(buildXml([{ type: "comment", value: "a note" }])).toBe( + "", + ); + }); + + it("serialises a cdata node, preserving its text verbatim", () => { + expect(buildXml([{ type: "cdata", value: "raw & unescaped " }])).toBe( + "]]>", + ); + }); + + it("serialises a processing instruction from its target alone", () => { + // The underlying builder emits any "?"-prefixed key from the key and its ":@" attributes only, never from the value beside it — see build.ts's own comment on the pi/declaration cases. content is therefore deliberately absent from the output regardless of what it holds. + const withContent: XmlNode = { + type: "pi", + target: "xml-stylesheet", + content: 'type="text/xsl" href="styles.xsl"', + }; + expect(buildXml([withContent])).toBe(""); + expect( + buildXml([{ type: "pi", target: "xml-stylesheet", content: "" }]), + ).toBe(""); + }); + + it("serialises a declaration node from its attributes", () => { + expect( + buildXml([ + { + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }, + ]), + ).toBe(''); + }); + + it("serialises a declaration node with no attributes at all", () => { + expect(buildXml([{ type: "declaration", attributes: [] }])).toBe(""); + }); + + it("serialises a childless, attribute-less element as an empty tag pair", () => { + expect( + buildXml([{ type: "element", tag: "a", attributes: [], children: [] }]), + ).toBe(""); + }); + + it("omits the attribute map entirely when an element carries no attributes", () => { + // Confirms Object.keys(attrs).length > 0 gates the ":@" key: were it added unconditionally, the builder would still render identical text for a genuinely empty attrs object, so an equality check on the rendered string alone couldn't tell the two apart — what actually distinguishes them is that an element WITH attributes (below) proves the key does get added when there is something to add. + const withAttrs = buildXml([ + { + type: "element", + tag: "a", + attributes: [{ name: "href", value: "x" }], + children: [], + }, + ]); + expect(withAttrs).toBe(''); + }); + + it("serialises an element carrying several attributes, in given order", () => { + expect( + buildXml([ + { + type: "element", + tag: "a", + attributes: [ + { name: "x", value: "1" }, + { name: "y", value: "2" }, + ], + children: [{ type: "text", value: "body" }], + }, + ]), + ).toBe('body'); + }); + + it("serialises nested elements in document order", () => { + expect( + buildXml([ + { + type: "element", + tag: "outer", + attributes: [], + children: [ + { type: "element", tag: "inner", attributes: [], children: [] }, + ], + }, + ]), + ).toBe(""); + }); + + it("throws when the underlying builder does not return a string", () => { + // Spies on the shared prototype method rather than re-importing the module under a mock: a fresh import would re-run build.ts's own top-level `new XMLBuilder({...})` call inside this test's coverage window, which would make Stryker attribute (spurious) per-test coverage to that config object literal — exactly the kind of coverage that can never distinguish one config value from another, since the real constructor never runs during a mocked-import test. Spying on the prototype instead leaves BUILDER's own construction untouched and only intercepts the one call this test cares about. + const spy = vi + .spyOn(XMLBuilder.prototype, "build") + .mockReturnValueOnce({ not: "a string" } as unknown as string); + try { + expect(() => buildXml([{ type: "text", value: "x" }])).toThrow( + "XMLBuilder did not return a string", + ); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("toOrderedNode", () => { + it("omits the ':@' key entirely for an attribute-less element, rather than carrying an empty attrs object", () => { + // toEqual checks the object's exact own-property set: were the ":@" key added unconditionally (as an empty object), this would fail even though buildXml's own rendered XML string is identical either way — see build.test.ts's "omits the attribute map" case above, which pins the observable half of this same invariant. + expect( + toOrderedNode({ + type: "element", + tag: "a", + attributes: [], + children: [], + }), + ).toStrictEqual({ a: [] }); + }); + + it("adds the ':@' key once an element carries at least one attribute", () => { + expect( + toOrderedNode({ + type: "element", + tag: "a", + attributes: [{ name: "href", value: "x" }], + children: [], + }), + ).toStrictEqual({ a: [], ":@": { "@_href": "x" } }); + }); + + it("maps a pi node to its '?'-prefixed key holding an empty array, regardless of its own content", () => { + expect( + toOrderedNode({ + type: "pi", + target: "xml-stylesheet", + content: "ignored", + }), + ).toStrictEqual({ "?xml-stylesheet": [] }); + }); + + it("maps a declaration node to '?xml' holding an empty array plus its attributes", () => { + expect( + toOrderedNode({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }), + ).toStrictEqual({ "?xml": [], ":@": { "@_version": "1.0" } }); + }); +}); diff --git a/packages/odf.js/src/xml/build.ts b/packages/odf.js/src/xml/build.ts index a285ddef5..8c7b0174a 100644 --- a/packages/odf.js/src/xml/build.ts +++ b/packages/odf.js/src/xml/build.ts @@ -33,7 +33,11 @@ function attrsObject(attributes: Attribute[]): Record { return obj; } -function toOrderedNode(node: XmlNode): Record { +// The array Stryker's own ArrayDeclaration mutator would otherwise target at each of the pi/declaration return sites below, factored to one shared literal and pinned to the empty-tuple type `readonly []` so that any mutation of its contents (Stryker's own probe replaces `[]` with `["Stryker was here"]`) is a type error rather than a silent, unobservable survivor: fast-xml-builder reads neither node shape's own array value, only its ":@" attributes (see the case comments below), so no test on buildXml's output could ever distinguish an empty array here from a non-empty one. The type system rules the mutation out instead of a test having to. +const NO_ORDERED_CONTENT: readonly [] = []; + +// Exported so a test can pin the exact intermediate ordered-node shape directly — in particular that an attribute-less element's object carries no ":@" key at all, rather than one holding an empty object, a distinction fast-xml-builder itself never renders differently in the built XML string and so no output-equality test on buildXml could ever observe. +export function toOrderedNode(node: XmlNode): Record { switch (node.type) { case "text": return { "#text": node.value }; @@ -41,10 +45,12 @@ function toOrderedNode(node: XmlNode): Record { return { __comment: [{ "#text": node.value }] }; case "cdata": return { __cdata: [{ "#text": node.value }] }; + // fast-xml-builder (fast-xml-parser's own build engine) special-cases any "?"-prefixed key: it emits `` from the key and its ":@" attributes alone and never looks at the key's own array value, for a PI exactly as it does for the declaration case just below — confirmed directly against the library's own orderedJs2Xml.js, which branches on a leading "?" before ever touching a node's array/text content. node.content therefore never reaches the built string; an empty array is exactly as observable as any other value here; and there's no attribute for it to ride either, since a PI's content is free text rather than name/value pairs. This is a genuine limitation of the library, not a choice this codec makes — see the equivalent case below for the same reasoning restated over ?xml's own attributes. case "pi": - return { [`?${node.target}`]: [{ "#text": node.content }] }; + return { [`?${node.target}`]: NO_ORDERED_CONTENT }; + // Same "?"-prefixed-key rule as the pi case above: fast-xml-builder reads ?xml's attributes from ":@" and ignores whatever sits in its own array value entirely, so the array carries nothing observable either way. case "declaration": - return { "?xml": [{ "#text": "" }], ":@": attrsObject(node.attributes) }; + return { "?xml": NO_ORDERED_CONTENT, ":@": attrsObject(node.attributes) }; case "element": { const obj: Record = { [node.tag]: toOrdered(node.children), diff --git a/packages/odf.js/src/xml/parse.test.ts b/packages/odf.js/src/xml/parse.test.ts new file mode 100644 index 000000000..010f78c7d --- /dev/null +++ b/packages/odf.js/src/xml/parse.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { parseXml } from "./parse"; + +// parseXml had no direct unit tests at all -- every reader test in this package exercises it only indirectly, through a whole XML document string, which never isolates a single node kind's own mapping. These pin each of parseNode's own branches (text/comment/cdata/declaration/pi/element) directly against a minimal real XML string, plus attribute parsing and nested-element recursion. + +describe("parseXml: node kinds", () => { + it("parses a bare element with no children, attributes, or text", () => { + expect(parseXml("")).toEqual([ + { type: "element", tag: "a", attributes: [], children: [] }, + ]); + }); + + it("parses an element's own text content as a text node child", () => { + const [node] = parseXml("hello"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "text", value: "hello" }], + }); + }); + + it("parses a comment as its own comment node, not folded into surrounding text", () => { + const [node] = parseXml(""); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "comment", value: "a note" }], + }); + }); + + it("parses a CDATA section as its own cdata node with the raw text preserved", () => { + const [node] = parseXml("]]>"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "cdata", value: "raw " }], + }); + }); + + it("parses the leading as a declaration node carrying its own attributes", () => { + const [node] = parseXml(''); + expect(node).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + }); + + it("parses a non-xml processing instruction with its own target", () => { + const [, pi] = parseXml(''); + if (pi?.type !== "pi") { + throw new Error("expected a pi node"); + } + expect(pi.target).toBe("xml-stylesheet"); + expect(typeof pi.content).toBe("string"); + }); + + it("parses an element's own attributes, stripping the @_ prefix and preserving order", () => { + const [node] = parseXml(''); + expect(node).toMatchObject({ + type: "element", + tag: "a", + attributes: [ + { name: "x", value: "1" }, + { name: "y", value: "2" }, + ], + }); + }); + + it("parses an element with no attributes to an empty attributes array, not undefined", () => { + const [node] = parseXml(""); + expect(node).toMatchObject({ attributes: [] }); + }); + + it("recurses into nested elements, preserving document order across mixed element and text children", () => { + const [node] = parseXml("onetwo"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [ + { type: "text", value: "one" }, + { type: "element", tag: "b", attributes: [], children: [] }, + { type: "text", value: "two" }, + ], + }); + }); + + it("does not re-encode entities -- an already-encoded & comes back exactly as written", () => { + const [node] = parseXml("x & y"); + expect(node).toMatchObject({ + children: [{ type: "text", value: "x & y" }], + }); + }); + + it("does not trim leading/trailing whitespace out of text content", () => { + const [node] = parseXml(" padded "); + expect(node).toMatchObject({ + children: [{ type: "text", value: " padded " }], + }); + }); +}); diff --git a/packages/odf.js/stryker.config.ts b/packages/odf.js/stryker.config.ts index 2f5854b88..2a01a27d8 100644 --- a/packages/odf.js/stryker.config.ts +++ b/packages/odf.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 71.31% of 8442 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 69, + // Re-measured from a genuine cold full-package run (incremental cache deleted first): 91.10% of 8201 valid mutants (612 survived, 127 no-coverage, 110 timeout, 7452 killed), timeout share 1.34% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: 612 survivors and 127 no-coverage mutants remain across many files (see PR #1260 for the full file-by-file breakdown), concentrated in typed/shared/paragraph.ts, typed/ods/write.ts, typed/ods/read.ts, typed/odt/write.ts, typed/odt/read.ts, and typed/draw/shapes.ts -- raise this again once the next batch's own full run confirms the next real floor. + breakThreshold: 89, }); diff --git a/packages/rtf-codec/CHANGELOG.md b/packages/rtf-codec/CHANGELOG.md index f658cb6cc..38f54c9e8 100644 --- a/packages/rtf-codec/CHANGELOG.md +++ b/packages/rtf-codec/CHANGELOG.md @@ -1,3 +1,125 @@ +## [4.4.5](https://github.com/ExaDev/documents.js/compare/rtf-codec%404.4.4...rtf-codec%404.4.5) (2026-09-15) + +### Bug Fixes + +* **rtf-codec:** narrow ConstructDescriptor to its anchor member before reading name ([d50cb28](https://github.com/ExaDev/documents.js/commit/d50cb28bfc59ce484b1dfd2386e2824c54c45388)) + +### Code Refactoring + +* **rtf-codec:** call every control-word dispatcher unconditionally, drop their boolean returns ([3d94611](https://github.com/ExaDev/documents.js/commit/3d9461126fb48f58cac8413d44d711f6d57d0bae)) +* **rtf-codec:** consolidate control-word dispatchers' per-case return true ([f8a7ec1](https://github.com/ExaDev/documents.js/commit/f8a7ec1b153e542862cc268dbb90a5a07b2cf5c5)) +* **rtf-codec:** count braces via matchAll instead of a strip-then-split pass ([807946c](https://github.com/ExaDev/documents.js/commit/807946c8a39aa788aea751374e6b20895d8f4903)) +* **rtf-codec:** drop a second redundant objectState !== undefined guard ([cd43569](https://github.com/ExaDev/documents.js/commit/cd43569efae9324ffab145c7ad4e4ae488fb2921)) +* **rtf-codec:** drop bounds checks embedded-object.ts's own reads already make redundant ([75ad175](https://github.com/ExaDev/documents.js/commit/75ad1758522c06a4e2ba579df6e9e4c05a1b37c4)) +* **rtf-codec:** drop cell-format's no-op default switch case ([1d4a407](https://github.com/ExaDev/documents.js/commit/1d4a407859f78f45578410cc2768178d6d4e2d1b)) +* **rtf-codec:** drop codepage.ts's dead empty-input fast path and impossible fallbacks ([48e2ead](https://github.com/ExaDev/documents.js/commit/48e2eade7093c9666ac4a968c38d6e9b24937d69)) +* **rtf-codec:** drop columnWidths' own no-op slice bound ([eeee24f](https://github.com/ExaDev/documents.js/commit/eeee24fe5756deda891a895aebb034430b1c021d)) +* **rtf-codec:** drop countGroupBraces' redundant length bound ([c4b6d80](https://github.com/ExaDev/documents.js/commit/c4b6d80fb926c607ff3e15dd785c895b90bfd29b)) +* **rtf-codec:** drop embedded-object's redundant zero-writes ([c8a68b6](https://github.com/ExaDev/documents.js/commit/c8a68b67de55ef8f888ae3e2c63378b8adb4de0f)) +* **rtf-codec:** drop finish()'s own dead zero-sections fallback ([38ffcb9](https://github.com/ExaDev/documents.js/commit/38ffcb92355227d6872fee25d6490384fa387d1d)) +* **rtf-codec:** drop header.ts's redundant nameStart tracking ([61ea72b](https://github.com/ExaDev/documents.js/commit/61ea72b6611bf758d7bdb5b7381baf0a8d2695cc)) +* **rtf-codec:** drop nine conditional-spread constructions in read.ts ([ca52154](https://github.com/ExaDev/documents.js/commit/ca52154b456a368917e86fd3d6c6aebfb0e061a5)) +* **rtf-codec:** drop scanControlWord's redundant cursor bounds and vestigial textStart tracking ([2a77be6](https://github.com/ExaDev/documents.js/commit/2a77be6ec1ddefea2e71b5208a57d823e80d99a1)) +* **rtf-codec:** drop skipUnicodeFallback's unreachable mid-token entry ([4ac70e8](https://github.com/ExaDev/documents.js/commit/4ac70e87b3e2ab12255aa5ae5de73a5ff61b3293)) +* **rtf-codec:** drop takeRunConstructs' own no-op runs.length filter ([cc874a0](https://github.com/ExaDev/documents.js/commit/cc874a0734ecddb8b953534737f200d374e5418d)) +* **rtf-codec:** drop the resolveRows matchIndex===-1 special case ([450134d](https://github.com/ExaDev/documents.js/commit/450134d25518d0fd4555ac74ab4f5f0985413fda)) +* **rtf-codec:** drop the unreachable fallback in the single-byte codepage decode ([d7e70cf](https://github.com/ExaDev/documents.js/commit/d7e70cfbc2f221bf661842d38e3ebfbce6f5bc3b)) +* **rtf-codec:** drop three redundant destination checks at group-end ([35af891](https://github.com/ExaDev/documents.js/commit/35af891c45b946e1be5c16d112023820ecc1bfb0)) +* **rtf-codec:** drop three table-collection sorts that were always no-ops ([fed5385](https://github.com/ExaDev/documents.js/commit/fed5385404eb0365b612b54eb1078c1dd94c75e8)) +* **rtf-codec:** drop three unreachable or redundant DTTM/form-field checks ([a4b90ba](https://github.com/ExaDev/documents.js/commit/a4b90ba99b0b11bf2d76fd08bb5630ef8501d9ff)) +* **rtf-codec:** drop toggleValue's redundant undefined check ([49d0192](https://github.com/ExaDev/documents.js/commit/49d0192c0fd9223daddc9d6b5213de7822c4dc93)) +* **rtf-codec:** drop tokenize's redundant CRLF-merge step and cover two boundary gaps ([253b699](https://github.com/ExaDev/documents.js/commit/253b6995b1fddd44cce528f86350c1187a3c1c53)) +* **rtf-codec:** drop two more equivalent constructs in write.ts ([db5dbbc](https://github.com/ExaDev/documents.js/commit/db5dbbcc98a63f45d0bcb1ae4619a507f896159b)) +* **rtf-codec:** drop two more equivalent constructs in write.ts ([c2cfe4a](https://github.com/ExaDev/documents.js/commit/c2cfe4a5eaa3d8c6f83fecf2e2d0406852bb47a9)) +* **rtf-codec:** eliminate an equivalent loop-bound mutant in encodeAscii ([4d8be60](https://github.com/ExaDev/documents.js/commit/4d8be6081edb3d9b09bd75f4273913c476ff3eb3)) +* **rtf-codec:** eliminate equivalent-mutant loop bounds in header.ts ([e617a8d](https://github.com/ExaDev/documents.js/commit/e617a8d9d85ced7690d5b3fd18ca2442f94152f6)) +* **rtf-codec:** eliminate several irreducible-equivalent mutation opportunities in read.ts ([fc7be81](https://github.com/ExaDev/documents.js/commit/fc7be81e4ad8440d9ca68f8e7dac3f23ac695ae7)) +* **rtf-codec:** eliminate three more equivalent constructs in write.ts ([ae65bc7](https://github.com/ExaDev/documents.js/commit/ae65bc7837c8b5479b9d87e4c68d6089e442d669)) +* **rtf-codec:** key runs by JSON-encoded field tuple, not a delimited flag string ([20e921b](https://github.com/ExaDev/documents.js/commit/20e921bec8f5de548a4aadd9e753645856905946)) +* **rtf-codec:** pass coalesceRunConstructs' entries directly instead of re-looking them up ([da7fdef](https://github.com/ExaDev/documents.js/commit/da7fdefc228e8cfdd668a514f18cb1f2a7b98c7c)) +* **rtf-codec:** remove equivalent-mutant-prone guards and dead boolean returns ([ac05f21](https://github.com/ExaDev/documents.js/commit/ac05f21e2b84986b2e3bca8b828b58537afdb444)) +* **rtf-codec:** remove more embedded-object.ts checks and writes that never change the result ([a7fdb16](https://github.com/ExaDev/documents.js/commit/a7fdb168702d80e3f4015329191d96b1d452fb39)) +* **rtf-codec:** remove three no-op default:break clauses and a redundant surrogate offset ([ddab5b1](https://github.com/ExaDev/documents.js/commit/ddab5b11e705a0356f0a3d56b015d52b413c8305)) +* **rtf-codec:** remove two more redundant range guards in write.ts ([f803b2a](https://github.com/ExaDev/documents.js/commit/f803b2af2d33427bc71495b3a7aba0d18f98ae00)) +* **rtf-codec:** remove withoutInvalidSource's redundant record guard ([dc355f0](https://github.com/ExaDev/documents.js/commit/dc355f05eb42c7c9002b2c83b45eb4de3c4601de)) +* **rtf-codec:** replace borderControlWords' search with a direct side-to-word lookup ([3c8152d](https://github.com/ExaDev/documents.js/commit/3c8152d16cb3b709445ee70204e34f624723052a)) +* **rtf-codec:** replace brace-balance's manual escape scanner with a two-pass regex ([ed76fd9](https://github.com/ExaDev/documents.js/commit/ed76fd90ff8ce05113cfd6ab3ac19117a02f7288)) +* **rtf-codec:** stop bounding two byte-loops by a comparison Uint8Array already makes redundant ([53a3aee](https://github.com/ExaDev/documents.js/commit/53a3aee497ac9bc0e955d0fa9a98c1476d3574c9)) +* **rtf-codec:** stop varying decodeDbcsBytes' pair-consume step when trail is undefined ([ebcaaa1](https://github.com/ExaDev/documents.js/commit/ebcaaa105e49f59093602199a47d1fd82852e600)) +* **rtf-codec:** use an unobservable-safe loop bound in matchingGroupEnd ([e2e11c0](https://github.com/ExaDev/documents.js/commit/e2e11c0f5195c8bf49e8631cf52fac7ca7f9d2e3)) + +### Documentation + +* **rtf-codec:** document applyControlWord's own four-way dispatch fallthrough as equivalent ([8cba286](https://github.com/ExaDev/documents.js/commit/8cba28657ffac7547aa305918dffe858929df37e)) +* **rtf-codec:** document six genuinely irreducible equivalent mutants ([1546c63](https://github.com/ExaDev/documents.js/commit/1546c63ba321be2fcd876d7c05007d6e297c3e40)) +* **rtf-codec:** document two guards as genuinely irreducible equivalent mutants ([0c60868](https://github.com/ExaDev/documents.js/commit/0c60868cacdb1578f6ad8cd845ccce81060392f6)) +* **rtf-codec:** document two more irreducible equivalents in resolveRows' own scan loop ([e1859a5](https://github.com/ExaDev/documents.js/commit/e1859a5d308ed0ffa1fce43018d03d91ec023870)) + +### Tests + +* **rtf-codec:** add a genuine startRun-ordering case to coalesceRunConstructs' sort test ([2c9e5de](https://github.com/ExaDev/documents.js/commit/2c9e5de3fe344a3635e59d1ec95493a3a3fcc108)) +* **rtf-codec:** add direct unit coverage for cell-format.ts's exported functions ([6dcd66b](https://github.com/ExaDev/documents.js/commit/6dcd66be5f6c3706407a0797a10b328b2b6c52e6)) +* **rtf-codec:** add direct unit coverage for constructs.ts's exported functions ([fd7baf8](https://github.com/ExaDev/documents.js/commit/fd7baf85f7871b9b8c1ea2c87b58091b6d0ecc3d)) +* **rtf-codec:** add direct unit coverage for expectBalancedBraces ([9345f21](https://github.com/ExaDev/documents.js/commit/9345f2122097100105bca8f70444dad51e36530e)) +* **rtf-codec:** add direct unit coverage for the small byte/error/id utilities ([6e1ff44](https://github.com/ExaDev/documents.js/commit/6e1ff449eb1c2b1e8b5c9d8d841d75d3eaf2bad8)) +* **rtf-codec:** assert applyCellDefinitionControlWord's return value and exact border output ([3dd6b2d](https://github.com/ExaDev/documents.js/commit/3dd6b2d58c3f61e520aea4c1851e2671d5f72fa8)) +* **rtf-codec:** assert exact diagnostic message text for contentControl gaps ([66f0845](https://github.com/ExaDev/documents.js/commit/66f0845612cff036dc68eed699ad59c90c2b359a)) +* **rtf-codec:** assert the exact package-table-dropped message and disjuncts ([4d280e4](https://github.com/ExaDev/documents.js/commit/4d280e4016809b6d969740653840f1bc5995f4c9)) +* **rtf-codec:** close a genuine full-run's own real survivors in read.ts ([66ae6d5](https://github.com/ExaDev/documents.js/commit/66ae6d54c7e6ee1bae071fbf3cf02afeeb7fd167)) +* **rtf-codec:** close a second round of read.ts survivors after refactoring ([a995c6e](https://github.com/ExaDev/documents.js/commit/a995c6ee392c14f04c0f7bf832bd2b354b604d50)) +* **rtf-codec:** close header.ts's business-logic mutation gaps ([e3d2c4d](https://github.com/ExaDev/documents.js/commit/e3d2c4d8197cdec70727113869ce91a937da9ee1)) +* **rtf-codec:** close readFontInfo/parseColorTable's remaining coverage gaps ([a56c262](https://github.com/ExaDev/documents.js/commit/a56c262276d968f391de42bfc19566427a0d18bd)) +* **rtf-codec:** close resolveRows' own vertical-merge-continuation gaps ([5de7c03](https://github.com/ExaDev/documents.js/commit/5de7c03f4b723275f943aa5d7c3ebaacc5d4f8fd)) +* **rtf-codec:** close two more read.ts survivors in extent ordering and bookmark deletion ([a43228f](https://github.com/ExaDev/documents.js/commit/a43228f21b9de0c20affc227f3bd6e059c441840)) +* **rtf-codec:** close write.ts form-field crossing and table-collection gaps ([7f61329](https://github.com/ExaDev/documents.js/commit/7f613298e593a1699e7174c48c8a9f130d929f58)) +* **rtf-codec:** close write.ts's bookmark and form-field boundary gaps ([adbdf34](https://github.com/ExaDev/documents.js/commit/adbdf344446f9c838aa07da1ec28c8de365397ed)) +* **rtf-codec:** close write.ts's cell-image and picture-decode gaps ([e43e840](https://github.com/ExaDev/documents.js/commit/e43e8408b29c2d061938e7772333296cbe19d946)) +* **rtf-codec:** close write.ts's last two scoped-run survivors ([1e9a86b](https://github.com/ExaDev/documents.js/commit/1e9a86b9219b37e0eb3bb149b9ebbc1e86a87690)) +* **rtf-codec:** close write.ts's list-table, table-guard, and escaping gaps ([fa82929](https://github.com/ExaDev/documents.js/commit/fa829290e97628b000be93cbf1e5d7e2f70c37ca)) +* **rtf-codec:** close write.ts's page-geometry and info-group gaps ([df312cc](https://github.com/ExaDev/documents.js/commit/df312cc9cd05e1fee8403c9f17137524d90d60fb)) +* **rtf-codec:** close write.ts's paragraph-shell and construct-stack gaps ([64d2e43](https://github.com/ExaDev/documents.js/commit/64d2e43c4deafd6edf42c4a78be6d58708f261c1)) +* **rtf-codec:** close write.ts's paragraphProperties gaps ([e0c5744](https://github.com/ExaDev/documents.js/commit/e0c5744842727239005e5563d579469a650ad219)) +* **rtf-codec:** close write.ts's picture/object payload gaps ([794e4d1](https://github.com/ExaDev/documents.js/commit/794e4d164aa2d9a08bdcd5a680701aabff1873fb)) +* **rtf-codec:** close write.ts's remaining scoped-run survivors ([df4a517](https://github.com/ExaDev/documents.js/commit/df4a517d3884e94a14bfd12f98bc627d3b65dd5e)) +* **rtf-codec:** close write.ts's run-property and table-cell gaps ([4ebbfce](https://github.com/ExaDev/documents.js/commit/4ebbfce06febf973569004f78263b3f56492fa72)) +* **rtf-codec:** cover \row's own inTable reset with no \pard following it ([dafb2e4](https://github.com/ExaDev/documents.js/commit/dafb2e412b46b838fc190f8a4cc10638442564f9)) +* **rtf-codec:** cover a bare \u with no numeric parameter ([e17de48](https://github.com/ExaDev/documents.js/commit/e17de485a0e4cd85a6d4edc71d692c26f0f5580e)) +* **rtf-codec:** cover a crash on a bare form-field destination outside any \field group ([7a35cae](https://github.com/ExaDev/documents.js/commit/7a35cae9ff1aa3f476e7d2d48da10dd3a48fd585)) +* **rtf-codec:** cover a levelstartat trailing the listlevel's own close ([f678adb](https://github.com/ExaDev/documents.js/commit/f678adbcd8ee371b31c7b1d5f6c4209b16172907)) +* **rtf-codec:** cover a nested destination inheriting a picture's own buffer ([5160873](https://github.com/ExaDev/documents.js/commit/516087361e01f14833651470203646e802fb6814)) +* **rtf-codec:** cover a stray nested destination sharing bookmark/formField state by reference ([44e054e](https://github.com/ExaDev/documents.js/commit/44e054e442d90f9a78a0b34c1ec62dbfb4b3f660)) +* **rtf-codec:** cover a stray non-hex byte inside objdata's SDATA text ([e45f7c5](https://github.com/ExaDev/documents.js/commit/e45f7c5985eb7c2c8a6838e68c6edf93c0c91394)) +* **rtf-codec:** cover applyControlWord's own destination-specific gates against a shared reference ([0d8103a](https://github.com/ExaDev/documents.js/commit/0d8103a2e16e892ad91ff28f639c0bdc71e47b2a)) +* **rtf-codec:** cover bodyStartIndex not advancing past a non-header group ([e6f6a97](https://github.com/ExaDev/documents.js/commit/e6f6a9747878584b7183bff9e9e1d51cd9522820)) +* **rtf-codec:** cover bookmark-name trimming and startFormField's own destination guard ([d36dd17](https://github.com/ExaDev/documents.js/commit/d36dd17979adcf2a76e44b2d0ffd3ca1cbd6e4d6)), references [#PCDATA](https://github.com/ExaDev/documents.js/issues/PCDATA) +* **rtf-codec:** cover decodeCodepageBytes's empty input through the UTF-8 and DBCS paths too ([06782ec](https://github.com/ExaDev/documents.js/commit/06782ecd5d61ae581e1a67719aff7670317daad8)) +* **rtf-codec:** cover duplicate-\result/\objdata diagnostics and their exact message text ([0654422](https://github.com/ExaDev/documents.js/commit/0654422c4a06607f53ece97db3c3135b235aaa28)) +* **rtf-codec:** cover endCell's own explicit closing-bookmark flush ([5a67298](https://github.com/ExaDev/documents.js/commit/5a67298430cb7d5533d32ee51e636e05a2b27e22)) +* **rtf-codec:** cover endSection's own first-section push and breakType key omission ([9c03f5c](https://github.com/ExaDev/documents.js/commit/9c03f5c0d20c0ce439fd0a3c7efafb3e30984f77)) +* **rtf-codec:** cover picture/embedded-object state init on a non-picture recognised destination ([9bd0aab](https://github.com/ExaDev/documents.js/commit/9bd0aab7bed338a307ad6f60fd507807117dd5fb)) +* **rtf-codec:** cover Presentation field wrong-value rejection and a missing Package stream ([a051eda](https://github.com/ExaDev/documents.js/commit/a051edad4eab0609fae7097f6f68f870758bb713)) +* **rtf-codec:** cover readStyle's heading-level precedence and the no-heading case ([b22778d](https://github.com/ExaDev/documents.js/commit/b22778dc0ababe2af0502700e6365e2bf2132750)) +* **rtf-codec:** cover resource limits, group dispatch, and control-word switches ([63a7c04](https://github.com/ExaDev/documents.js/commit/63a7c04c92d28528eaf8b2fd8dbd5e6f33fb2a80)) +* **rtf-codec:** cover run-identity, unicode-skip, and construct-nesting gaps ([e0bb8c7](https://github.com/ExaDev/documents.js/commit/e0bb8c7d1cbd4c5d8b378e85de476640ba9c66a9)) +* **rtf-codec:** cover table row/column, section, picture, and object gaps ([bd95665](https://github.com/ExaDev/documents.js/commit/bd956658017dddcf8cbd4c2104ff9053a48aa6bd)) +* **rtf-codec:** cover takeRunConstructs' own sort comparator against reversed push order ([dfb246e](https://github.com/ExaDev/documents.js/commit/dfb246e8ebab9fa1659c1c2be22e0d64335bc101)) +* **rtf-codec:** cover the {\info ...} group's remaining fields and a partial colour entry ([61b8bc7](https://github.com/ExaDev/documents.js/commit/61b8bc7b257964a4ae9335868215f333df70992c)) +* **rtf-codec:** cover the ASCII-letter and hex-digit boundaries in tokenize.ts ([1b42967](https://github.com/ExaDev/documents.js/commit/1b429677f172bcd383c4f38a38d77089e4b9f267)) +* **rtf-codec:** cover tokenize.ts's backslash-CR/LF combinations, bin0, and boundary fallbacks ([4a61e41](https://github.com/ExaDev/documents.js/commit/4a61e41c60be31941c4935cddcf5f1e1840d9ecd)) +* **rtf-codec:** cover trailing-byte flush and the header assertion's own three clauses ([7f53c4c](https://github.com/ExaDev/documents.js/commit/7f53c4cd047a03066fda085eef40e600095c4b5c)) +* **rtf-codec:** cover unicode-fallback boundaries and bookmark resolution timing ([7f8a7e2](https://github.com/ExaDev/documents.js/commit/7f8a7e22988e2a4efe74cc84326f84c3d8f4e472)) +* **rtf-codec:** fix order-dependent header.ts survivor tests, drop a redundant +1 ([5b70403](https://github.com/ExaDev/documents.js/commit/5b70403136b7a012a68df3cca12b6f623c120846)) +* **rtf-codec:** isolate addBlocks' own emptiness guard and flushRun call ([ba18a22](https://github.com/ExaDev/documents.js/commit/ba18a22f663ea2e7f5917856828b7ec42e60c8fb)) +* **rtf-codec:** isolate beginResultScratch's and endResultScratch's own calls ([b7538cf](https://github.com/ExaDev/documents.js/commit/b7538cf7c6eba4c1589fde03030e2965f4fe82c0)) +* **rtf-codec:** prove parseRtfListNumId's index-capture guard is real, not decorative ([f847258](https://github.com/ExaDev/documents.js/commit/f847258980b915b7ecf1a46c1eb2f4ecfac5840d)) +* **rtf-codec:** reject an RTF magic prefix that shares only its opening brace ([5420833](https://github.com/ExaDev/documents.js/commit/5420833003f4237f223829f6539cbc13211a8611)) +* **rtf-codec:** strengthen two vulnerable-to-merging assertions, cover \sect's own force=true ([8a2efba](https://github.com/ExaDev/documents.js/commit/8a2efbadf4bfae9e1e58cb05c699be76f16f32de)) +* **rtf-codec:** verify embedded-object.ts's exact written bytes and remaining boundary checks ([83e6def](https://github.com/ExaDev/documents.js/commit/83e6def37ed0fb5a7b12b126932ef0d056dc4f5e)) + +### Miscellaneous Chores + +* **rtf-codec:** raise the mutation break threshold to 100 ([764f5c5](https://github.com/ExaDev/documents.js/commit/764f5c5dd20e18c0d60892271c779a16fd11b6cc)) + ## [4.4.4](https://github.com/ExaDev/documents.js/compare/rtf-codec%404.4.3...rtf-codec%404.4.4) (2026-09-14) ### Miscellaneous Chores diff --git a/packages/rtf-codec/package.json b/packages/rtf-codec/package.json index 205c166b5..9079952f1 100644 --- a/packages/rtf-codec/package.json +++ b/packages/rtf-codec/package.json @@ -1,6 +1,6 @@ { "name": "rtf-codec", - "version": "4.4.4", + "version": "4.4.5", "description": "Hand-written Rich Text Format (RTF 1.9.1) reader and writer against the shared document-schema.js content pivot.", "type": "module", "repository": { diff --git a/packages/rtf-codec/src/base64.test.ts b/packages/rtf-codec/src/base64.test.ts new file mode 100644 index 000000000..c9a223b0b --- /dev/null +++ b/packages/rtf-codec/src/base64.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from "./base64"; + +describe("bytesToBase64", () => { + it("pads with two '=' for a length not divisible by 3 with one trailing byte", () => { + // "Ma==" is the canonical base64 of the single byte 0x4d ("M"). + expect(bytesToBase64(Uint8Array.from([0x4d]))).toBe("TQ=="); + }); + + it("pads with one '=' for a length not divisible by 3 with two trailing bytes", () => { + // "TWE=" is the canonical base64 of the two bytes "Ma". + expect(bytesToBase64(Uint8Array.from([0x4d, 0x61]))).toBe("TWE="); + }); + + it("pads with nothing for a length exactly divisible by 3", () => { + // "Man" encodes to "TWFu" with no padding at all. + expect(bytesToBase64(Uint8Array.from([0x4d, 0x61, 0x6e]))).toBe("TWFu"); + }); + + it("does not read past the input on the last group of three", () => { + // Six bytes is two whole groups of three; a loop reading one byte too far would append a spurious fourth group of padding-only characters. + expect( + bytesToBase64(Uint8Array.from([0x4d, 0x61, 0x6e, 0x4d, 0x61, 0x6e])), + ).toBe("TWFuTWFu"); + }); + + it("encodes an empty input as an empty string", () => { + expect(bytesToBase64(new Uint8Array(0))).toBe(""); + }); +}); + +describe("base64ToBytes", () => { + it("decodes a real multi-character base64 string back to its exact bytes", () => { + expect(base64ToBytes("TWFu")).toEqual(Uint8Array.from([0x4d, 0x61, 0x6e])); + }); + + it("decodes a base64 string with two padding characters back to one byte", () => { + expect(base64ToBytes("TQ==")).toEqual(Uint8Array.from([0x4d])); + }); + + it("decodes a base64 string with one padding character back to two bytes", () => { + expect(base64ToBytes("TWE=")).toEqual(Uint8Array.from([0x4d, 0x61])); + }); + + it("round-trips every byte value through encode then decode", () => { + const bytes = Uint8Array.from({ length: 256 }, (_, index) => index); + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes); + }); + + it("skips whitespace and padding characters rather than rejecting them", () => { + expect(base64ToBytes("TWFu\n")).toEqual( + Uint8Array.from([0x4d, 0x61, 0x6e]), + ); + }); + + it("returns undefined for a character outside the base64 alphabet", () => { + expect(base64ToBytes("T!Fu")).toBeUndefined(); + }); +}); + +describe("bytesToHex", () => { + it("encodes every byte as two lowercase hex digits", () => { + expect(bytesToHex(Uint8Array.from([0x00, 0x0f, 0xff, 0xab]))).toBe( + "000fffab", + ); + }); +}); + +describe("hexToBytes", () => { + it("decodes a run of hex digits into their bytes", () => { + expect(hexToBytes("000fffab")).toEqual( + Uint8Array.from([0x00, 0x0f, 0xff, 0xab]), + ); + }); + + it("skips whitespace inserted for RTF's own line-wrapping advice", () => { + expect(hexToBytes("00 0f\r\nff ab")).toEqual( + Uint8Array.from([0x00, 0x0f, 0xff, 0xab]), + ); + }); + + it("skips a non-hex character rather than rejecting it or treating it as a digit", () => { + expect(hexToBytes("00zzffab")).toEqual(Uint8Array.from([0x00, 0xff, 0xab])); + }); + + it("drops a trailing unpaired hex digit rather than padding it into a byte", () => { + expect(hexToBytes("000fa")).toEqual(Uint8Array.from([0x00, 0x0f])); + }); + + it("is case-insensitive", () => { + expect(hexToBytes("AaBbCcDdEeFf")).toEqual( + Uint8Array.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), + ); + }); +}); diff --git a/packages/rtf-codec/src/bytes.test.ts b/packages/rtf-codec/src/bytes.test.ts new file mode 100644 index 000000000..9cb8cd11a --- /dev/null +++ b/packages/rtf-codec/src/bytes.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { appendBytes, asciiStringFromBytes, rtfBytesFromLatin1 } from "./bytes"; +import { RtfParseError } from "./diagnostics"; + +describe("asciiStringFromBytes", () => { + it("converts a short byte run to its ASCII string", () => { + expect(asciiStringFromBytes(Uint8Array.from([0x41, 0x42, 0x43]))).toBe( + "ABC", + ); + }); + + it("does not drop or duplicate bytes at the chunk boundary", () => { + // ASCII_CHUNK_SIZE is 8192; one byte either side of that boundary is exactly where an off-by-one in the chunking loop would show up. + const length = 8193; + const input = Uint8Array.from( + { length }, + (_, index) => 0x30 + (index % 10), + ); + const expected = Array.from(input, (byte) => + String.fromCharCode(byte), + ).join(""); + expect(asciiStringFromBytes(input)).toBe(expected); + }); + + it("converts an empty input to an empty string", () => { + expect(asciiStringFromBytes(new Uint8Array(0))).toBe(""); + }); +}); + +describe("appendBytes", () => { + it("appends every byte of the input onto the target array, in order", () => { + const target = [0x01]; + appendBytes(target, Uint8Array.from([0x02, 0x03, 0x04])); + expect(target).toEqual([0x01, 0x02, 0x03, 0x04]); + }); + + it("appends nothing for an empty input", () => { + const target = [0x01]; + appendBytes(target, new Uint8Array(0)); + expect(target).toEqual([0x01]); + }); +}); + +describe("rtfBytesFromLatin1", () => { + it("converts every character to its own byte, one code unit per byte", () => { + expect(rtfBytesFromLatin1("ABC")).toEqual( + Uint8Array.from([0x41, 0x42, 0x43]), + ); + }); + + it("converts a code unit at the U+00FF boundary without throwing", () => { + expect(rtfBytesFromLatin1("ÿ")).toEqual(Uint8Array.from([0xff])); + }); + + it("converts an empty string to an empty byte array", () => { + expect(rtfBytesFromLatin1("")).toEqual(new Uint8Array(0)); + }); + + it("throws a named, coded RtfParseError for a code unit above U+00FF", () => { + let caught: unknown; + try { + rtfBytesFromLatin1("Ā"); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RtfParseError); + const error = caught as RtfParseError; + expect(error.code).toBe("rtf/not-byte-preserving-string"); + expect(error.message).toBe( + "character at index 0 is U+0100, above U+00FF: this string was decoded through a multi-byte encoding and no longer holds the file's bytes. Read the .rtf file as bytes and pass the Uint8Array directly.", + ); + }); + + it("names the actual index of the offending character, not always 0", () => { + let caught: unknown; + try { + rtfBytesFromLatin1("ABĀ"); + } catch (error) { + caught = error; + } + expect((caught as RtfParseError).message).toContain("index 2"); + }); + + it("upper-cases the hex code point in the message", () => { + let caught: unknown; + try { + rtfBytesFromLatin1("ƫ"); + } catch (error) { + caught = error; + } + expect((caught as RtfParseError).message).toContain("U+01AB"); + }); +}); diff --git a/packages/rtf-codec/src/bytes.ts b/packages/rtf-codec/src/bytes.ts index 2410ae9d0..fc8876049 100644 --- a/packages/rtf-codec/src/bytes.ts +++ b/packages/rtf-codec/src/bytes.ts @@ -12,10 +12,15 @@ const ASCII_CHUNK_SIZE = 8192; export function asciiStringFromBytes(input: Uint8Array): string { let out = ""; - for (let start = 0; start < input.length; start += ASCII_CHUNK_SIZE) { - out += String.fromCharCode( - ...input.subarray(start, start + ASCII_CHUNK_SIZE), - ); + let start = 0; + // Stops on the first empty chunk rather than comparing `start` against `input.length` directly: Uint8Array.subarray already clamps a past-the-end range to empty on its own, so this is the one condition that actually distinguishes "more bytes remain" from "done", regardless of whether input.length happens to be an exact multiple of ASCII_CHUNK_SIZE. + for (;;) { + const chunk = input.subarray(start, start + ASCII_CHUNK_SIZE); + if (chunk.length === 0) { + break; + } + out += String.fromCharCode(...chunk); + start += ASCII_CHUNK_SIZE; } return out; } @@ -29,7 +34,8 @@ export function appendBytes(target: number[], input: Uint8Array): void { export function rtfBytesFromLatin1(source: string): Uint8Array { const out = new Uint8Array(source.length); - for (let index = 0; index < source.length; index += 1) { + // index increments by exactly 1 every iteration, so it can never skip past source.length -- !== is exactly equivalent to < here, and unlike <, an off-by-one mutation of it (=== in place of !==) stops the loop from running at all instead of surviving unobserved. + for (let index = 0; index !== source.length; index += 1) { const code = source.charCodeAt(index); if (code > 0xff) { throw new RtfParseError( diff --git a/packages/rtf-codec/src/cell-format.test.ts b/packages/rtf-codec/src/cell-format.test.ts new file mode 100644 index 000000000..37e16a35b --- /dev/null +++ b/packages/rtf-codec/src/cell-format.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it } from "vitest"; +import type { Color, ContentBorder, ContentCellFill } from "document-schema.js"; +import { + applyCellDefinitionControlWord, + borderControlWords, + CELL_BORDER_SIDES, + cellFillControlWords, + newPendingCell, + resolveBorder, + resolveCellFill, + type PendingBorder, + type PendingCell, +} from "./cell-format"; + +function pendingBorder(overrides: Partial = {}): PendingBorder { + return { + style: undefined, + widthTwips: undefined, + colorIndex: undefined, + none: false, + ...overrides, + }; +} + +const RED: Color = { r: 1, g: 0, b: 0 }; +const BLUE: Color = { r: 0, g: 0, b: 1 }; +const colorTable: (Color | undefined)[] = [undefined, RED, BLUE]; +const colorAt = (index: number): Color | undefined => colorTable[index]; + +describe("applyCellDefinitionControlWord", () => { + it("recognizes every /// side word and starts a fresh border for it", () => { + for (const [word, side] of CELL_BORDER_SIDES) { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord(word, undefined, cell)).toBe(true); + expect(cell.side).toBe(side); + expect(cell.borders[side]).toEqual(pendingBorder()); + } + }); + + it("resets the pending border when the same side is named a second time", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + cell.borders.top = pendingBorder({ style: "dashed", none: true }); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + expect(cell.borders.top).toEqual(pendingBorder()); + }); + + it.each([ + ["clvmgf", "verticalMergeFirst"], + ["clvmrg", "verticalMergeContinuation"], + ["clmgf", "horizontalMergeFirst"], + ["clmrg", "horizontalMergeContinuation"], + ] as const)( + "sets %s's own %s flag and leaves the rest false", + (word, field) => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord(word, undefined, cell)).toBe(true); + expect(cell[field]).toBe(true); + for (const other of [ + "verticalMergeFirst", + "verticalMergeContinuation", + "horizontalMergeFirst", + "horizontalMergeContinuation", + ] as const) { + if (other !== field) { + expect(cell[other]).toBe(false); + } + } + }, + ); + + it("stores clcbpat's own parameter as the background index, including when the parameter is absent", () => { + const withParam = newPendingCell(); + expect(applyCellDefinitionControlWord("clcbpat", 3, withParam)).toBe(true); + expect(withParam.backgroundIndex).toBe(3); + + const withoutParam = newPendingCell(); + expect( + applyCellDefinitionControlWord("clcbpat", undefined, withoutParam), + ).toBe(true); + expect(withoutParam.backgroundIndex).toBeUndefined(); + }); + + it("stores clcfpat's own parameter as the foreground index", () => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord("clcfpat", 7, cell)).toBe(true); + expect(cell.foregroundIndex).toBe(7); + }); + + it("divides clshdng's hundredths-of-a-percent parameter down to a 0-100 scale", () => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord("clshdng", 2500, cell)).toBe(true); + expect(cell.shadingPercent).toBe(25); + }); + + it("leaves shadingPercent undefined when clshdng carries no parameter at all", () => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord("clshdng", undefined, cell)).toBe( + true, + ); + expect(cell.shadingPercent).toBeUndefined(); + }); + + it("sets verticalAlign to center for clvertalc and bottom for clvertalb", () => { + const centered = newPendingCell(); + expect( + applyCellDefinitionControlWord("clvertalc", undefined, centered), + ).toBe(true); + expect(centered.verticalAlign).toBe("center"); + + const bottomed = newPendingCell(); + expect( + applyCellDefinitionControlWord("clvertalb", undefined, bottomed), + ).toBe(true); + expect(bottomed.verticalAlign).toBe("bottom"); + }); + + it("leaves verticalAlign undefined for clvertalt, the default top alignment", () => { + const cell = newPendingCell(); + cell.verticalAlign = "center"; + expect(applyCellDefinitionControlWord("clvertalt", undefined, cell)).toBe( + true, + ); + expect(cell.verticalAlign).toBeUndefined(); + }); + + it("returns false for a name that is neither a side, a switch case, nor a border descriptor, with no pending side", () => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord("plain", undefined, cell)).toBe( + false, + ); + }); + + it("returns false for a border descriptor word when no side has been named yet", () => { + const cell = newPendingCell(); + expect(applyCellDefinitionControlWord("brdrw", 20, cell)).toBe(false); + expect(applyCellDefinitionControlWord("brdrnone", undefined, cell)).toBe( + false, + ); + }); + + it("marks the pending side's border as none for each of the three no-border keywords", () => { + for (const word of ["brdrnone", "brdrnil", "brdrtbl"]) { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + expect(applyCellDefinitionControlWord(word, undefined, cell)).toBe(true); + expect(cell.borders.top?.none).toBe(true); + } + }); + + it("maps a keyword to its ContentStrokeStyle member on the pending side", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrl", undefined, cell); + expect(applyCellDefinitionControlWord("brdrdash", undefined, cell)).toBe( + true, + ); + expect(cell.borders.left?.style).toBe("dashed"); + }); + + it("stores brdrw's parameter as the pending side's width in twips", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrb", undefined, cell); + expect(applyCellDefinitionControlWord("brdrw", 45, cell)).toBe(true); + expect(cell.borders.bottom?.widthTwips).toBe(45); + }); + + it("stores brdrcf's parameter as the pending side's colour index", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrr", undefined, cell); + expect(applyCellDefinitionControlWord("brdrcf", 2, cell)).toBe(true); + expect(cell.borders.right?.colorIndex).toBe(2); + }); + + it("consumes an unrecognized brdr-prefixed word as belonging to the pending border", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + expect(applyCellDefinitionControlWord("brdrzzz", undefined, cell)).toBe( + true, + ); + }); + + it("consumes an unrecognized brsp-prefixed word as belonging to the pending border", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + expect(applyCellDefinitionControlWord("brsp99", undefined, cell)).toBe( + true, + ); + }); + + it("does not consume a word that merely ends with brdr or brsp without starting with it", () => { + const cell = newPendingCell(); + applyCellDefinitionControlWord("clbrdrt", undefined, cell); + expect(applyCellDefinitionControlWord("xxxbrdr", undefined, cell)).toBe( + false, + ); + expect(applyCellDefinitionControlWord("xxxbrsp", undefined, cell)).toBe( + false, + ); + }); +}); + +describe("resolveBorder", () => { + it("returns undefined when the side is explicitly stated to have no border", () => { + expect( + resolveBorder(pendingBorder({ none: true }), colorAt), + ).toBeUndefined(); + }); + + it("falls back to the default width when none is stated", () => { + const border = resolveBorder(pendingBorder(), colorAt); + expect(border?.widthPt).toBe(0.75); + }); + + it("uses the stated width, converted from twips to points", () => { + const border = resolveBorder(pendingBorder({ widthTwips: 40 }), colorAt); + expect(border?.widthPt).toBe(2); + }); + + it("returns undefined when the resolved width is exactly zero", () => { + expect( + resolveBorder(pendingBorder({ widthTwips: 0 }), colorAt), + ).toBeUndefined(); + }); + + it("defaults an unstated colour index to black", () => { + const border = resolveBorder(pendingBorder(), colorAt); + expect(border?.color).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("defaults to black when the colour index resolves to no table entry", () => { + const border = resolveBorder(pendingBorder({ colorIndex: 0 }), colorAt); + expect(border?.color).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("resolves a stated colour index through the colour table", () => { + const border = resolveBorder(pendingBorder({ colorIndex: 1 }), colorAt); + expect(border?.color).toEqual(RED); + }); + + it("omits the style field for an unstated style", () => { + const border = resolveBorder(pendingBorder(), colorAt); + expect(border).not.toHaveProperty("style"); + }); + + it("omits the style field for an explicit solid style", () => { + const border = resolveBorder(pendingBorder({ style: "solid" }), colorAt); + expect(border).not.toHaveProperty("style"); + }); + + it("carries a non-solid style through", () => { + const border = resolveBorder(pendingBorder({ style: "double" }), colorAt); + expect(border?.style).toBe("double"); + }); +}); + +function pendingCell(overrides: Partial = {}): PendingCell { + return { ...newPendingCell(), ...overrides }; +} + +describe("resolveCellFill", () => { + it("returns undefined when no background colour was ever stated", () => { + expect(resolveCellFill(pendingCell(), colorAt)).toBeUndefined(); + }); + + it("returns undefined when the background index resolves to no colour and there is no shading", () => { + expect( + resolveCellFill(pendingCell({ backgroundIndex: 0 }), colorAt), + ).toBeUndefined(); + }); + + it("resolves a flat background with no shading percentage stated at all", () => { + expect( + resolveCellFill(pendingCell({ backgroundIndex: 1 }), colorAt), + ).toEqual({ kind: "solid", color: RED }); + }); + + it("resolves a flat background when shading is exactly zero", () => { + expect( + resolveCellFill( + pendingCell({ backgroundIndex: 2, shadingPercent: 0 }), + colorAt, + ), + ).toEqual({ kind: "solid", color: BLUE }); + }); + + it("resolves a flat foreground colour when shading is exactly 100", () => { + expect( + resolveCellFill( + pendingCell({ + backgroundIndex: 1, + foregroundIndex: 2, + shadingPercent: 100, + }), + colorAt, + ), + ).toEqual({ kind: "solid", color: BLUE }); + }); + + it("returns undefined at full shading when the foreground index resolves to no colour", () => { + expect( + resolveCellFill( + pendingCell({ + backgroundIndex: 1, + foregroundIndex: 0, + shadingPercent: 100, + }), + colorAt, + ), + ).toBeUndefined(); + }); + + it("returns undefined at full shading when no foreground index was stated at all", () => { + expect( + resolveCellFill( + pendingCell({ backgroundIndex: 1, shadingPercent: 100 }), + colorAt, + ), + ).toBeUndefined(); + }); + + it("resolves a genuine two-colour pattern for shading strictly between 0 and 100", () => { + expect( + resolveCellFill( + pendingCell({ + backgroundIndex: 2, + foregroundIndex: 1, + shadingPercent: 50, + }), + colorAt, + ), + ).toEqual({ + kind: "pattern", + patternType: "percent50", + foregroundColor: RED, + backgroundColor: BLUE, + }); + }); + + it("omits foregroundColor from a pattern fill when the foreground index resolves to no colour", () => { + const fill = resolveCellFill( + pendingCell({ + backgroundIndex: 1, + foregroundIndex: 0, + shadingPercent: 50, + }), + colorAt, + ); + expect(fill).not.toHaveProperty("foregroundColor"); + }); + + it("omits backgroundColor from a pattern fill when the background index resolves to no colour", () => { + const fill = resolveCellFill( + pendingCell({ + backgroundIndex: 0, + foregroundIndex: 1, + shadingPercent: 50, + }), + colorAt, + ); + expect(fill).not.toHaveProperty("backgroundColor"); + }); + + it("snaps a tied shading percentage to the earlier, not the later, equally-near percentN step", () => { + // 7.5 sits exactly halfway between percent5 and percent10 -- the reduce must keep the first-seen closest step on a tie, not overwrite it with a later equally-close one. + const fill = resolveCellFill( + pendingCell({ + backgroundIndex: 1, + foregroundIndex: 2, + shadingPercent: 7.5, + }), + colorAt, + ); + expect(fill).toMatchObject({ patternType: "percent5" }); + }); +}); + +describe("borderControlWords", () => { + const solidBorder: ContentBorder = { color: RED, widthPt: 1 }; + + it.each(["top", "left", "bottom", "right"] as const)( + "writes the %s side's own control word", + (side) => { + const expectedWord = [...CELL_BORDER_SIDES].find( + ([, value]) => value === side, + )?.[0]; + expect(borderControlWords(side, solidBorder, undefined)).toContain( + `\\${expectedWord}`, + ); + }, + ); + + it("writes the canonical control word for each ContentStrokeStyle member", () => { + expect( + borderControlWords("top", { ...solidBorder, style: "solid" }, undefined), + ).toContain("\\brdrs\\"); + expect( + borderControlWords("top", { ...solidBorder, style: "dashed" }, undefined), + ).toContain("\\brdrdash\\"); + expect( + borderControlWords("top", { ...solidBorder, style: "dotted" }, undefined), + ).toContain("\\brdrdot\\"); + expect( + borderControlWords("top", { ...solidBorder, style: "double" }, undefined), + ).toContain("\\brdrdb\\"); + }); + + it("defaults to the solid style when style is unstated", () => { + expect(borderControlWords("top", solidBorder, undefined)).toContain( + "\\brdrs\\", + ); + }); + + it("floors the written width to at least one twip", () => { + expect( + borderControlWords("top", { ...solidBorder, widthPt: 0.01 }, undefined), + ).toContain("\\brdrw1"); + }); + + it("writes the width converted to twips for an ordinary width", () => { + expect( + borderControlWords("top", { ...solidBorder, widthPt: 2 }, undefined), + ).toContain("\\brdrw40"); + }); + + it("appends the colour control word only when a colour index is given", () => { + expect(borderControlWords("top", solidBorder, undefined)).toBe( + "\\clbrdrt\\brdrs\\brdrw20", + ); + expect(borderControlWords("top", solidBorder, 3)).toBe( + "\\clbrdrt\\brdrs\\brdrw20\\brdrcf3", + ); + }); +}); + +describe("cellFillControlWords", () => { + const colorIndexOf = (color: Color): number | undefined => + color === RED ? 1 : color === BLUE ? 2 : undefined; + + it("writes clcbpat alone for a solid fill whose colour resolves to an index", () => { + expect( + cellFillControlWords({ kind: "solid", color: RED }, colorIndexOf), + ).toBe("\\clcbpat1"); + }); + + it("writes nothing for a solid fill whose colour resolves to no index", () => { + expect( + cellFillControlWords( + { kind: "solid", color: { r: 0.5, g: 0.5, b: 0.5 } }, + colorIndexOf, + ), + ).toBe(""); + }); + + it("throws for a pattern fill type outside the percentN family", () => { + expect(() => + cellFillControlWords( + { kind: "pattern", patternType: "gray125" }, + colorIndexOf, + ), + ).toThrow(/gray125/); + }); + + it("writes all three control words for a two-colour pattern with both colours resolvable", () => { + const fill: ContentCellFill = { + kind: "pattern", + patternType: "percent50", + backgroundColor: BLUE, + foregroundColor: RED, + }; + const text = cellFillControlWords(fill, colorIndexOf); + expect(text).toBe("\\clcbpat2\\clcfpat1\\clshdng5000"); + }); + + it("omits clcbpat when the background colour resolves to no index", () => { + const fill: ContentCellFill = { + kind: "pattern", + patternType: "percent50", + backgroundColor: { r: 0.2, g: 0.2, b: 0.2 }, + foregroundColor: RED, + }; + expect(cellFillControlWords(fill, colorIndexOf)).toBe( + "\\clcfpat1\\clshdng5000", + ); + }); + + it("omits clcfpat when the foreground colour resolves to no index", () => { + const fill: ContentCellFill = { + kind: "pattern", + patternType: "percent50", + backgroundColor: BLUE, + foregroundColor: { r: 0.2, g: 0.2, b: 0.2 }, + }; + expect(cellFillControlWords(fill, colorIndexOf)).toBe( + "\\clcbpat2\\clshdng5000", + ); + }); + + it("writes only clshdng when neither pattern colour is stated at all", () => { + const fill: ContentCellFill = { kind: "pattern", patternType: "percent25" }; + expect(cellFillControlWords(fill, colorIndexOf)).toBe("\\clshdng2500"); + }); + + it("throws for a fill kind outside the solid/pattern discriminated union", () => { + const bogus = { kind: "bogus" } as unknown as ContentCellFill; + expect(() => cellFillControlWords(bogus, colorIndexOf)).toThrow(/bogus/); + }); +}); diff --git a/packages/rtf-codec/src/cell-format.ts b/packages/rtf-codec/src/cell-format.ts index c2ab4fb86..c785f7da6 100644 --- a/packages/rtf-codec/src/cell-format.ts +++ b/packages/rtf-codec/src/cell-format.ts @@ -63,6 +63,14 @@ export const CELL_BORDER_SIDES: ReadonlyMap = new Map([ ["clbrdrr", "right"], ]); +// The inverse of CELL_BORDER_SIDES, keyed by side rather than by control word -- a total mapping over CellBorderSide's own four members, so borderControlWords below can look a side's word up directly rather than searching CELL_BORDER_SIDES for the entry whose value matches, which would need a defensive "not found" branch for a side that can structurally never fail to resolve. +const CELL_BORDER_SIDE_WORDS: Readonly> = { + top: "clbrdrt", + left: "clbrdrl", + bottom: "clbrdrb", + right: "clbrdrr", +}; + // The keywords, narrowed onto ContentStrokeStyle's four members. RTF names about thirty; most are decorative variants of one of the four (every \brdrdash* spelling is a dash, every thick-thin combination is a double rule), and a keyword with no member here is read as the 'solid' the field's own "absent means solid" default already states rather than being invented into a member it does not have. const BORDER_STYLES: ReadonlyMap = new Map([ ["brdrs", "solid"], @@ -199,8 +207,6 @@ export function applyCellDefinitionControlWord( case "clvertalt": cell.verticalAlign = undefined; return true; - default: - break; } const pending = cell.side === undefined ? undefined : cell.borders[cell.side]; if (pending === undefined) { @@ -290,12 +296,7 @@ export function borderControlWords( border: ContentBorder, colorIndex: number | undefined, ): string { - const sideWord = [...CELL_BORDER_SIDES].find( - ([, value]) => value === side, - )?.[0]; - if (sideWord === undefined) { - return ""; - } + const sideWord = CELL_BORDER_SIDE_WORDS[side]; const style = BORDER_STYLE_CONTROL_WORDS[border.style ?? "solid"]; const width = Math.max(1, pointsToTwips(border.widthPt)); return ( diff --git a/packages/rtf-codec/src/codec.test.ts b/packages/rtf-codec/src/codec.test.ts index 2c6adf335..1bf1caa1e 100644 --- a/packages/rtf-codec/src/codec.test.ts +++ b/packages/rtf-codec/src/codec.test.ts @@ -18,6 +18,12 @@ describe("RtfBytesSchema", () => { expect(RtfBytesSchema.safeParse(bytes("PK")).success).toBe(false); }); + it("rejects bytes that match only the leading '{' and not the rest of the magic", () => { + // Every one of the five magic bytes must match, not merely one of them -- + // this starts with the same brace the real magic opens with but diverges immediately after. + expect(RtfBytesSchema.safeParse(bytes("{XXXX")).success).toBe(false); + }); + it("accepts a version parameter other than 1, which a future document could still carry", () => { expect(RtfBytesSchema.safeParse(bytes("{\\rtf2\\ansi}")).success).toBe( true, diff --git a/packages/rtf-codec/src/codepage.test.ts b/packages/rtf-codec/src/codepage.test.ts index 2e4d1f306..436f73d0f 100644 --- a/packages/rtf-codec/src/codepage.test.ts +++ b/packages/rtf-codec/src/codepage.test.ts @@ -110,6 +110,111 @@ describe("decodeCodepageBytes: East Asian DBCS pages", () => { }); }); +describe("decodeCodepageBytes: general behaviour", () => { + it("decodes an empty input as an empty string", () => { + expect( + decodeCodepageBytes(new Uint8Array(0), 1252, NOOP_RTF_DIAGNOSTIC_SINK), + ).toBe(""); + }); + + it("decodes an empty input as an empty string through the UTF-8 path too", () => { + expect( + decodeCodepageBytes( + new Uint8Array(0), + UTF8_CODEPAGE, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe(""); + }); + + it("decodes an empty input as an empty string through the DBCS path too, with no diagnostic reported", () => { + let called = false; + expect( + decodeCodepageBytes(new Uint8Array(0), 932, () => { + called = true; + }), + ).toBe(""); + expect(called).toBe(false); + }); + + it("decodes through the platform's own UTF-8 decoder for \\ansicpg65001", () => { + const bytes = new TextEncoder().encode("café €"); + expect( + decodeCodepageBytes(bytes, UTF8_CODEPAGE, NOOP_RTF_DIAGNOSTIC_SINK), + ).toBe("café €"); + }); + + it("treats the byte right below 0x80 as plain ASCII and the byte at 0x80 as the table's own first entry", () => { + expect( + decodeCodepageBytes( + Uint8Array.from([0x7f]), + 1252, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe(""); + expect( + decodeCodepageBytes( + Uint8Array.from([0x80]), + 1252, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe("€"); + }); + + it("reports the exact unsupported-codepage message, naming both the requested and fallback pages", () => { + const diagnostics: string[] = []; + decodeCodepageBytes(Uint8Array.from([0x41]), 9999, (diagnostic) => { + diagnostics.push(diagnostic.message); + }); + expect(diagnostics).toEqual([ + "code page 9999 is not supported; decoding this run through code page 1252 instead", + ]); + }); + + it("does not read one byte past the end of a DBCS run", () => { + // Two whole two-byte GBK characters; an off-by-one loop bound would either drop the last character or read a phantom trailing byte. + expect( + decodeCodepageBytes( + Uint8Array.from([0xd6, 0xd0, 0xd6, 0xd0]), + 936, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe("中中"); + }); + + it("treats the byte right below 0x80 in a DBCS run as plain ASCII", () => { + expect( + decodeCodepageBytes( + Uint8Array.from([0x7f]), + 936, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe(""); + }); + + it("decodes a lead byte followed by a trail byte it does not define as U+FFFD", () => { + // 0x81 is a real Shift-JIS lead byte; 0x7f is never a valid trail byte for it. + expect( + decodeCodepageBytes( + Uint8Array.from([0x81, 0x7f]), + 932, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe("�"); + }); + + it("decodes a high byte that is neither a lead byte nor a single-byte extra as U+FFFD", () => { + // 936 (GBK) defines no single-byte extras at all, and 0x80 is not one of its lead bytes. + expect( + decodeCodepageBytes( + Uint8Array.from([0x80]), + 936, + NOOP_RTF_DIAGNOSTIC_SINK, + ), + ).toBe("�"); + }); +}); + describe("readRtfContent: East Asian DBCS pages end to end", () => { it("decodes a Shift-JIS document declared via \\ansicpg932 with no unsupported-codepage diagnostic", () => { const { document, diagnostics } = readRtfContent( diff --git a/packages/rtf-codec/src/codepage.ts b/packages/rtf-codec/src/codepage.ts index 1cc396657..a61834d06 100644 --- a/packages/rtf-codec/src/codepage.ts +++ b/packages/rtf-codec/src/codepage.ts @@ -181,15 +181,12 @@ export function isSupportedCodepage(codepage: number): boolean { // Decodes one run of ANSI bytes through `codepage`. A run, not a byte, because \ansicpg65001 is UTF-8 and a stateful multi-byte encoding cannot be decoded a byte at a time -- see this module's own header. // -// An unsupported page decodes through cp1252 and reports rtf/unsupported-codepage once per run rather than throwing: the rest of the document is still readable, and cp1252 agrees with every supported page on the ASCII range, so a document whose non-ASCII content is incidental still reads correctly. The sink is what makes that visible instead of silent. +// An unsupported page decodes through cp1252 and reports rtf/unsupported-codepage once per run rather than throwing: the rest of the document is still readable, and cp1252 agrees with every supported page on the ASCII range, so a document whose non-ASCII content is incidental still reads correctly. The sink is what makes that visible instead of silent. An empty input needs no dedicated fast path: TextDecoder.decode, the DBCS state machine, and the single-byte for-of loop below all already produce "" on their own for zero bytes, with no codepage lookup or diagnostic ever triggered along the way. export function decodeCodepageBytes( input: Uint8Array, codepage: number, sink: RtfDiagnosticSink, ): string { - if (input.length === 0) { - return ""; - } if (codepage === UTF8_CODEPAGE) { return new TextDecoder("utf-8").decode(input); } @@ -208,8 +205,8 @@ export function decodeCodepageBytes( } let out = ""; for (const byte of input) { - out += - byte < 0x80 ? String.fromCharCode(byte) : (table[byte - 0x80] ?? "�"); + // charAt, not a bracket read: every entry in SINGLE_BYTE_PAGES is exactly 128 characters (0x80..0xFF, generated and verified against Python's own codec library -- see this module's own header), so `byte - 0x80` is always in range and a `?? "�"` fallback for the bracket-read's own `string | undefined` type would be pretending an unreachable case is real, per this family's no-defensive-over-engineering convention (base64.ts's own bytesToBase64 states the identical charAt-over-bracket-read reasoning). + out += byte < 0x80 ? String.fromCharCode(byte) : table.charAt(byte - 0x80); } return out; } @@ -223,7 +220,8 @@ function decodeDbcsBytes( const singleByteExtras = DBCS_SINGLE_BYTE_EXTRAS.get(codepage); let out = ""; let i = 0; - while (i < input.length) { + // No explicit i < input.length bound: i's own step varies (1 or 2 bytes per iteration), but a Uint8Array index at or past its own length always reads back undefined rather than throwing, so the byte === undefined check below is already the one true stopping condition -- a separate length comparison would only ever fire in lockstep with it. + for (;;) { const byte = input[i]; if (byte === undefined) { break; @@ -236,11 +234,17 @@ function decodeDbcsBytes( const trailTable = leadTable.get(byte); if (trailTable !== undefined) { const trail = input[i + 1]; - out += trail === undefined ? "�" : (trailTable[trail] ?? "�"); - i += trail === undefined ? 1 : 2; + // charAt, not a bracket read: trailTable is always a dense 256-character string (DBCS_LEAD_BYTE_TABLES's own header comment), so a trail byte (0x00-0xFF) is always in range, and a `?? "�"` fallback for the bracket-read's own `string | undefined` type would be pretending an unreachable case is real -- the same reasoning decodeCodepageBytes's own single-byte loop already states for SINGLE_BYTE_PAGES. + out += trail === undefined ? "�" : trailTable.charAt(trail); + // Always 2, even when trail is undefined: trail is only ever undefined when i + 1 is already past input's own end, meaning i was already the last index -- advancing by 1 or by 2 from there both land past input.length either way, so there is no real pair left to skip over by advancing the full 2. + i += 2; continue; } - out += singleByteExtras?.[byte - 0x80] ?? "�"; + // charAt, not a bracket read, for the identical reason: DBCS_SINGLE_BYTE_EXTRAS's own header comment states each entry is a dense 128-character string, so byte - 0x80 is always in range once singleByteExtras itself is known to exist. + out += + singleByteExtras === undefined + ? "�" + : singleByteExtras.charAt(byte - 0x80); i += 1; } return out; diff --git a/packages/rtf-codec/src/constructs.test.ts b/packages/rtf-codec/src/constructs.test.ts new file mode 100644 index 000000000..41119be33 --- /dev/null +++ b/packages/rtf-codec/src/constructs.test.ts @@ -0,0 +1,654 @@ +import { describe, expect, it } from "vitest"; +import { + bookmarkAnchorDescriptor, + bookmarkResidueControlWords, + coalesceRunConstructs, + dttmFromIso, + formFieldContentControl, + formFieldControlType, + hasRevision, + isBookmarkAnchor, + isoFromDttm, + NO_REVISION, + provenanceDescriptors, + type RevisionState, + type RtfFormFieldData, +} from "./constructs"; +import type { AnchorDescriptor, ConstructDescriptor } from "document-schema.js"; + +describe("bookmarkAnchorDescriptor", () => { + it("carries no source at all when no column range is given", () => { + const descriptor = bookmarkAnchorDescriptor("mark", undefined); + expect(descriptor).not.toHaveProperty("source"); + }); + + it("carries no source when a column range is given but both ends are undefined", () => { + const descriptor = bookmarkAnchorDescriptor("mark", { + first: undefined, + last: undefined, + }); + expect(descriptor).not.toHaveProperty("source"); + }); + + it("residue carries only bkmkcolf when only the first column is given", () => { + const descriptor = bookmarkAnchorDescriptor("mark", { + first: 2, + last: undefined, + }); + expect(descriptor.source).toEqual({ + format: "rtf", + xml: "\\bkmkcolf2", + }); + }); + + it("residue carries only bkmkcoll when only the last column is given", () => { + const descriptor = bookmarkAnchorDescriptor("mark", { + first: undefined, + last: 5, + }); + expect(descriptor.source).toEqual({ + format: "rtf", + xml: "\\bkmkcoll5", + }); + }); + + it("residue carries both control words in order when both columns are given", () => { + const descriptor = bookmarkAnchorDescriptor("mark", { first: 2, last: 5 }); + expect(descriptor.source).toEqual({ + format: "rtf", + xml: "\\bkmkcolf2\\bkmkcoll5", + }); + }); +}); + +describe("bookmarkResidueControlWords", () => { + it("returns the rtf residue verbatim", () => { + const descriptor: AnchorDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "mark", + source: { format: "rtf", xml: "\\bkmkcolf2" }, + }; + expect(bookmarkResidueControlWords(descriptor)).toBe("\\bkmkcolf2"); + }); + + it("returns an empty string for residue from a different format", () => { + const descriptor: AnchorDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "mark", + source: { format: "docx", xml: "" }, + }; + expect(bookmarkResidueControlWords(descriptor)).toBe(""); + }); + + it("returns an empty string when there is no source at all", () => { + const descriptor: AnchorDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "mark", + }; + expect(bookmarkResidueControlWords(descriptor)).toBe(""); + }); +}); + +describe("isBookmarkAnchor", () => { + it("is true for a bookmark anchor", () => { + const descriptor: ConstructDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "mark", + }; + expect(isBookmarkAnchor(descriptor)).toBe(true); + }); + + it("is false for a non-bookmark anchor", () => { + const descriptor: ConstructDescriptor = { + kind: "anchor", + anchorType: "footnote", + name: "1", + }; + expect(isBookmarkAnchor(descriptor)).toBe(false); + }); + + it("is false for a non-anchor descriptor", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "checkbox", + }; + expect(isBookmarkAnchor(descriptor)).toBe(false); + }); +}); + +describe("hasRevision", () => { + it("is false for NO_REVISION", () => { + expect(hasRevision(NO_REVISION)).toBe(false); + }); + + it("is true when only revised is set", () => { + expect(hasRevision({ ...NO_REVISION, revised: true })).toBe(true); + }); + + it("is true when only deleted is set", () => { + expect(hasRevision({ ...NO_REVISION, deleted: true })).toBe(true); + }); + + it("is true when only moved is set", () => { + expect(hasRevision({ ...NO_REVISION, moved: "moveFrom" })).toBe(true); + }); + + it("is true when only formatAuthor is set", () => { + expect(hasRevision({ ...NO_REVISION, formatAuthor: 0 })).toBe(true); + }); +}); + +describe("provenanceDescriptors", () => { + const authors = ["Alice", "Bob"]; + + it("returns an empty array for NO_REVISION", () => { + expect(provenanceDescriptors(NO_REVISION, authors)).toEqual([]); + }); + + it("emits an insertion descriptor for revised, with author and date resolved", () => { + const state: RevisionState = { + ...NO_REVISION, + revised: true, + revisedAuthor: 0, + revisedDateTime: dttmFromIso("1900-01-01T01:01:00"), + }; + expect(provenanceDescriptors(state, authors)).toEqual([ + { + kind: "provenance", + change: "insertion", + author: "Alice", + dateIso: "1900-01-01T01:01:00", + }, + ]); + }); + + it("emits a deletion descriptor for deleted", () => { + const state: RevisionState = { + ...NO_REVISION, + deleted: true, + deletedAuthor: 1, + }; + expect(provenanceDescriptors(state, authors)).toEqual([ + { kind: "provenance", change: "deletion", author: "Bob" }, + ]); + }); + + it("emits a moveFrom or moveTo descriptor matching the moved field", () => { + expect( + provenanceDescriptors({ ...NO_REVISION, moved: "moveTo" }, authors), + ).toEqual([{ kind: "provenance", change: "moveTo" }]); + }); + + it("emits a formatChange descriptor for formatAuthor", () => { + expect( + provenanceDescriptors( + { ...NO_REVISION, formatAuthor: 0, formatDateTime: undefined }, + authors, + ), + ).toEqual([ + { kind: "provenance", change: "formatChange", author: "Alice" }, + ]); + }); + + it("emits all four descriptors at once when every revision fact is present", () => { + const state: RevisionState = { + revised: true, + revisedAuthor: 0, + revisedDateTime: undefined, + deleted: true, + deletedAuthor: 1, + deletedDateTime: undefined, + moved: "moveFrom", + movedAuthor: undefined, + movedDateTime: undefined, + formatAuthor: 0, + formatDateTime: undefined, + }; + expect(provenanceDescriptors(state, authors)).toHaveLength(4); + }); + + it("omits author when the index resolves to no table entry", () => { + const state: RevisionState = { + ...NO_REVISION, + revised: true, + revisedAuthor: 99, + }; + expect(provenanceDescriptors(state, authors)).toEqual([ + { kind: "provenance", change: "insertion" }, + ]); + }); + + it("omits author when the resolved name is an empty string", () => { + const state: RevisionState = { + ...NO_REVISION, + revised: true, + revisedAuthor: 0, + }; + expect(provenanceDescriptors(state, [""])).toEqual([ + { kind: "provenance", change: "insertion" }, + ]); + }); + + it("omits dateIso entirely when the DTTM doesn't parse to a real date", () => { + const state: RevisionState = { + ...NO_REVISION, + revised: true, + revisedDateTime: 0, // day 0, month 0 -- "no time recorded" + }; + const [descriptor] = provenanceDescriptors(state, authors); + expect(descriptor).not.toHaveProperty("dateIso"); + }); +}); + +describe("isoFromDttm", () => { + // bits: year(9) month(4) day(5) hour(5) minute(6), assembled the same way the module's own writer does. + function dttm( + year: number, + month: number, + day: number, + hour: number, + minute: number, + ): number { + return ( + (minute & 0x3f) | + ((hour & 0x1f) << 6) | + ((day & 0x1f) << 11) | + ((month & 0xf) << 16) | + (((year - 1900) & 0x1ff) << 20) + ); + } + + it("formats an ordinary date and time", () => { + expect(isoFromDttm(dttm(2024, 3, 15, 9, 30))).toBe("2024-03-15T09:30:00"); + }); + + it("returns undefined for day 0, the 'no time recorded' sentinel", () => { + expect(isoFromDttm(dttm(2024, 3, 0, 9, 30))).toBeUndefined(); + }); + + it("returns undefined for month 0, even with a nonzero day", () => { + expect(isoFromDttm(dttm(2024, 0, 15, 9, 30))).toBeUndefined(); + }); + + it("accepts month 12 but rejects month 13", () => { + expect(isoFromDttm(dttm(2024, 12, 1, 0, 0))).toBe("2024-12-01T00:00:00"); + expect(isoFromDttm(dttm(2024, 13, 1, 0, 0))).toBeUndefined(); + }); + + it("accepts day 31, the highest value its 5-bit field can hold", () => { + expect(isoFromDttm(dttm(2024, 1, 31, 0, 0))).toBe("2024-01-31T00:00:00"); + }); + + it("pads single-digit month/day/hour/minute with a leading zero", () => { + expect(isoFromDttm(dttm(2024, 1, 2, 3, 4))).toBe("2024-01-02T03:04:00"); + }); +}); + +describe("dttmFromIso", () => { + it("returns undefined for a string that isn't an ISO date at all", () => { + expect(dttmFromIso("not a date")).toBeUndefined(); + }); + + it("round-trips a date and time through isoFromDttm", () => { + const bits = dttmFromIso("2024-03-15T09:30:00"); + if (bits === undefined) throw new Error("expected a defined DTTM value"); + expect(isoFromDttm(bits)).toBe("2024-03-15T09:30:00"); + }); + + it("defaults hour and minute to 0 for a date-only ISO string", () => { + const bits = dttmFromIso("2024-03-15"); + if (bits === undefined) throw new Error("expected a defined DTTM value"); + expect(isoFromDttm(bits)).toBe("2024-03-15T00:00:00"); + }); + + it("accepts the earliest representable year (the DTTM epoch itself)", () => { + expect(dttmFromIso("1900-01-01")).toBeDefined(); + }); + + it("rejects a year before the DTTM epoch", () => { + expect(dttmFromIso("1899-01-01")).toBeUndefined(); + }); + + it("accepts the latest representable year (epoch + the 9-bit field's own max)", () => { + expect(dttmFromIso("2411-01-01")).toBeDefined(); + }); + + it("rejects a year one past the latest representable year", () => { + expect(dttmFromIso("2412-01-01")).toBeUndefined(); + }); +}); + +describe("formFieldControlType", () => { + it("recognizes FORMCHECKBOX, FORMDROPDOWN, and FORMTEXT", () => { + expect(formFieldControlType("FORMCHECKBOX")).toBe("checkbox"); + expect(formFieldControlType("FORMDROPDOWN")).toBe("dropDown"); + expect(formFieldControlType("FORMTEXT")).toBe("plainText"); + }); + + it("is case-insensitive and tolerates leading whitespace", () => { + expect(formFieldControlType(" formtext")).toBe("plainText"); + }); + + it("returns undefined for an ordinary field instruction", () => { + expect( + formFieldControlType('HYPERLINK "http://example.com/FORMTEXT"'), + ).toBeUndefined(); + }); +}); + +function formFieldData( + overrides: Partial = {}, +): RtfFormFieldData { + return { + name: "", + helpText: "", + ownHelp: false, + listItems: [], + resultIndex: undefined, + defaultResultIndex: undefined, + protectedField: false, + ...overrides, + }; +} + +describe("formFieldContentControl", () => { + it("returns undefined for an ordinary field instruction", () => { + expect(formFieldContentControl("PAGE", undefined)).toBeUndefined(); + }); + + it("returns a bare descriptor with no formField data at all", () => { + expect(formFieldContentControl("FORMTEXT", undefined)).toEqual({ + kind: "contentControl", + controlType: "plainText", + }); + }); + + it("trims the name before using it as tag, and omits tag entirely once trimmed to empty", () => { + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ name: " field1 " }), + ), + ).toMatchObject({ tag: "field1" }); + expect( + formFieldContentControl("FORMTEXT", formFieldData({ name: " " })), + ).not.toHaveProperty("tag"); + }); + + it("sets alias only when ownHelp is true and the trimmed help text is non-empty", () => { + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ ownHelp: true, helpText: " Enter your name " }), + ), + ).toMatchObject({ alias: "Enter your name" }); + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ ownHelp: false, helpText: "Name" }), + ), + ).not.toHaveProperty("alias"); + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ ownHelp: true, helpText: " " }), + ), + ).not.toHaveProperty("alias"); + }); + + it("sets lock to content when protectedField is true, and omits it otherwise", () => { + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ protectedField: true }), + ), + ).toMatchObject({ lock: "content" }); + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ protectedField: false }), + ), + ).not.toHaveProperty("lock"); + }); + + describe("checkbox", () => { + it("is checked when resultIndex is 1", () => { + expect( + formFieldContentControl( + "FORMCHECKBOX", + formFieldData({ resultIndex: 1 }), + ), + ).toMatchObject({ checked: true }); + }); + + it("is unchecked when resultIndex is 0", () => { + expect( + formFieldContentControl( + "FORMCHECKBOX", + formFieldData({ resultIndex: 0 }), + ), + ).toMatchObject({ checked: false }); + }); + + it("falls through to defaultResultIndex when resultIndex is the undefined sentinel (25)", () => { + expect( + formFieldContentControl( + "FORMCHECKBOX", + formFieldData({ resultIndex: 25, defaultResultIndex: 1 }), + ), + ).toMatchObject({ checked: true }); + }); + + it("falls through to defaultResultIndex when resultIndex is absent entirely", () => { + expect( + formFieldContentControl( + "FORMCHECKBOX", + formFieldData({ resultIndex: undefined, defaultResultIndex: 1 }), + ), + ).toMatchObject({ checked: true }); + }); + + it("defaults to unchecked when neither resultIndex nor defaultResultIndex is usable", () => { + expect( + formFieldContentControl( + "FORMCHECKBOX", + formFieldData({ resultIndex: 25, defaultResultIndex: undefined }), + ), + ).toMatchObject({ checked: false }); + }); + + it("sets no options on a plain-text field even when its own listItems is non-empty", () => { + // controlType==="checkbox" is its own separate if-branch above, so a plainText field (neither checkbox nor dropDown) is what actually reaches this else-if's own condition -- listItems being non-empty must not be sufficient on its own to enter the dropDown branch without controlType actually being "dropDown". + expect( + formFieldContentControl( + "FORMTEXT", + formFieldData({ listItems: ["a"] }), + ), + ).not.toHaveProperty("options"); + }); + }); + + describe("dropDown", () => { + it("sets no options at all when listItems is empty", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems: [] }), + ), + ).not.toHaveProperty("options"); + }); + + it("sets options to a copy of listItems when non-empty", () => { + const result = formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems: ["a", "b"] }), + ); + expect(result?.options).toEqual(["a", "b"]); + }); + + it("sets value to the selected entry named by resultIndex", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems: ["a", "b", "c"], resultIndex: 1 }), + ), + ).toMatchObject({ value: "b" }); + }); + + it("falls through to defaultResultIndex when resultIndex is the undefined sentinel", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ + listItems: ["a", "b", "c"], + resultIndex: 25, + defaultResultIndex: 2, + }), + ), + ).toMatchObject({ value: "c" }); + }); + + it("sets no value when the selected index is negative", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ + listItems: ["a", "b"], + resultIndex: undefined, + defaultResultIndex: -1, + }), + ), + ).not.toHaveProperty("value"); + }); + + it("sets no value when the selected index is at or past listItems.length", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems: ["a", "b"], resultIndex: 2 }), + ), + ).not.toHaveProperty("value"); + // the last genuinely valid index still resolves + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems: ["a", "b"], resultIndex: 1 }), + ), + ).toMatchObject({ value: "b" }); + }); + + it("sets no value when the selected index names a genuine hole in a sparse listItems array", () => { + // A genuinely sparse array (index 1 has no own property at all, not merely an undefined value), not a simulated one -- listItems.length is still 3. + const listItems = new Array(3); + listItems[0] = "a"; + listItems[2] = "c"; + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ listItems, resultIndex: 1 }), + ), + ).not.toHaveProperty("value"); + }); + + it("sets no value at all when resultIndex is undefined and defaultResultIndex is too", () => { + expect( + formFieldContentControl( + "FORMDROPDOWN", + formFieldData({ + listItems: ["a", "b"], + resultIndex: undefined, + defaultResultIndex: undefined, + }), + ), + ).not.toHaveProperty("value"); + }); + }); +}); + +describe("coalesceRunConstructs", () => { + const anchor: ConstructDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "a", + }; + const other: ConstructDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "b", + }; + + it("returns an empty array for no runs at all", () => { + expect(coalesceRunConstructs([])).toEqual([]); + }); + + it("coalesces the same descriptor across adjacent runs into one extent", () => { + expect(coalesceRunConstructs([[anchor], [anchor], [anchor]])).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 3 }, + ]); + }); + + it("closes an extent the moment a run stops carrying the descriptor", () => { + expect(coalesceRunConstructs([[anchor], [anchor], []])).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 2 }, + ]); + }); + + it("closes a still-open extent at the end of the paragraph", () => { + expect(coalesceRunConstructs([[anchor]])).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 1 }, + ]); + }); + + it("reopens a fresh extent when the same descriptor reappears after a gap", () => { + expect(coalesceRunConstructs([[anchor], [], [anchor]])).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 1 }, + { descriptor: anchor, startRun: 2, endRun: 3 }, + ]); + }); + + it("tracks two distinct descriptors as independent extents", () => { + const result = coalesceRunConstructs([[anchor, other], [anchor], [other]]); + expect(result).toContainEqual({ + descriptor: anchor, + startRun: 0, + endRun: 2, + }); + expect(result).toContainEqual({ + descriptor: other, + startRun: 0, + endRun: 1, + }); + expect(result).toContainEqual({ + descriptor: other, + startRun: 2, + endRun: 3, + }); + }); + + it("sorts by startRun ascending, breaking a tie by endRun ascending", () => { + // Two descriptors open on the same run (tied startRun) but close at different runs -- the second call site (the end-of-paragraph cleanup, not the per-run close) inserts them in Map iteration order, which need not already be endRun-ascending. + const result = coalesceRunConstructs([[other, anchor], [other]]); + expect(result).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 1 }, + { descriptor: other, startRun: 0, endRun: 2 }, + ]); + }); + + it("sorts two overlapping extents by startRun even though the shorter one closes -- and is pushed -- first", () => { + // "a" opens at run 0 and stays open the whole time; "b" opens at run 1 and closes at run 2, before "a" does at run 3 -- so the per-run close pushes b's extent onto `out` before a's own extent reaches the end-of-paragraph cleanup, making raw insertion order [b, a], the reverse of the startRun order the sort must produce. + const result = coalesceRunConstructs([ + [anchor], + [anchor, other], + [anchor], + [anchor], + ]); + expect(result).toEqual([ + { descriptor: anchor, startRun: 0, endRun: 4 }, + { descriptor: other, startRun: 1, endRun: 2 }, + ]); + }); +}); diff --git a/packages/rtf-codec/src/constructs.ts b/packages/rtf-codec/src/constructs.ts index ba453ef35..b1bdeeb3a 100644 --- a/packages/rtf-codec/src/constructs.ts +++ b/packages/rtf-codec/src/constructs.ts @@ -197,8 +197,8 @@ export function isoFromDttm(value: number): string | undefined { const day = (bits >>> DTTM_DAY_SHIFT) & DTTM_DAY_MASK; const month = (bits >>> DTTM_MONTH_SHIFT) & DTTM_MONTH_MASK; const year = ((bits >>> DTTM_YEAR_SHIFT) & DTTM_YEAR_MASK) + DTTM_YEAR_EPOCH; - // A zero DTTM -- day 0, month 0 -- is what a producer writes for "no time recorded", and it is not a date. Rejecting it here keeps a fabricated 1900-00-00 out of dateIso rather than letting the field claim a timestamp the document never carried. - if (day === 0 || month === 0 || month > 12 || day > 31) { + // A zero DTTM -- day 0, month 0 -- is what a producer writes for "no time recorded", and it is not a date. Rejecting it here keeps a fabricated 1900-00-00 out of dateIso rather than letting the field claim a timestamp the document never carried. No day-range upper check is needed alongside month>12: day is already masked to 0-31 by DTTM_DAY_MASK above, so it can never exceed 31 in the first place -- unlike month, whose own 4-bit mask reaches as high as 15. + if (day === 0 || month === 0 || month > 12) { return undefined; } return ( @@ -219,8 +219,9 @@ export function dttmFromIso(dateIso: string): number | undefined { const year = Number(match[1]) - DTTM_YEAR_EPOCH; const month = Number(match[2]); const day = Number(match[3]); - const hour = match[4] === undefined ? 0 : Number(match[4]); - const minute = match[5] === undefined ? 0 : Number(match[5]); + // Number(undefined) is NaN when the optional time-of-day group didn't match, and NaN & mask is 0 by ToInt32 coercion -- the same "default to 0" a defined-check would spell out, so the bit-packing below already gives an absent hour/minute the right value with no explicit fallback needed. + const hour = Number(match[4]); + const minute = Number(match[5]); if (year < 0 || year > DTTM_YEAR_MASK) { return undefined; } @@ -313,17 +314,13 @@ export function formFieldContentControl( descriptor.checked = (current ?? formField.defaultResultIndex ?? 0) !== 0; } else if (controlType === "dropDown" && formField.listItems.length > 0) { descriptor.options = [...formField.listItems]; - // \ffres also names a dropdown's own currently selected entry -- the same field FFDataBits gives the checkbox's state, read here under iTypeDrop's own "zero-based index into \ffl" meaning instead. FFDataBits's reserved 25 sentinel (see FORM_FIELD_RESULT_UNDEFINED above) means "selection is undefined" for iTypeDrop exactly as it means "checkbox state is undefined" for iTypeChck, so the identical sentinel-then-default fallback the checkbox branch above applies to its own `current` value applies here too: a sentinel or absent \ffres falls through to \ffdefres, the field's own recorded default selection. Confirmed against a real PHPRtfLite fixture, which always emits the sentinel \ffres25 alongside a meaningful \ffdefres -- without this fallback, a real-world dropdown's selection is silently lost even though \ffdefres names it. Bounds-checked against the list actually read, since an out-of-range index names no real entry. + // \ffres also names a dropdown's own currently selected entry -- the same field FFDataBits gives the checkbox's state, read here under iTypeDrop's own "zero-based index into \ffl" meaning instead. FFDataBits's reserved 25 sentinel (see FORM_FIELD_RESULT_UNDEFINED above) means "selection is undefined" for iTypeDrop exactly as it means "checkbox state is undefined" for iTypeChck, so the identical sentinel-then-default fallback the checkbox branch above applies to its own `current` value applies here too: a sentinel or absent \ffres falls through to \ffdefres, the field's own recorded default selection. Confirmed against a real PHPRtfLite fixture, which always emits the sentinel \ffres25 alongside a meaningful \ffdefres -- without this fallback, a real-world dropdown's selection is silently lost even though \ffdefres names it. No separate bounds check against the list actually read: a negative index or one at or past listItems.length reads back undefined from the array exactly like a genuine sparse hole inside range does, so the `selected !== undefined` check below already excludes every out-of-range index on its own. const current = formField.resultIndex === FORM_FIELD_RESULT_UNDEFINED ? undefined : formField.resultIndex; const selectedIndex = current ?? formField.defaultResultIndex; - if ( - selectedIndex !== undefined && - selectedIndex >= 0 && - selectedIndex < formField.listItems.length - ) { + if (selectedIndex !== undefined) { const selected = formField.listItems[selectedIndex]; if (selected !== undefined) { descriptor.value = selected; @@ -342,9 +339,12 @@ export function coalesceRunConstructs( { descriptor: ConstructDescriptor; start: number } >(); const out: RunConstructExtent[] = []; - const close = (key: string, end: number): void => { - const entry = open.get(key); - if (entry === undefined) return; + // Takes the entry itself, not a key to re-look-up: both call sites below already hold it from iterating `open` directly, so there is no "key not found" case to guard against here -- Map.get would only ever repeat a lookup the caller has already done. + const close = ( + key: string, + entry: { descriptor: ConstructDescriptor; start: number }, + end: number, + ): void => { out.push({ descriptor: entry.descriptor, startRun: entry.start, @@ -356,9 +356,9 @@ export function coalesceRunConstructs( const present = new Map( descriptors.map((descriptor) => [JSON.stringify(descriptor), descriptor]), ); - for (const key of [...open.keys()]) { + for (const [key, entry] of [...open]) { if (!present.has(key)) { - close(key, index); + close(key, entry, index); } } for (const [key, descriptor] of present) { @@ -367,12 +367,9 @@ export function coalesceRunConstructs( } } } - for (const key of [...open.keys()]) { - close(key, perRun.length); + for (const [key, entry] of [...open]) { + close(key, entry, perRun.length); } - // Document order by where each extent starts, so a paragraph's constructs array reads the way the source did. - return out.sort( - (left, right) => - left.startRun - right.startRun || left.endRun - right.endRun, - ); + // Document order by where each extent starts, so a paragraph's constructs array reads the way the source did. No endRun tie-break is needed alongside it: every entry sharing a startRun was necessarily pushed into `out` above in ascending close-run order (the per-run loop above visits run indices strictly increasingly, closing whatever is open at each one), so Array.prototype.sort's guaranteed stability (ES2019+) already preserves that ascending order for a tied startRun with no second comparison required. + return out.sort((left, right) => left.startRun - right.startRun); } diff --git a/packages/rtf-codec/src/diagnostics-coverage.test.ts b/packages/rtf-codec/src/diagnostics-coverage.test.ts index 51cd90d93..9a61b8ab0 100644 --- a/packages/rtf-codec/src/diagnostics-coverage.test.ts +++ b/packages/rtf-codec/src/diagnostics-coverage.test.ts @@ -176,15 +176,51 @@ describe("every write-side diagnostic code is reachable", () => { }); it("rtf/package-table-dropped", () => { + const table = { + n1: { + kind: "footnote" as const, + blocks: [{ kind: "paragraph" as const, runs: [{ text: "note" }] }], + }, + }; + const children: DocumentTree["children"] = [ + { + node: { kind: "section", ...SECTION }, + children: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ]; + // Each of the four package tables independently triggers the diagnostic -- hasPackageTables ORs all four, so a fixture setting only one at a time is the only way to prove each disjunct is actually load-bearing rather than merely reachable through definitions alone. + for (const field of [ + "definitions", + "layers", + "attachments", + "destinations", + ] as const) { + const documentPackage: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + [field]: table, + children, + }; + const diagnostics: RtfDiagnostic[] = []; + writeRtf(documentPackage, { + sink: (diagnostic) => diagnostics.push(diagnostic), + }); + collect(diagnostics); + expect(diagnostics).toEqual([ + { + code: RtfDiagnosticCodes.PACKAGE_TABLE_DROPPED, + severity: "info", + message: + "the package's definitions/layers/attachments/destinations tables are dropped: flattening resolves style refs, and RTF has no destination for the remaining tenants", + }, + ]); + } + }); + + it("writes no rtf/package-table-dropped diagnostic when none of the four package tables are present", () => { const documentPackage: DocumentTree = { kind: "wordprocessing", metadata: {}, - definitions: { - n1: { - kind: "footnote", - blocks: [{ kind: "paragraph", runs: [{ text: "note" }] }], - }, - }, children: [ { node: { kind: "section", ...SECTION }, @@ -192,9 +228,11 @@ describe("every write-side diagnostic code is reachable", () => { }, ], }; - const codes: RtfDiagnostic[] = []; - writeRtf(documentPackage, { sink: (diagnostic) => codes.push(diagnostic) }); - expect(collect(codes)).toContain(RtfDiagnosticCodes.PACKAGE_TABLE_DROPPED); + const diagnostics: RtfDiagnostic[] = []; + writeRtf(documentPackage, { + sink: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(diagnostics).toEqual([]); }); }); diff --git a/packages/rtf-codec/src/diagnostics.test.ts b/packages/rtf-codec/src/diagnostics.test.ts new file mode 100644 index 000000000..7e95465f1 --- /dev/null +++ b/packages/rtf-codec/src/diagnostics.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + RtfInputTooLargeError, + RtfNestingLimitExceededError, + RtfNotAnRtfDocumentError, + RtfParseError, + RtfUnsupportedDocumentKindError, + RtfWriteError, +} from "./diagnostics"; + +describe("RtfParseError", () => { + it("carries the given code and message, named RtfParseError", () => { + const error = new RtfParseError("rtf/example", "an example message"); + expect(error.name).toBe("RtfParseError"); + expect(error.code).toBe("rtf/example"); + expect(error.message).toBe("an example message"); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe("RtfNotAnRtfDocumentError", () => { + it("defaults to its own code and message", () => { + const error = new RtfNotAnRtfDocumentError(); + expect(error.name).toBe("RtfNotAnRtfDocumentError"); + expect(error.code).toBe("rtf/not-an-rtf-document"); + expect(error.message).toBe("input does not begin with '{\\rtf'"); + expect(error).toBeInstanceOf(RtfParseError); + }); + + it("accepts a caller-supplied message while keeping its own code", () => { + const error = new RtfNotAnRtfDocumentError("custom message"); + expect(error.message).toBe("custom message"); + expect(error.code).toBe("rtf/not-an-rtf-document"); + }); +}); + +describe("RtfInputTooLargeError", () => { + it("names its own code, byteLength, maxInputBytes, and a message stating both", () => { + const error = new RtfInputTooLargeError(100, 64); + expect(error.name).toBe("RtfInputTooLargeError"); + expect(error.code).toBe("rtf/input-too-large"); + expect(error.byteLength).toBe(100); + expect(error.maxInputBytes).toBe(64); + expect(error.message).toBe("input is 100 bytes, over the 64-byte limit"); + expect(error).toBeInstanceOf(RtfParseError); + }); +}); + +describe("RtfNestingLimitExceededError", () => { + it("names its own code, maxGroupDepth, and a message stating it", () => { + const error = new RtfNestingLimitExceededError(256); + expect(error.name).toBe("RtfNestingLimitExceededError"); + expect(error.code).toBe("rtf/nesting-limit-exceeded"); + expect(error.maxGroupDepth).toBe(256); + expect(error.message).toBe("group nesting exceeded the 256-level limit"); + expect(error).toBeInstanceOf(RtfParseError); + }); +}); + +describe("RtfWriteError", () => { + it("carries the given code and message, named RtfWriteError", () => { + const error = new RtfWriteError( + "rtf/example-write", + "a write-side message", + ); + expect(error.name).toBe("RtfWriteError"); + expect(error.code).toBe("rtf/example-write"); + expect(error.message).toBe("a write-side message"); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe("RtfUnsupportedDocumentKindError", () => { + it("names its own code, documentKind, and a message stating it", () => { + const error = new RtfUnsupportedDocumentKindError("spreadsheet"); + expect(error.name).toBe("RtfUnsupportedDocumentKindError"); + expect(error.code).toBe("rtf/unsupported-document-kind"); + expect(error.documentKind).toBe("spreadsheet"); + expect(error.message).toBe( + "RTF is a wordprocessing format; a 'spreadsheet' ContentDocument has no RTF representation", + ); + expect(error).toBeInstanceOf(RtfWriteError); + }); +}); diff --git a/packages/rtf-codec/src/embedded-object.test.ts b/packages/rtf-codec/src/embedded-object.test.ts index 248119934..81d5f6206 100644 --- a/packages/rtf-codec/src/embedded-object.test.ts +++ b/packages/rtf-codec/src/embedded-object.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; -import { writeCompoundFile, writeOlePackage } from "archive-codec"; +import { + readCompoundFile, + readOlePackage, + writeCompoundFile, + writeOlePackage, +} from "archive-codec"; import type { ContentEmbeddedObject } from "document-schema.js"; import { readEmbeddedObjectData, + skipClipboardFormatHeader, + skipPresentationObject, + skipPresentationObjectHeader, writeEmbeddedObjectData, } from "./embedded-object"; @@ -111,17 +119,120 @@ describe("readEmbeddedObjectData", () => { // There must genuinely be bytes there -- a payload that ends exactly at NativeData (the pre-fix shape) would leave nothing here at all. expect(bytes.length).toBeGreaterThan(presentationStart); // Those bytes must themselves be a real, structurally valid Presentation field: a PresentationObjectHeader (OLEVersion, FormatID 0x00000005, an empty ClassName) then ClipboardFormat CF_DIB (0x00000008), matching what [MS-OLEDS] 2.2.3.1/2.2.3.2 require, not arbitrary filler of the right length. + const presentationOleVersion = view.getUint32(presentationStart, true); + expect(presentationOleVersion).toBe(0x00000501); const presentationFormatId = view.getUint32(presentationStart + 4, true); expect(presentationFormatId).toBe(0x00000005); const presentationClassNameLength = view.getUint32( presentationStart + 8, true, ); - expect(presentationClassNameLength).toBe(0); // an empty ClassName -- see PRESENTATION_CLASS_NAME's own reasoning in embedded-object.ts + expect(presentationClassNameLength).toBe(0); // an empty ClassName -- see writePresentationObjectHeader's own reasoning in embedded-object.ts const clipboardFormat = view.getUint32(presentationStart + 12, true); expect(clipboardFormat).toBe(0x00000008); // CF_DIB }); + it("writes ObjectHeader's OLEVersion and ClassName exactly, byte for byte", () => { + const bytes = writeEmbeddedObjectData(embedded); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + expect(view.getUint32(0, true)).toBe(0x00000501); // OLEVersion + expect(view.getUint32(4, true)).toBe(0x00000002); // FormatID -- EmbeddedObject + const classNameLength = view.getUint32(8, true); + expect(classNameLength).toBe("Package".length + 1); // + the terminating null character + const classNameChars = Array.from( + bytes.subarray(12, 12 + "Package".length), + ).map((code) => String.fromCharCode(code)); + expect(classNameChars.join("")).toBe("Package"); // proves every character was copied, not a truncated or overrun prefix + expect(bytes[12 + "Package".length]).toBe(0); // the terminating null character + }); + + it("writes writeMinimalDib's own DeviceIndependentBitmap Object exactly, field for field", () => { + const bytes = writeEmbeddedObjectData(embedded); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const classNameLength = "Package".length + 1; + const objectHeaderLength = 8 + (4 + classNameLength) + 4 + 4; + const nativeDataSize = view.getUint32(objectHeaderLength, true); + const presentationStart = objectHeaderLength + 4 + nativeDataSize; + // PresentationObjectHeader (12: OLEVersion+FormatID+empty ClassName) + ClipboardFormat (4) + PresentationDataSize (4). + const dibStart = presentationStart + 12 + 4 + 4; + expect(view.getUint32(dibStart, true)).toBe(40); // BitmapInfoHeader's own fixed size + expect(view.getInt32(dibStart + 4, true)).toBe(1); // Width + expect(view.getInt32(dibStart + 8, true)).toBe(1); // Height + expect(view.getUint16(dibStart + 12, true)).toBe(1); // Planes + expect(view.getUint16(dibStart + 14, true)).toBe(1); // BitCount -- monochrome + expect(view.getUint32(dibStart + 16, true)).toBe(0); // Compression -- BI_RGB + expect(view.getUint32(dibStart + 20, true)).toBe(0); // ImageSize + expect(view.getInt32(dibStart + 24, true)).toBe(0); // XPelsPerMeter + expect(view.getInt32(dibStart + 28, true)).toBe(0); // YPelsPerMeter + expect(view.getUint32(dibStart + 32, true)).toBe(2); // ColorUsed -- both entries of the 2-colour table + expect(view.getUint32(dibStart + 36, true)).toBe(0); // ColorImportant + // Two RGBQuad entries: black then white. + expect(Array.from(bytes.subarray(dibStart + 40, dibStart + 44))).toEqual([ + 0x00, 0x00, 0x00, 0x00, + ]); + expect(Array.from(bytes.subarray(dibStart + 44, dibStart + 48))).toEqual([ + 0xff, 0xff, 0xff, 0x00, + ]); + // rowBytes (4, from the (Width*Planes*BitCount+31)&~31)/8 formula) * abs(Height) (1) = 4 bytes of packed monochrome pixel data, already all-zero -- the one pixel indexes colour 0 (black). + expect(bytes.length - (dibStart + 48)).toBe(4); + // The whole DIB is exactly HeaderSize(40) + 2 colours * 4 bytes + 4 bytes of pixel data = 52 bytes, and PresentationDataSize must declare exactly that. + const presentationDataSize = view.getUint32(presentationStart + 16, true); + expect(presentationDataSize).toBe(52); + expect(bytes.length).toBe(dibStart + 52); + }); + + it("writes an empty sourcePath and tempPath into the Package stream, not arbitrary filler", () => { + const bytes = writeEmbeddedObjectData(embedded); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const classNameLength = "Package".length + 1; + const objectHeaderLength = 8 + (4 + classNameLength) + 4 + 4; + const nativeDataSize = view.getUint32(objectHeaderLength, true); + const nativeData = bytes.subarray( + objectHeaderLength + 4, + objectHeaderLength + 4 + nativeDataSize, + ); + const streams = readCompoundFile(nativeData); + const packageStream = streams.find((stream) => stream.path === "Package"); + if (packageStream === undefined) { + throw new Error("expected a Package stream"); + } + const olePackage = readOlePackage(packageStream.bytes); + expect(olePackage.sourcePath).toBe(""); + expect(olePackage.tempPath).toBe(""); + }); + + it("finds the Package stream by name, not merely by being present in the compound file", () => { + // [MS-CFB] 2.6.4's own directory-entry sort compares by name LENGTH first, then content, so a name shorter than "Package" (7 characters) is what actually guarantees it sorts, and therefore reads back, before "Package" -- picking whichever stream happens to come first, rather than the one actually named "Package", would hand readOlePackage bytes it cannot parse. + const packageBytes = writeOlePackage({ + label: "test.json", + sourcePath: "", + tempPath: "", + fileBytes: new TextEncoder().encode(JSON.stringify(embedded)), + }); + const nativeData = writeCompoundFile([ + { path: "AAA", bytes: new TextEncoder().encode("not a package") }, + { path: "Package", bytes: packageBytes }, + ]); + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData, + }); + expect(readEmbeddedObjectData(bytes)).toEqual(embedded); + }); + + it("rejects a compound file with no stream named Package at all", () => { + const nativeData = writeCompoundFile([ + { path: "NotPackage", bytes: new TextEncoder().encode("{}") }, + ]); + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData, + }); + expect(readEmbeddedObjectData(bytes)).toBeUndefined(); + }); + it("rejects a payload whose bytes end exactly at NativeData, with no Presentation field at all", () => { // The pre-fix shape this writer used to produce: a real, decodable NativeData with nothing after it. EmbeddedObject's own fourth field is mandatory, so this is no longer spec-conformant \\objdata even though NativeData alone still decodes. const bytes = buildEmbeddedObjectBytes({ @@ -143,6 +254,34 @@ describe("readEmbeddedObjectData", () => { expect(readEmbeddedObjectData(bytes)).toBeUndefined(); }); + it("rejects a PresentationObjectHeader.FormatID other than 0x00000005, even when otherwise well-formed", () => { + const presentation = presentationObjectBytes(); + const buffer = Uint8Array.from(presentation); + // FormatID is the 4 bytes right after OLEVersion. + new DataView(buffer.buffer).setUint32(4, 0x00000000, true); + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData: packagedJson(embedded), + presentation: Array.from(buffer), + }); + expect(readEmbeddedObjectData(bytes)).toBeUndefined(); + }); + + it("rejects a ClipboardFormat other than CF_DIB, even when otherwise well-formed", () => { + const presentation = presentationObjectBytes(); + const buffer = Uint8Array.from(presentation); + // ClipboardFormat is the 4 bytes right after PresentationObjectHeader (OLEVersion + FormatID + empty ClassName = 12 bytes). + new DataView(buffer.buffer).setUint32(12, 0x00000002, true); // CF_BITMAP, not CF_DIB + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData: packagedJson(embedded), + presentation: Array.from(buffer), + }); + expect(readEmbeddedObjectData(bytes)).toBeUndefined(); + }); + it("rejects a Presentation field whose PresentationDataSize overruns the bytes actually present", () => { const presentation = presentationObjectBytes(); const buffer = Uint8Array.from(presentation); @@ -201,6 +340,41 @@ describe("readEmbeddedObjectData", () => { expect(readEmbeddedObjectData(withOverrun)).toBeUndefined(); }); + it("rejects a NativeDataSize declaring exactly one byte more than actually remains", () => { + const realNativeData = packagedJson(embedded); + const header = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData: realNativeData, + presentation: [], + }); + const withOffByOne = header.slice(); + new DataView(withOffByOne.buffer).setUint32( + withOffByOne.length - realNativeData.length - 4, + realNativeData.length + 1, + true, + ); + expect(readEmbeddedObjectData(withOffByOne)).toBeUndefined(); + }); + + it("rejects a PresentationDataSize declaring exactly one byte more than actually remains", () => { + const presentation = presentationObjectBytes(); + const dib = Uint8Array.from(presentation.slice(-4)); // the 4-byte PresentationData this helper always writes + const withOffByOne = Uint8Array.from(presentation); + new DataView(withOffByOne.buffer).setUint32( + presentation.length - 4 - dib.length, + dib.length + 1, + true, + ); + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData: packagedJson(embedded), + presentation: Array.from(withOffByOne), + }); + expect(readEmbeddedObjectData(bytes)).toBeUndefined(); + }); + it("rejects bytes too short to hold even an ObjectHeader's own OLEVersion/FormatID pair", () => { expect(readEmbeddedObjectData(Uint8Array.from([1, 2, 3]))).toBeUndefined(); }); @@ -258,4 +432,128 @@ describe("readEmbeddedObjectData", () => { const bytes = writeEmbeddedObjectData(anchored); expect(readEmbeddedObjectData(bytes)).toEqual(anchored); }); + + it("omits every optional field entirely when the source embed carries none of them", () => { + const bytes = writeEmbeddedObjectData(embedded); + const result = readEmbeddedObjectData(bytes); + expect(result).not.toHaveProperty("anchorRow"); + expect(result).not.toHaveProperty("anchorColumn"); + expect(result).not.toHaveProperty("offsetXPt"); + expect(result).not.toHaveProperty("offsetYPt"); + expect(result).not.toHaveProperty("source"); + }); + + it.each([ + ["a bare JSON null", "null"], + ["a bare JSON array", "[]"], + ["a bare JSON string", '"just a string"'], + ["a bare JSON number", "42"], + ])( + "rejects a NativeData payload that decodes to %s rather than a JSON object", + (_description, json) => { + const packageBytes = writeOlePackage({ + label: "test.json", + sourcePath: "", + tempPath: "", + fileBytes: new TextEncoder().encode(json), + }); + const nativeData = writeCompoundFile([ + { path: "Package", bytes: packageBytes }, + ]); + const bytes = buildEmbeddedObjectBytes({ + formatId: 0x00000002, + className: "Package", + nativeData, + }); + expect(readEmbeddedObjectData(bytes)).toBeUndefined(); + }, + ); +}); + +// readEmbeddedObjectData's single shared catch discards whatever these three throw, along with skipPresentationObject's own return value (never read back by any caller) -- so their exact thrown text and return offset need direct coverage here, not another round trip through readEmbeddedObjectData, to be observed at all. +describe("skipPresentationObjectHeader", () => { + it("throws the exact FormatID this module's own writer never produces, zero-padded to 8 hex digits", () => { + const bytes = Uint8Array.from([ + ...uint32Le(0x00000501), // OLEVersion + ...uint32Le(0x00000000), // FormatID -- wrong + ...lengthPrefixedAnsiString(""), + ]); + expect(() => skipPresentationObjectHeader(bytes, 0)).toThrow( + "PresentationObjectHeader.FormatID is 0x00000000, not the 0x00000005 this module's own Presentation field always writes", + ); + }); + + it("returns the offset immediately past a well-formed header's own ClassName", () => { + const bytes = Uint8Array.from([ + ...uint32Le(0x00000501), + ...uint32Le(0x00000005), + ...lengthPrefixedAnsiString(""), // 4 bytes: an empty ClassName's own zero length prefix + ]); + expect(skipPresentationObjectHeader(bytes, 0)).toBe(12); + }); +}); + +describe("skipClipboardFormatHeader", () => { + function presentationHeaderBytes(): number[] { + return [ + ...uint32Le(0x00000501), + ...uint32Le(0x00000005), + ...lengthPrefixedAnsiString(""), + ]; + } + + it("throws the exact ClipboardFormat this module's own writer never produces, zero-padded to 8 hex digits", () => { + const bytes = Uint8Array.from([ + ...presentationHeaderBytes(), + ...uint32Le(0x00000002), // CF_BITMAP, not CF_DIB + ]); + expect(() => skipClipboardFormatHeader(bytes, 0)).toThrow( + "ClipboardFormatHeader.ClipboardFormat is 0x00000002, not the CF_DIB (0x00000008) this module's own Presentation field always writes", + ); + }); + + it("returns the offset immediately past a well-formed header's own ClipboardFormat", () => { + const bytes = Uint8Array.from([ + ...presentationHeaderBytes(), + ...uint32Le(0x00000008), // CF_DIB + ]); + expect(skipClipboardFormatHeader(bytes, 0)).toBe(16); + }); +}); + +describe("skipPresentationObject", () => { + function clipboardFormatHeaderBytes(): number[] { + return [ + ...uint32Le(0x00000501), + ...uint32Le(0x00000005), + ...lengthPrefixedAnsiString(""), + ...uint32Le(0x00000008), // CF_DIB + ]; + } + + it("throws the exact declared and actually-remaining byte counts when PresentationDataSize overruns", () => { + const bytes = Uint8Array.from([ + ...clipboardFormatHeaderBytes(), + ...uint32Le(10), // PresentationDataSize claims 10 bytes + 0, + 0, + 0, // but only 3 remain + ]); + expect(() => skipPresentationObject(bytes, 0)).toThrow( + "StandardClipboardFormatPresentationObject's PresentationDataSize declares 10 bytes but only 3 remain", + ); + }); + + it("returns dataStart plus PresentationDataSize, not dataStart minus it, for a well-formed field", () => { + const bytes = Uint8Array.from([ + ...clipboardFormatHeaderBytes(), + ...uint32Le(4), + 0xaa, + 0xbb, + 0xcc, + 0xdd, + ]); + // dataStart is 16 (clipboardFormatHeaderBytes' own length) + 4 (the PresentationDataSize field itself) = 20; +4 more of PresentationData = 24. A dataStart-minus-size mutant would return 12 instead. + expect(skipPresentationObject(bytes, 0)).toBe(24); + }); }); diff --git a/packages/rtf-codec/src/embedded-object.ts b/packages/rtf-codec/src/embedded-object.ts index 6109f0ac7..a71d4d5b6 100644 --- a/packages/rtf-codec/src/embedded-object.ts +++ b/packages/rtf-codec/src/embedded-object.ts @@ -29,14 +29,10 @@ const OBJECT_HEADER_OLE_VERSION = 0x00000501; // ObjectHeader.ClassName for this module's own EmbeddedObject: "Package" is the class name real Word/PowerPoint write for exactly this shape -- an OLE Package stream sitting inside the object's native data -- so a real OLE-aware consumer that cannot decode our own JSON payload still sees a recognisable, accurate class rather than an invented ProgID. const OBJECT_HEADER_CLASS_NAME = "Package"; -// The ANSI decoder for ObjectHeader's three LengthPrefixedAnsiString fields, matching archive-codec's own cfb/ole-package.ts convention for the identical kind of producer-locale ANSI string. -const ANSI_DECODER = new TextDecoder("windows-1252"); - -// [MS-OLEDS] 2.2.1 PresentationObjectHeader.FormatID: "This MUST be set to 0x00000000 or 0x00000005 ... 0x00000005 [means] The ClassName field is present." This module's own Presentation field always carries a ClassName (see PRESENTATION_CLASS_NAME below), so it only ever writes 0x00000005, and only ever accepts that value back -- a real presentation object with FormatID 0x00000000 exists in principle but is not a shape this module's own writer ever produces. +// [MS-OLEDS] 2.2.1 PresentationObjectHeader.FormatID: "This MUST be set to 0x00000000 or 0x00000005 ... 0x00000005 [means] The ClassName field is present." This module's own Presentation field always carries a ClassName (an empty one -- see writePresentationObjectHeader below), so it only ever writes 0x00000005, and only ever accepts that value back -- a real presentation object with FormatID 0x00000000 exists in principle but is not a shape this module's own writer ever produces. const PRESENTATION_OBJECT_HEADER_FORMAT_ID = 0x00000005; -// [MS-OLEDS] 2.2.3.1 ClipboardFormatHeader: "The FormatID field of the PresentationObjectHeader MUST NOT be set to 0x00000000 and the ClassName field of the Header MUST NOT be set to 'METAFILEPICT', 'DIB', or 'BITMAP'" -- those three reserved names route a presentation object through EmbeddedObject's own three type-specific structures (2.2.2.1-2.2.2.3) instead. An empty ClassName satisfies "MUST NOT be [one of those three]" trivially and needs no codepage table beyond ASCII, routing this module's own Presentation field through the generic ClipboardFormatHeader path (2.2.3) every time. -const PRESENTATION_CLASS_NAME = ""; +// [MS-OLEDS] 2.2.3.1 ClipboardFormatHeader: "The FormatID field of the PresentationObjectHeader MUST NOT be set to 0x00000000 and the ClassName field of the Header MUST NOT be set to 'METAFILEPICT', 'DIB', or 'BITMAP'" -- those three reserved names route a presentation object through EmbeddedObject's own three type-specific structures (2.2.2.1-2.2.2.3) instead. An empty ClassName (see writePresentationObjectHeader below) satisfies "MUST NOT be [one of those three]" trivially and needs no codepage table beyond ASCII, routing this module's own Presentation field through the generic ClipboardFormatHeader path (2.2.3) every time. // [MS-OLEDS] 2.1.1's own Standard Clipboard Formats table names exactly four values: CF_BITMAP (0x2), CF_METAFILEPICT (0x3), CF_DIB (0x8), CF_ENHMETAFILE (0xE) -- each naming a real image sub-structure ([MS-WMF]'s Bitmap16, a Windows metafile, a DeviceIndependentBitmap Object, or an enhanced metafile, respectively). CF_DIB's DeviceIndependentBitmap Object (2.2.2.9) is the only one of the four this module can build without a full metafile/enhanced-metafile record writer, so it is the one this module's own Presentation field always declares. const CLIPBOARD_FORMAT_CF_DIB = 0x00000008; @@ -47,100 +43,62 @@ const DIB_HEADER_SIZE_BITMAPINFOHEADER = 40; // [MS-WMF] 2.1.1.3 BitCount Enumeration's BI_BITCOUNT_1: "The image is specified with two colours... represented by a single bit." The smallest legal pixel depth a DeviceIndependentBitmap Object can declare, paired with a 2-entry RGBQuad colour table below. const DIB_BIT_COUNT_MONOCHROME = 1; -// [MS-OLEDS] 2.1.4 LengthPrefixedAnsiString: "Length (4 bytes): This MUST be set to the number of ANSI characters in the String field, including the terminating null character. Length MUST be set to 0x00000000 to indicate an empty string." -- so an empty string is the 4-byte zero length alone, with no String field at all, not a length of 1 holding just a null byte. +// [MS-OLEDS] 2.1.4 LengthPrefixedAnsiString: "Length (4 bytes): This MUST be set to the number of ANSI characters in the String field, including the terminating null character." Every field this module's own writer actually needs an EMPTY LengthPrefixedAnsiString for (ObjectHeader's TopicName/ItemName, PresentationObjectHeader's ClassName) is a fixed 4-byte zero length with no String field at all, already satisfied by a freshly zero-initialized Uint8Array with no call to this function needed -- so this function's only remaining caller (OBJECT_HEADER_CLASS_NAME, "Package") ever passes it a non-empty value, and there is no empty-string case left to special-case here. function writeLengthPrefixedAnsiString(value: string): Uint8Array { - if (value.length === 0) { - return new Uint8Array(4); // already zero -- Length = 0x00000000 - } - for (let index = 0; index < value.length; index++) { - const code = value.charCodeAt(index); - if (code > 0x7f) { - throw new Error( - `ObjectHeader string contains a character (U+${code.toString(16).padStart(4, "0")}) outside ASCII; encoding it to an arbitrary windows-1252 byte would need a full codepage table this module does not carry`, - ); - } - } const length = value.length + 1; // + the terminating null character, per LengthPrefixedAnsiString's own field definition const out = new Uint8Array(4 + length); const view = new DataView(out.buffer); view.setUint32(0, length, true); - for (let index = 0; index < value.length; index++) { + // index increments by exactly 1 every iteration, so it can never skip past value.length -- !== is exactly equivalent to < here, and unlike <, an off-by-one mutation of it (=== in place of !==) stops the loop from running at all instead of surviving unobserved. + for (let index = 0; index !== value.length; index++) { out[4 + index] = value.charCodeAt(index); } // out[4 + value.length] is already zero -- the terminating null character. return out; } -// The mirror of writeLengthPrefixedAnsiString. Throws on any structural shortfall (loud failure over a truncated string that looks complete), matching archive-codec's readZeroTerminated/readOlePackage precedent for the identical class of format. +// The mirror of writeLengthPrefixedAnsiString, returning only the offset immediately past the field -- every caller (readObjectHeader, skipPresentationObjectHeader) walks past a LengthPrefixedAnsiString purely to reach whatever follows it, and never inspects the string itself (ClassName/TopicName/ItemName all name a shape this reader already knows from context, not information it acts on), so this skips decoding it at all rather than computing a value nothing reads. Reading the Length field needs no explicit bounds check: DataView.getUint32 already throws a RangeError when offset+4 exceeds bytes.length, caught by readEmbeddedObjectData's own shared catch. The declared String length is never validated against bytes.length either, even though Uint8Array.subarray would silently clamp an out-of-range end index rather than throw were the bytes actually decoded: the returned offset alone is what every caller trusts, and an offset that overruns the real buffer is still caught the moment it is next used for a bounds-checked read (a getUint32 field, or the final NativeData/PresentationData subarray-and-decode) -- there is no path through this module where an overlong declared length reaches a caller that trusts it uninspected. function readLengthPrefixedAnsiString( bytes: Uint8Array, offset: number, - fieldName: string, -): { readonly value: string; readonly next: number } { - if (offset + 4 > bytes.length) { - throw new Error(`ObjectHeader ends before its ${fieldName} length field`); - } +): number { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const length = view.getUint32(offset, true); offset += 4; - if (length === 0) { - return { value: "", next: offset }; - } - if (offset + length > bytes.length) { - throw new Error( - `ObjectHeader's ${fieldName} declares ${length} bytes but only ${bytes.length - offset} remain`, - ); - } - // length includes the terminating null character (LengthPrefixedAnsiString's own definition); drop it rather than decode it as a character. - const value = ANSI_DECODER.decode( - bytes.subarray(offset, offset + length - 1), - ); - return { value, next: offset + length }; + // length includes the terminating null character -- LengthPrefixedAnsiString's own definition -- so the field's total byte span is exactly `length` past this point. No dedicated zero-length case: offset + 0 is already offset, the exact value an empty string's own dedicated early return would have produced. + return offset + length; } -// Builds an [MS-OLEDS] 2.2.4 ObjectHeader for an EmbeddedObject: OLEVersion, FormatID (fixed at 0x00000002 -- this module never writes a LinkedObject), then ClassName/TopicName/ItemName as LengthPrefixedAnsiStrings. TopicName and ItemName are both empty: "If the ObjectHeader structure is contained by an EmbeddedObject structure ... the TopicName [ItemName] field SHOULD contain an empty string and MUST be ignored on processing" -- both are LinkedObject-only fields. +// Builds an [MS-OLEDS] 2.2.4 ObjectHeader for an EmbeddedObject: OLEVersion, FormatID (fixed at 0x00000002 -- this module never writes a LinkedObject), then ClassName/TopicName/ItemName as LengthPrefixedAnsiStrings. TopicName and ItemName are both empty: "If the ObjectHeader structure is contained by an EmbeddedObject structure ... the TopicName [ItemName] field SHOULD contain an empty string and MUST be ignored on processing" -- both are LinkedObject-only fields. An empty LengthPrefixedAnsiString is 4 zero bytes (writeLengthPrefixedAnsiString's own empty-string case), which `out`'s own zero-initialization already provides, so there is nothing to actually write for either -- only their 4-byte span needs accounting for. function writeObjectHeader(): Uint8Array { const classNameBytes = writeLengthPrefixedAnsiString( OBJECT_HEADER_CLASS_NAME, ); - const topicNameBytes = writeLengthPrefixedAnsiString(""); - const itemNameBytes = writeLengthPrefixedAnsiString(""); + const emptyNameFieldBytes = 4 + 4; // TopicName's own 4-byte zero length, then ItemName's const out = new Uint8Array( 4 + // OLEVersion 4 + // FormatID classNameBytes.length + - topicNameBytes.length + - itemNameBytes.length, + emptyNameFieldBytes, ); const view = new DataView(out.buffer); view.setUint32(0, OBJECT_HEADER_OLE_VERSION, true); view.setUint32(4, OBJECT_HEADER_FORMAT_ID_EMBEDDED, true); - let offset = 8; - out.set(classNameBytes, offset); - offset += classNameBytes.length; - out.set(topicNameBytes, offset); - offset += topicNameBytes.length; - out.set(itemNameBytes, offset); + out.set(classNameBytes, 8); return out; } -// The mirror of writeObjectHeader, generalised to accept either FormatID this reader may legitimately encounter (a real \object could carry a LinkedObject's header, even though this module's own writer never produces one). OLEVersion is read and discarded -- "MUST be ignored on receipt" is the spec's own instruction, not this module's choice. +// The mirror of writeObjectHeader, generalised to accept either FormatID this reader may legitimately encounter (a real \object could carry a LinkedObject's header, even though this module's own writer never produces one). OLEVersion is read and discarded -- "MUST be ignored on receipt" is the spec's own instruction, not this module's choice. Reading FormatID needs no explicit length guard: DataView.getUint32 already throws a RangeError for bytes shorter than 8, caught by readEmbeddedObjectData's own shared catch exactly like every other structural shortfall this module reports. function readObjectHeader(bytes: Uint8Array): { readonly formatId: number; readonly next: number; } { - if (bytes.length < 8) { - throw new Error("ObjectHeader ends before its FormatID field"); - } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const formatId = view.getUint32(4, true); let offset = 8; - const className = readLengthPrefixedAnsiString(bytes, offset, "ClassName"); - offset = className.next; - const topicName = readLengthPrefixedAnsiString(bytes, offset, "TopicName"); - offset = topicName.next; - const itemName = readLengthPrefixedAnsiString(bytes, offset, "ItemName"); - offset = itemName.next; + offset = readLengthPrefixedAnsiString(bytes, offset); // ClassName + offset = readLengthPrefixedAnsiString(bytes, offset); // TopicName + offset = readLengthPrefixedAnsiString(bytes, offset); // ItemName return { formatId, next: offset }; } @@ -149,12 +107,10 @@ function writeMinimalDib(): Uint8Array { const width = 1; const height = 1; const colorCount = 2; // BI_BITCOUNT_1's own two-colour table - // The DIB Object's own aData size formula (MS-WMF 2.2.2.9): (((Width*Planes*BitCount+31)&~31)/8) * abs(Height). - const rowBytes = ((width * 1 * DIB_BIT_COUNT_MONOCHROME + 31) & ~31) / 8; + // The DIB Object's own aData size formula (MS-WMF 2.2.2.9): (((Width*Planes*BitCount+31)&~31)/8) * abs(Height) -- evaluated at this function's own fixed 1x1 monochrome dimensions (Planes is always 1, per the field written below), which is exactly 4 bytes of packed pixel data. Left as a literal rather than the general formula: every input the formula would vary over (Width, Height, BitCount) is one of this function's own hardcoded constants, so the arithmetic never actually has more than one possible result to compute. + const pixelDataBytes = 4; const out = new Uint8Array( - DIB_HEADER_SIZE_BITMAPINFOHEADER + - colorCount * 4 + - rowBytes * Math.abs(height), + DIB_HEADER_SIZE_BITMAPINFOHEADER + colorCount * 4 + pixelDataBytes, ); const view = new DataView(out.buffer); view.setUint32(0, DIB_HEADER_SIZE_BITMAPINFOHEADER, true); // HeaderSize @@ -162,27 +118,20 @@ function writeMinimalDib(): Uint8Array { view.setInt32(8, height, true); // Height -- positive: a bottom-up bitmap, MS-WMF's own default orientation view.setUint16(12, 1, true); // Planes -- "MUST be 0x0001" view.setUint16(14, DIB_BIT_COUNT_MONOCHROME, true); // BitCount - view.setUint32(16, 0, true); // Compression -- BI_RGB - view.setUint32(20, 0, true); // ImageSize -- "If the Compression value is BI_RGB, this value SHOULD be zero" - view.setInt32(24, 0, true); // XPelsPerMeter - view.setInt32(28, 0, true); // YPelsPerMeter + // Compression (BI_RGB), ImageSize ("If the Compression value is BI_RGB, this value SHOULD be zero"), XPelsPerMeter, YPelsPerMeter, and ColorImportant ("If this value is zero, all colour indexes are required") are every one of them zero -- already provided by `out`'s own zero-initialization, with nothing left to actually write. view.setUint32(32, colorCount, true); // ColorUsed -- both entries of the 2-colour table, explicit rather than the 0x00000000-means-default spelling - view.setUint32(36, 0, true); // ColorImportant -- "If this value is zero, all colour indexes are required" - // Colors: two RGBQuad entries (Blue, Green, Red, Reserved -- MS-WMF 2.2.2.20), black then white. The BitmapBuffer that follows is already all-zero, so its one pixel indexes entry 0 (black). - - out.set([0x00, 0x00, 0x00, 0x00], DIB_HEADER_SIZE_BITMAPINFOHEADER); // index 0: black + // Colors: two RGBQuad entries (Blue, Green, Red, Reserved -- MS-WMF 2.2.2.20), black then white. Black (0x00000000) is likewise already covered by `out`'s own zero-initialization; only white needs an actual write. The BitmapBuffer that follows is already all-zero too, so its one pixel indexes entry 0 (black). out.set([0xff, 0xff, 0xff, 0x00], DIB_HEADER_SIZE_BITMAPINFOHEADER + 4); // index 1: white return out; } // [MS-OLEDS] 2.2.1 PresentationObjectHeader: OLEVersion ("any arbitrary value ... MUST be ignored on processing" -- the same licence ObjectHeader's own OLEVersion carries, so this reuses OBJECT_HEADER_OLE_VERSION rather than inventing a second arbitrary constant), FormatID (fixed at 0x00000005, since a ClassName follows), then ClassName as a LengthPrefixedAnsiString. function writePresentationObjectHeader(): Uint8Array { - const classNameBytes = writeLengthPrefixedAnsiString(PRESENTATION_CLASS_NAME); - const out = new Uint8Array(4 + 4 + classNameBytes.length); + // PRESENTATION_CLASS_NAME is fixed at the empty string, whose own LengthPrefixedAnsiString encoding (writeLengthPrefixedAnsiString's own empty-string case) is 4 zero bytes -- already provided by `out`'s own zero-initialization below, with nothing left to actually copy in. + const out = new Uint8Array(4 + 4 + 4); // OLEVersion + FormatID + the empty ClassName's own 4-byte zero length prefix const view = new DataView(out.buffer); view.setUint32(0, OBJECT_HEADER_OLE_VERSION, true); view.setUint32(4, PRESENTATION_OBJECT_HEADER_FORMAT_ID, true); - out.set(classNameBytes, 8); return out; } @@ -210,16 +159,11 @@ function writePresentationObject(): Uint8Array { return out; } -// The mirror of writePresentationObjectHeader: validates and skips a PresentationObjectHeader, throwing when the bytes are not this module's own shape (a FormatID other than 0x00000005, or a truncated ClassName) -- readEmbeddedObjectData's shared catch treats that identically to every other reason a payload is not this package's own. -function skipPresentationObjectHeader( +// The mirror of writePresentationObjectHeader: validates and skips a PresentationObjectHeader, throwing when the bytes are not this module's own shape (a FormatID other than 0x00000005, or a truncated ClassName) -- readEmbeddedObjectData's shared catch treats that identically to every other reason a payload is not this package's own, discarding the thrown message along with it, so this (and skipClipboardFormatHeader/skipPresentationObject below) is exported for its own direct unit coverage rather than only ever being exercised through a caller that can never observe what it throws or returns. +export function skipPresentationObjectHeader( bytes: Uint8Array, offset: number, ): number { - if (offset + 8 > bytes.length) { - throw new Error( - "the Presentation field ends before its PresentationObjectHeader's FormatID", - ); - } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const formatId = view.getUint32(offset + 4, true); if (formatId !== PRESENTATION_OBJECT_HEADER_FORMAT_ID) { @@ -227,25 +171,15 @@ function skipPresentationObjectHeader( `PresentationObjectHeader.FormatID is 0x${formatId.toString(16).padStart(8, "0")}, not the 0x00000005 this module's own Presentation field always writes`, ); } - const className = readLengthPrefixedAnsiString( - bytes, - offset + 8, - "PresentationObjectHeader.ClassName", - ); - return className.next; + return readLengthPrefixedAnsiString(bytes, offset + 8); // PresentationObjectHeader.ClassName } -// The mirror of writeClipboardFormatHeader. -function skipClipboardFormatHeader( +// The mirror of writeClipboardFormatHeader. Reading ClipboardFormat needs no explicit length guard of its own, for the identical reason skipPresentationObjectHeader's own FormatID read doesn't. +export function skipClipboardFormatHeader( bytes: Uint8Array, offset: number, ): number { const afterHeader = skipPresentationObjectHeader(bytes, offset); - if (afterHeader + 4 > bytes.length) { - throw new Error( - "ClipboardFormatHeader ends before its ClipboardFormat field", - ); - } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const clipboardFormat = view.getUint32(afterHeader, true); if (clipboardFormat !== CLIPBOARD_FORMAT_CF_DIB) { @@ -257,16 +191,12 @@ function skipClipboardFormatHeader( } // The mirror of writePresentationObject: validates and skips the whole Presentation field, returning the offset immediately past it (the end of the EmbeddedObject structure itself). readEmbeddedObjectData calls this purely to confirm the field this package's own writer always appends is genuinely present and well-formed -- it never reads PresentationData back into anything, since nothing in ContentEmbeddedObject has a position for a placeholder preview image, and a payload missing this mandatory field is not this package's own regardless of whether NativeData alone happened to decode. -function skipPresentationObject( +export function skipPresentationObject( bytes: Uint8Array, offset: number, ): number { const afterHeader = skipClipboardFormatHeader(bytes, offset); - if (afterHeader + 4 > bytes.length) { - throw new Error( - "StandardClipboardFormatPresentationObject ends before its PresentationDataSize field", - ); - } + // PresentationDataSize itself needs no explicit length guard (DataView.getUint32 already throws for a truncated field), but the check below is genuinely load-bearing: the returned offset is never itself bounds-checked by any caller, so this is the only place a PresentationDataSize claiming more bytes than actually exist is ever caught. const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const presentationDataSize = view.getUint32(afterHeader, true); const dataStart = afterHeader + 4; @@ -340,20 +270,16 @@ function knownContentEmbeddedObjectFields( }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - // A hostile or malformed \objdata JSON payload's own `source` value used to reach knownContentEmbeddedObjectFields untouched, because isContentEmbeddedObject's guard never inspected it -- that function validated and dropped it independently. Now that ContentEmbeddedObjectSchema validates `source` as a genuine field of its own (see that schema's own comment in document-schema.js's src/content.ts), a hostile source would instead fail the WHOLE object's validation at the safeParse call below, discarding an otherwise perfectly well-formed embedded object over one bad metadata field -- a real behavioural regression from the original, more lenient "drop the bad field, keep the rest" contract this reader has always offered. Stripping an invalid `source` key before that validation runs restores it: a payload whose `source` doesn't parse loses only that field, exactly as before, rather than the whole read. +// +// No narrowing of `value` at all before probing it, not even for whether it genuinely has a "source" key: this is only ever called on a JSON.parse result within readEmbeddedObjectData's own try block, and every shape lacking a real, schema-valid `source` -- a record with no "source" property, an array (whose numeric-index serialisation never carries one), or a bare primitive -- converges on the identical outcome regardless of which specific line notices. record.source reads back undefined for any of those (property access never throws for a missing key, only null/undefined themselves throw, landing in readEmbeddedObjectData's own shared catch exactly where a dedicated non-record check would have returned early instead), SourceResidueSchema.safeParse(undefined) always fails its own required-object shape, and the resulting strip loop then rebuilds a value equal in content to the original for anything that never had a "source" key to remove in the first place (a plain object gains no observable difference from being rebuilt key-for-key; an array becomes a numeric-keyed object, but ContentEmbeddedObjectSchema.safeParse rejects both shapes identically, since neither carries the fields it requires) -- so a dedicated "does this even have a source key" check would only rename which line reaches that same convergence, never change it. The cast is safe precisely because it is never trusted uninspected: every read from `record` below either degrades harmlessly (a missing property) or throws before anything built from it is used. function withoutInvalidSource(value: unknown): unknown { - if (!isRecord(value) || !("source" in value)) { - return value; - } - if (SourceResidueSchema.safeParse(value.source).success) { + const record = value as Record; + if (SourceResidueSchema.safeParse(record.source).success) { return value; } const sanitized: Record = {}; - for (const [key, entry] of Object.entries(value)) { + for (const [key, entry] of Object.entries(record)) { if (key !== "source") { sanitized[key] = entry; } @@ -373,15 +299,10 @@ export function readEmbeddedObjectData( ) { return undefined; } - if (header.next + 4 > bytes.length) { - return undefined; - } + // NativeDataSize itself needs no explicit length guard: DataView.getUint32 already throws a RangeError for a truncated field, caught by the shared catch below exactly like every other structural shortfall. NativeData's own bounds need no separate guard either, even though Uint8Array.subarray silently clamps an out-of-range end index rather than throwing: an oversized nativeDataSize hands readCompoundFile a silently truncated buffer, but every one of readCompoundFile's own internal checks (sector count, FAT/directory bounds, and the rest) is derived from that buffer's own real .length rather than from any size this reader declared, so a truncation genuinely inconsistent with the [MS-CFB] structure it claims to hold is still caught there -- confirmed directly against archive-codec's own cfb/read.ts, which throws CompoundFileFormatError from bytes.length-derived arithmetic at every structural boundary. const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const nativeDataSize = view.getUint32(header.next, true); const nativeDataStart = header.next + 4; - if (nativeDataStart + nativeDataSize > bytes.length) { - return undefined; - } const nativeData = bytes.subarray( nativeDataStart, nativeDataStart + nativeDataSize, diff --git a/packages/rtf-codec/src/group.test.ts b/packages/rtf-codec/src/group.test.ts new file mode 100644 index 000000000..0047a8f69 --- /dev/null +++ b/packages/rtf-codec/src/group.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { groupHead, matchingGroupEnd } from "./group"; +import { tokenizeRtf } from "./tokenize"; + +describe("matchingGroupEnd", () => { + it("finds the matching close of a group with no nesting", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{\\b x}y")); + expect(matchingGroupEnd(tokens, 0)).toBe(3); + }); + + it("skips over a nested group's own close to find the outer one", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{a{b}c}d")); + // groupStart(0) text(1) groupStart(2) text(3) groupEnd(4) text(5) groupEnd(6) + expect(matchingGroupEnd(tokens, 0)).toBe(6); + expect(matchingGroupEnd(tokens, 2)).toBe(4); + }); + + it("returns tokens.length for a group that is never closed", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{\\b x")); + expect(matchingGroupEnd(tokens, 0)).toBe(tokens.length); + }); + + it("does not read one token past the actual matching close", () => { + // A trailing sibling group after the one being matched is exactly where an off-by-one loop bound would accidentally report the sibling's own close. + const tokens = tokenizeRtf(new TextEncoder().encode("{a}{b}")); + expect(matchingGroupEnd(tokens, 0)).toBe(2); + }); +}); + +describe("groupHead", () => { + it("names the destination a group opens with", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{\\pict x}")); + const head = groupHead(tokens, 0); + expect(head.destination).toBe("pict"); + expect(head.ignorable).toBe(false); + }); + + it("marks a group ignorable when it opens with the \\* control symbol", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{\\*\\bkmkstart x}")); + const head = groupHead(tokens, 0); + expect(head.destination).toBe("bkmkstart"); + expect(head.ignorable).toBe(true); + }); + + it("does not mark a group ignorable for a control symbol other than \\*", () => { + // \~ is a real control symbol (non-breaking space); a mutant collapsing the \* check to "always true" would wrongly mark this group ignorable and would also wrongly consume this token as the ignorable marker. + const tokens = tokenizeRtf(new TextEncoder().encode("{\\~x}")); + const head = groupHead(tokens, 0); + expect(head.ignorable).toBe(false); + expect(head.destination).toBeUndefined(); + expect(head.contentStart).toBe(1); + }); + + it("returns no destination for a group that opens with plain text", () => { + const tokens = tokenizeRtf(new TextEncoder().encode("{plain text}")); + const head = groupHead(tokens, 0); + expect(head.destination).toBeUndefined(); + expect(head.ignorable).toBe(false); + expect(head.contentStart).toBe(1); + }); +}); diff --git a/packages/rtf-codec/src/group.ts b/packages/rtf-codec/src/group.ts index 019245978..63cfd56d1 100644 --- a/packages/rtf-codec/src/group.ts +++ b/packages/rtf-codec/src/group.ts @@ -19,7 +19,8 @@ export function matchingGroupEnd( start: number, ): number { let depth = 0; - for (let index = start; index < tokens.length; index += 1) { + // index increments by exactly 1 every iteration, so it can never skip past tokens.length -- !== is exactly equivalent to < here, and unlike <, an off-by-one mutation of it (=== in place of !==) stops the loop from running at all instead of surviving unobserved. + for (let index = start; index !== tokens.length; index += 1) { const kind = tokens[index]?.kind; if (kind === "groupStart") { depth += 1; diff --git a/packages/rtf-codec/src/header.test.ts b/packages/rtf-codec/src/header.test.ts index 5f10b645e..530c4d9bb 100644 --- a/packages/rtf-codec/src/header.test.ts +++ b/packages/rtf-codec/src/header.test.ts @@ -41,6 +41,18 @@ describe("font table", () => { expect(fonts.get(0)?.codepage).toBe(1251); }); + it("does not let a bare \\cpg with no digits clobber an already-recorded \\cpgN", () => { + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl{\\f0\\fswiss\\fcharset0\\cpg1251\\cpg Arial Cyr;}}}", + ); + expect(fonts.get(0)?.codepage).toBe(1251); + }); + + it("does not let a bare \\f with no digits clobber an already-recorded font number", () => { + const { fonts } = headerOf("{\\rtf1{\\fonttbl{\\f0\\froman\\f Arial;}}}"); + expect(fonts.get(0)?.name).toBe("Arial"); + }); + it("ignores the {\\*\\falt ...} alternate-name subgroup rather than folding it into the face name", () => { const { fonts } = headerOf( "{\\rtf1{\\fonttbl{\\f0\\froman\\fcharset0 Cambria{\\*\\falt Times New Roman};}}}", @@ -53,6 +65,70 @@ describe("font table", () => { expect(fonts.get(0)?.family).toBe("roman"); expect(fonts.get(2)?.family).toBe("swiss"); }); + + it("decodes a \\'hh hex byte inside a font name through the entry's own code page", () => { + // 0x41 is 'A' in every single-byte code page this decodes through, so the assertion holds regardless of which one \fcharset0 resolves to. + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl{\\f0\\froman\\fcharset0 C\\'41B;}}}", + ); + expect(fonts.get(0)?.name).toBe("CAB"); + }); + + it("flushes pending bytes before skipping a nested group, not after, so a DBCS lead byte never spans across it", () => { + // \cpg932's lead/trail byte scheme means combining a byte from before {\*\falt ...} with one from after it (rather than flushing each side separately) decodes the two together as one Shift-JIS character instead of the lone lead byte's own U+FFFD fallback followed by the plain ASCII byte after it. + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl{\\f0\\froman\\cpg932 \\'81{\\*\\falt X}\\'40;}}}", + ); + expect(fonts.get(0)?.name).toBe(`${String.fromCodePoint(0xfffd)}@`); + }); + + it("does not flush an empty pending run into a diagnostic-worthy decode", () => { + // \cpg99999 is deliberately unsupported, so decoding through it emits one UNSUPPORTED_CODEPAGE warning per real, non-empty flush -- {\*\falt X} is skipped as a nested group before any name text has accumulated, so an unguarded flush right there would decode zero bytes through the same unsupported page and emit a second, spurious warning nothing in the actual name justifies. + const diagnostics: unknown[] = []; + const tokens = tokenizeRtf( + bytes("{\\rtf1{\\fonttbl{\\f0\\froman\\cpg99999{\\*\\falt X}Y;}}}"), + ); + readRtfHeader(tokens, (diagnostic) => diagnostics.push(diagnostic)); + expect(diagnostics).toHaveLength(1); + }); + + it("trims leading whitespace from a font name, not just the trailing semicolon", () => { + // The delimiter space after each control word is already consumed by the tokenizer, so these extra leading/trailing spaces are genuinely part of the name's own text run, not delimiter artifacts -- only .trim() removes the leading pair; the trailing-semicolon regex only ever touches what comes after the last real character. + const { fonts } = headerOf("{\\rtf1{\\fonttbl{\\f0\\froman Arial ;}}}"); + expect(fonts.get(0)?.name).toBe("Arial"); + }); + + it("decodes a \\uN unicode escape inside a font name, for both a positive and a negative-encoded code point", () => { + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl{\\f0\\froman A\\u233 B\\u-1C;}}}", + ); + // \u233 is U+00E9 (233); \u-1 is RTF's signed-16-bit spelling of U+FFFF (-1 + 0x10000). + expect(fonts.get(0)?.name).toBe( + `A${String.fromCodePoint(233)}B${String.fromCodePoint(0xffff)}C`, + ); + }); + + it("does not add 0x1_00_00 to \\u0, the boundary case that is exactly zero rather than negative", () => { + // code < 0 is false for code === 0 -- a <= mutation would wrongly take the negative-encoding branch for this exact boundary value, producing U+10000 instead of NUL. + const { fonts } = headerOf("{\\rtf1{\\fonttbl{\\f0\\froman A\\u0B;}}}"); + expect(fonts.get(0)?.name).toBe(`A${String.fromCodePoint(0)}B`); + }); + + it("skips more than one non-brace token before finding the next 's own opening brace", () => { + // Two \filetbl-unrelated bare control words ahead of the first braced font entry: parseFontTable's own char-by-char pre-scan must step forward through both, not just tolerate a single one. + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl\\deflang1033\\ftnbj{\\f0\\froman Arial;}}}", + ); + expect(fonts.get(0)?.name).toBe("Arial"); + }); + + it("does not let an unrelated control word with its own numeric parameter clobber the codepage as if it were \\cpgN", () => { + // \fprq carries no field this reader records at all (see readFontInfo's own comment); reaching this table entry with a defined param must never fall into the \cpg branch merely for being the last check in the chain. + const { fonts } = headerOf( + "{\\rtf1{\\fonttbl{\\f0\\fswiss\\cpg1251\\fprq2 Arial Cyr;}}}", + ); + expect(fonts.get(0)?.codepage).toBe(1251); + }); }); describe("color table", () => { @@ -77,6 +153,39 @@ describe("color table", () => { ); expect(colors[1]).toEqual({ r: 174 / 255, g: 150 / 255, b: 56 / 255 }); }); + + it("is a real colour, not the auto entry, when only one of red/green/blue is stated", () => { + // Only \red is present here -- green and blue are genuinely absent from the entry, not merely zero -- so this must still resolve to a real (defaulted-to-0) colour rather than being mistaken for the auto entry, which requires all three to be absent. + const { colors } = headerOf("{\\rtf1{\\colortbl;\\red200;}}"); + expect(colors[1]).toEqual({ r: 200 / 255, g: 0, b: 0 }); + }); + + it("is a real colour when only \\green is stated, not the auto entry", () => { + const { colors } = headerOf("{\\rtf1{\\colortbl;\\green100;}}"); + expect(colors[1]).toEqual({ r: 0, g: 100 / 255, b: 0 }); + }); + + it("is a real colour when only \\blue is stated, not the auto entry", () => { + const { colors } = headerOf("{\\rtf1{\\colortbl;\\blue50;}}"); + expect(colors[1]).toEqual({ r: 0, g: 0, b: 50 / 255 }); + }); + + it("does not treat an unrelated control word as \\blue just because it isn't \\red or \\green", () => { + // \wgrffmtfilter99 names no field this table reads at all -- it must be ignored, not mistaken for \blue99 merely for falling into the same else-if chain's final branch. + const { colors } = headerOf( + "{\\rtf1{\\colortbl;\\red10\\green20\\wgrffmtfilter99;}}", + ); + expect(colors[1]).toEqual({ r: 10 / 255, g: 20 / 255, b: 0 }); + }); + + it("only finishes the current entry on a real semicolon byte, not on every byte of trailing text", () => { + // "xy;" after \blue30 is 3 plain text bytes -- only the last one is the entry terminator; treating every byte as one would finish (and reset) the entry twice more, in each case with nothing left to record. + const { colors } = headerOf( + "{\\rtf1{\\colortbl;\\red10\\green20\\blue30xy;}}", + ); + expect(colors).toHaveLength(2); + expect(colors[1]).toEqual({ r: 10 / 255, g: 20 / 255, b: 30 / 255 }); + }); }); describe("style sheet", () => { @@ -109,6 +218,20 @@ describe("style sheet", () => { expect(styles.get(7)?.headingLevel).toBe(2); }); + it("prefers \\outlinelevelN over a conflicting 'heading N' style name", () => { + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s5\\outlinelevel3\\snext0 heading 1;}}}", + ); + expect(styles.get(5)?.headingLevel).toBe(4); + }); + + it("leaves headingLevel undefined when neither \\outlinelevelN nor a 'heading N' name is present", () => { + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s4\\snext0 Body Text;}}}", + ); + expect(styles.get(4)?.headingLevel).toBeUndefined(); + }); + it("skips a character style, which the spec requires be written as {\\*\\csN ...}", () => { const { styles } = headerOf( "{\\rtf1{\\stylesheet{\\s0 Normal;}{\\*\\cs10\\additive Default Paragraph Font;}}}", @@ -116,6 +239,36 @@ describe("style sheet", () => { expect(styles.get(0)?.name).toBe("Normal"); expect(styles.has(10)).toBe(false); }); + + it("prefers \\outlinelevelN over a conflicting 'heading N' style name", () => { + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s5\\outlinelevel3\\snext0 heading 1;}}}", + ); + expect(styles.get(5)?.headingLevel).toBe(4); + }); + + it("does not let a bare \\outlinelevel with no digits clobber an already-recorded \\outlinelevelN", () => { + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s6\\outlinelevel2\\outlinelevel\\snext0 Body;}}}", + ); + expect(styles.get(6)?.headingLevel).toBe(3); + }); + + it("skips a nested group inside a style entry whole, rather than reading a stray \\sN inside it as this entry's own handle", () => { + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s3{\\*\\keycode\\s99}\\snext0 My Style;}}}", + ); + expect(styles.get(3)?.name).toBe("My Style"); + expect(styles.has(99)).toBe(false); + }); + + it("skips a stray text byte between two style groups rather than misreading it as the next entry's own opening brace", () => { + // The tokenizer drops bare CR/LF as pure whitespace, but a literal space here is a real "text" token some producers still emit for readability between destination groups -- treating it as if it were a groupStart would fold the whole of the next entry into a bogus, mis-scoped one and lose it. + const { styles } = headerOf( + "{\\rtf1{\\stylesheet{\\s1\\snext0 heading 1;} {\\s2\\snext0 heading 2;}}}", + ); + expect(styles.get(2)?.name).toBe("heading 2"); + }); }); describe("list and list override tables", () => { @@ -139,6 +292,84 @@ describe("list and list override tables", () => { it("has nothing for an \\lsN no override declares", () => { expect(headerOf(sample).lists.has(9)).toBe(false); }); + + it("skips a stray text byte between two \\list groups rather than misreading it as the next \\list's own opening brace", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable" + + "{\\list\\listtemplateid1\\listsimple{\\listlevel\\levelnfc23\\leveljc0\\levelstartat1{\\leveltext \\'01\\u183 ?;}{\\levelnumbers;}}\\listid201}" + + " {\\list\\listtemplateid2\\listsimple{\\listlevel\\levelnfc0\\leveljc0\\levelstartat1{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}\\listid202}" + + "}{\\*\\listoverridetable{\\listoverride\\listid202\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not treat an unrelated group inside \\*\\listtable as if it were a \\list", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\unknowndest\\listid999}}" + + "{\\*\\listoverridetable{\\listoverride\\listid999\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.has(1)).toBe(false); + }); + + it("does not read a stray \\levelnfc/\\levelstartat inside a 's own nested {\\leveltext ...} as if it were the level's own", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listtemplateid1\\listsimple" + + "{\\listlevel\\levelnfc0\\leveljc0\\levelstartat1{\\leveltext\\levelnfc99\\levelstartat88 \\'02\\'00.;}{\\levelnumbers\\'01;}}" + + "\\listid302}}{\\*\\listoverridetable{\\listoverride\\listid302\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + expect(lists.get(1)?.levels[0]?.startAt).toBe(1); + }); + + it("does not treat an unrelated group inside a \\list as if it were its own \\listlevel", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listtemplateid1\\listsimple" + + "{\\unknowndest\\levelnfc99}" + + "{\\listlevel\\levelnfc0\\leveljc0\\levelstartat1{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}" + + "\\listid301}}{\\*\\listoverridetable{\\listoverride\\listid301\\listoverridecount0\\ls1}}}", + ).lists; + // Only the real group contributes -- the unrelated group must not become a bogus, wrongly-numbered-23 level 0. + expect(lists.get(1)?.levels).toHaveLength(1); + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not let a control word named something other than \\listidN set the list's own id", () => { + // \listtemplateid999 carries a numeric param too, but only the exact name \listid may set listId -- placed AFTER the real \listidN so a wrongly-matched value would visibly stick rather than just get overwritten again by coincidence. + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listid401\\listtemplateid999\\listsimple" + + "{\\listlevel\\levelnfc0\\leveljc0\\levelstartat1{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}}}" + + "{\\*\\listoverridetable{\\listoverride\\listid401\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not let a bare \\listid with no digits clobber an already-recorded \\listidN", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listid402\\listid\\listsimple" + + "{\\listlevel\\levelnfc0\\leveljc0\\levelstartat1{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}}}" + + "{\\*\\listoverridetable{\\listoverride\\listid402\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not let a bare \\levelstartat with no digits clobber an already-recorded \\levelstartatN inside a \\listlevel", () => { + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listtemplateid1\\listsimple" + + "{\\listlevel\\levelnfc0\\leveljc0\\levelstartat9\\levelstartat{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}" + + "\\listid403}}{\\*\\listoverridetable{\\listoverride\\listid403\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.startAt).toBe(9); + }); + + it("does not let a control word named something other than \\levelnfc or \\levelstartat set a \\listlevel's own fields", () => { + // \leveljc carries a numeric param too, but only the exact name \levelstartat may set startAt -- placed AFTER the real \levelstartat9 so a wrongly-matched value would visibly stick rather than just get overwritten again by coincidence. + const lists = headerOf( + "{\\rtf1{\\*\\listtable{\\list\\listtemplateid1\\listsimple" + + "{\\listlevel\\levelnfc0\\levelstartat9\\leveljc0{\\leveltext \\'02\\'00.;}{\\levelnumbers\\'01;}}" + + "\\listid404}}{\\*\\listoverridetable{\\listoverride\\listid404\\listoverridecount0\\ls1}}}", + ).lists; + expect(lists.get(1)?.levels[0]?.startAt).toBe(9); + }); }); // is `'{' \lfolevel \listoverrideformatN? \listoverridestartat? '}'`, and the spec states exactly which of the two flags puts what where: "If the format flag (\listoverrideformatN) is given, the \lfolevel should also contain a list level (). If the start-at flag (\listoverridestartat) is given, a start-at value must be provided. If the start-at is overridden but the format is not, then a \levelstartatN should be provided in the itself. If both the start-at and the format are overridden, put the \levelstartatN inside the contained in the ." (RTF 1.9.1, "List Override Table") @@ -218,6 +449,88 @@ describe("list override levels", () => { expect(lists.get(1)?.levels[0]?.startAt).toBe(5); expect(lists.get(2)?.levels[0]?.startAt).toBe(1); }); + + it("skips a stray text byte between two \\listoverride groups rather than misreading it as the next one's own opening brace", () => { + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount0\\ls1}" + + " {\\listoverride\\listid101\\listoverridecount1{\\lfolevel\\listoverridestartat\\levelstartat8}\\ls2}", + ); + expect(lists.get(2)?.levels[0]?.startAt).toBe(8); + }); + + it("does not treat an unrelated group inside a \\listoverride as if it were its own \\lfolevel", () => { + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\unknowndest\\levelstartat77}" + + "{\\lfolevel\\listoverridestartat\\levelstartat9}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.startAt).toBe(9); + }); + + it("does not let a bare \\listid or \\ls with no digits clobber an already-recorded value", () => { + const lists = listsFor( + "{\\listoverride\\listid101\\listid\\listoverridecount0\\ls3\\ls}", + ); + expect(lists.get(3)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not let a control word named something other than \\listid or \\ls set the override's own id fields", () => { + // \listoverridecount0 itself carries a numeric param -- placed AFTER the real \ls1 so a wrongly-matched value would visibly stick. + const lists = listsFor( + "{\\listoverride\\listid101\\ls1\\listoverridecount0}", + ); + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(0); + }); + + it("does not treat an unrelated group inside a \\lfolevel as if it were its own ", () => { + // The real comes FIRST here, and the unrelated group after it -- so a wrongly-matched second read would visibly overwrite the correct value, rather than just get overwritten again by coincidence. + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\lfolevel\\listoverrideformat1" + + "{\\listlevel\\levelnfc23\\leveljc0\\levelstartat1{\\leveltext \\'01\\u183 ?;}{\\levelnumbers;}}" + + "{\\unknowndest\\levelnfc99}}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(23); + }); + + it("does not let a bare \\levelstartat with no digits inside a \\lfolevel clobber an already-recorded one", () => { + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\lfolevel\\listoverridestartat\\levelstartat6\\levelstartat}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.startAt).toBe(6); + }); + + it("does not let a control word named something other than \\levelstartat set the \\lfolevel's own start-at", () => { + // \listoverridestartat itself carries no param at all here, and \listoverrideformat1 does -- neither is \levelstartat, so placing one right after the real \levelstartat6 must not overwrite it. + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\lfolevel\\listoverridestartat\\levelstartat6\\listoverrideformat1}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.startAt).toBe(6); + }); + + it("prefers the nested 's own start-at over a redundant direct \\levelstartatN when both are given", () => { + // A malformed producer stating both is exactly the case applyListOverride's own level-replacement precedence exists for: the whole-level replacement must win over the otherwise-independent direct override. + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\lfolevel\\levelstartat50\\listoverrideformat1" + + "{\\listlevel\\levelnfc23\\leveljc0\\levelstartat99{\\leveltext \\'01\\u183 ?;}{\\levelnumbers;}}}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.startAt).toBe(99); + }); + + it("stops reading the nested at its own closing brace, not the \\lfolevel's", () => { + // A direct \levelstartatN placed AFTER the group's own close, but still inside the \lfolevel, must never be folded into that 's own reading -- readListLevel's own \levelstartat is last-wins, so a boundary that ran past the true close would let this trailing 77 silently clobber the 99 stated inside the level itself. + const lists = listsFor( + "{\\listoverride\\listid101\\listoverridecount1" + + "{\\lfolevel\\listoverrideformat1" + + "{\\listlevel\\levelnfc23\\leveljc0\\levelstartat99{\\leveltext \\'01\\u183 ?;}{\\levelnumbers;}}" + + "\\levelstartat77}\\ls1}", + ); + expect(lists.get(1)?.levels[0]?.startAt).toBe(99); + expect(lists.get(1)?.levels[0]?.numberFormat).toBe(23); + }); }); describe("document properties", () => { @@ -235,11 +548,16 @@ describe("document properties", () => { }); it("reads the paper size and margins, in twips", () => { + // Every one of the six fields gets its own distinct value, so a switch case that silently did nothing (or a case label swapped for a neighbour's) would leave a field at its default rather than merely matching a sibling's value by coincidence. const header = headerOf( - "{\\rtf1\\ansi\\paperw11906\\paperh16838\\margl1134\\margr1134\\margt1417\\margb1417}", + "{\\rtf1\\ansi\\paperw11906\\paperh16838\\margl1001\\margr1002\\margt1003\\margb1004}", ); expect(header.page.paperWidthTwips).toBe(11_906); - expect(header.page.marginTopTwips).toBe(1417); + expect(header.page.paperHeightTwips).toBe(16_838); + expect(header.page.marginLeftTwips).toBe(1001); + expect(header.page.marginRightTwips).toBe(1002); + expect(header.page.marginTopTwips).toBe(1003); + expect(header.page.marginBottomTwips).toBe(1004); }); it("falls back to the spec's own stated defaults when no page geometry is declared", () => { @@ -260,4 +578,104 @@ describe("document properties", () => { expect(header.metadata.title).toBe("A Document"); expect(header.metadata.author).toBe("John Doe"); }); + + it("reads the {\\info ...} group's subject, keywords, and operator too", () => { + const header = headerOf( + "{\\rtf1\\ansi{\\info{\\subject A Subject}{\\keywords one, two;three}{\\operator Jane Roe}}}", + ); + expect(header.metadata.subject).toBe("A Subject"); + expect(header.metadata.keywords).toEqual(["one", "two", "three"]); + expect(header.metadata.creator).toBe("Jane Roe"); + }); + + it("drops an empty entry a keywords list's own delimiter run produces", () => { + const header = headerOf("{\\rtf1\\ansi{\\info{\\keywords one;;two}}}"); + expect(header.metadata.keywords).toEqual(["one", "two"]); + }); + + it("leaves every {\\info ...} field entirely absent when its own value is empty", () => { + const header = headerOf("{\\rtf1\\ansi{\\info{\\title}}}"); + expect(header.metadata).not.toHaveProperty("title"); + }); + + it("does not treat an unrecognized {\\info ...} field as \\operator just for reaching the end of the else-if chain", () => { + // The real \operator comes FIRST here, and the unrecognized field after it -- so a wrongly-matched second write would visibly overwrite the correct value, rather than just get overwritten again by coincidence. + const header = headerOf( + "{\\rtf1\\ansi{\\info{\\operator Jane Roe}{\\manager Someone Else}}}", + ); + expect(header.metadata.creator).toBe("Jane Roe"); + }); + + it("skips a stray text byte between two {\\info ...} fields rather than misreading it as the next field's own opening brace", () => { + const header = headerOf( + "{\\rtf1\\ansi{\\info{\\title A Document} {\\author John Doe}}}", + ); + expect(header.metadata.author).toBe("John Doe"); + }); + + it("does not read a document property from inside a nested destination group, only at the file group's own top level", () => { + // \paperw999 sits inside a font entry here -- nonsensical RTF, but nothing stops a malformed producer from emitting it, and the document-properties sweep must skip the whole {\fonttbl ...} group rather than linearly scanning through it. + const header = headerOf( + "{\\rtf1\\ansi{\\fonttbl{\\f0\\froman\\paperw999 Arial;}}}", + ); + expect(header.page.paperWidthTwips).toBe(12_240); + }); + + it("skips a stray text byte between two top-level destination groups rather than misreading it as the next one's own opening brace", () => { + const header = headerOf( + "{\\rtf1\\ansi{\\fonttbl{\\f0\\froman Arial;}} {\\colortbl;\\red10\\green20\\blue30;}}", + ); + expect(header.fonts.get(0)?.name).toBe("Arial"); + expect(header.colors[1]).toEqual({ r: 10 / 255, g: 20 / 255, b: 30 / 255 }); + }); +}); + +describe("revision table", () => { + it("reads each author name, trimmed, in table order", () => { + // Leading/trailing spaces around "Spacey Author" are genuinely part of the group's own text run (nothing here is a control-word delimiter), so only .trim() removes them. + const header = headerOf( + "{\\rtf1{\\*\\revtbl{Unknown;}{ Spacey Author ;}}}", + ); + expect(header.revisionAuthors).toEqual(["Unknown", "Spacey Author"]); + }); + + it("trims whitespace immediately before the conflict form's own NUL separator, which collectPlainText's own overall trim never reaches", () => { + // collectPlainText already trims the WHOLE group's own leading/trailing whitespace before parseRevisionTable ever sees it, so a plain leading/trailing-space fixture alone cannot tell the two .trim() calls apart -- this one puts the whitespace immediately before an internal \'00 (NUL) byte, a position the outer trim never touches at all. + const header = headerOf( + "{\\rtf1{\\*\\revtbl{Current Author \\'00\\'05Prev A;}}}", + ); + expect(header.revisionAuthors).toEqual(["Current Author"]); + }); + + it("skips a stray text byte between two revision-author groups rather than misreading it as the next one's own opening brace", () => { + const header = headerOf("{\\rtf1{\\*\\revtbl{Unknown;} {Second Author;}}}"); + expect(header.revisionAuthors).toEqual(["Unknown", "Second Author"]); + }); +}); + +describe("bodyStartIndex", () => { + it("does not advance past a group whose own destination is not one of HEADER_DESTINATIONS", () => { + const tokens = tokenizeRtf( + bytes( + "{\\rtf1\\ansi{\\fonttbl{\\f0\\froman Tms Rmn;}}{\\unknowndest x}Body}", + ), + ); + const header = readRtfHeader(tokens, () => { + /* not asserted here */ + }); + // bodyStartIndex must land exactly on the {\unknowndest x} group's own opening brace -- immediately after \fonttbl's matching close -- rather than being pushed past that whole group too. + expect(tokens[header.bodyStartIndex]).toEqual({ kind: "groupStart" }); + expect(tokens[header.bodyStartIndex - 1]).toEqual({ kind: "groupEnd" }); + }); + + it("does not advance past a group that opens with no destination control word at all", () => { + const tokens = tokenizeRtf( + bytes("{\\rtf1\\ansi{\\fonttbl{\\f0\\froman Tms Rmn;}}{plain text}Body}"), + ); + const header = readRtfHeader(tokens, () => { + /* not asserted here */ + }); + expect(tokens[header.bodyStartIndex]).toEqual({ kind: "groupStart" }); + expect(tokens[header.bodyStartIndex - 1]).toEqual({ kind: "groupEnd" }); + }); }); diff --git a/packages/rtf-codec/src/header.ts b/packages/rtf-codec/src/header.ts index e37cf9e91..2592bed7f 100644 --- a/packages/rtf-codec/src/header.ts +++ b/packages/rtf-codec/src/header.ts @@ -145,6 +145,11 @@ interface MutablePageGeometry { marginBottomTwips: number; } +// Shared by every group-skipping loop below: caps a jump target (typically matchingGroupEnd's result, possibly itself already capped to some enclosing boundary for a sub-parser's own use) at boundary - 1, so the loop's own +1 step afterwards always lands at exactly `boundary`, never past it, even for a malformed or unclosed nested group whose real close would otherwise land at or beyond it. This is what lets each of those loops compare its index against the boundary with !== instead of <: an off-by-one mutation of !== (=== in place of it) stops the loop from running at all instead of surviving unobserved, whereas < has an off-by-one variant (<=) that only ever reprocesses the boundary's own already-inert groupEnd token -- see readFontInfo's own loop for that fuller reasoning. +function capBeforeBoundary(jumpTarget: number, boundary: number): number { + return Math.min(jumpTarget, boundary - 1); +} + // Collects a destination's own plain text -- the shape every leaf text production in the header shares (a font's , a style's , an \info field's value). Control words inside are skipped rather than interpreted, and a nested group is skipped whole: the {\*\falt ...} alternate-name subgroup inside a is exactly why, since its text is a different font's name and folding it into the face name would corrupt every entry carrying one. function collectPlainText( tokens: readonly RtfToken[], @@ -160,12 +165,13 @@ function collectPlainText( out += decodeCodepageBytes(Uint8Array.from(pending), codepage, sink); pending.length = 0; }; - for (let index = start; index < end; index += 1) { + // A nested group's own jump is clamped to (at most) end - 1: index then always lands at end exactly, whether by the ordinary +1 step or by this jump, and never skips past it even for an unclosed nested group -- so !== is exactly equivalent to < here, and unlike <, an off-by-one mutation of it (=== in place of !==) stops the loop from running at all instead of surviving unobserved. See readFontInfo's own identical loop for the full reasoning. + for (let index = start; index !== end; index += 1) { const token = tokens[index]; if (token === undefined) break; if (token.kind === "groupStart") { flush(); - index = matchingGroupEnd(tokens, index); + index = capBeforeBoundary(matchingGroupEnd(tokens, index), end); continue; } if (token.kind === "text") { @@ -200,7 +206,8 @@ function parseFontTable( // The grammar admits a either braced or bare: '{' \fonttbl ( | ('{' '}'))+ '}'. Both are handled by treating each brace-delimited child as one entry and, when there is no brace at all, the remaining span as a single entry. let index = contentStart; let sawBracedEntry = false; - while (index < end) { + // index either advances by exactly 1 or jumps past one entry's own close, clamped to end - 1 so the advance always lands at index === end at most -- !== is exactly equivalent to < here; see readFontInfo's own identical loop for the full reasoning. + while (index !== end) { const token = tokens[index]; if (token?.kind !== "groupStart") { index += 1; @@ -209,7 +216,8 @@ function parseFontTable( sawBracedEntry = true; const entryEnd = Math.min(matchingGroupEnd(tokens, index), end); readFontInfo(tokens, index + 1, entryEnd, documentCodepage, fonts, sink); - index = entryEnd + 1; + // Landing exactly on the entry's own close (never past it, via the same end - 1 clamp every other loop here uses) rather than one past it: the closing brace itself is not a groupStart, so the loop's own top-of-body advance handles stepping past it on the very next iteration -- no separate +1 needed or safe to mutate away. + index = capBeforeBoundary(entryEnd, end); } if (!sawBracedEntry) { readFontInfo(tokens, contentStart, end, documentCodepage, fonts, sink); @@ -228,8 +236,8 @@ function readFontInfo( let family: string | undefined; let charsetPage: number | undefined; let explicitPage: number | undefined; - let nameStart = start; - for (let index = start; index < end; index += 1) { + // No nameStart tracking to skip past these descriptor control words before collecting the font's own name: collectPlainText already silently skips every controlWord token it doesn't specifically handle (only "u" and a nested group get real treatment), so scanning the whole [start, end) range for the name below already ignores \f, \froman/\fswiss/etc, \fcharsetN, \cpgN, \fprqN, and \fbias on its own, with nothing left for a separate start-of-name offset to add. \fprq/\fbias carry no field this reader records at all, so that branch is gone entirely rather than kept only to compute an offset nothing needs. index increments by exactly 1 every iteration and never jumps (unlike parseFontTable's own loop, which lands on matchingGroupEnd's result), so !== is exactly equivalent to < here, and unlike <, an off-by-one mutation of it (=== in place of !==) stops the loop from running at all instead of surviving unobserved -- group.ts's own matchingGroupEnd loop states the identical reasoning. + for (let index = start; index !== end; index += 1) { const token = tokens[index]; if (token === undefined) break; if (token.kind !== "controlWord") { @@ -237,26 +245,18 @@ function readFontInfo( } if (token.name === "f" && token.param !== undefined) { number = token.param; - nameStart = index + 1; continue; } if (FONT_FAMILIES.has(token.name)) { family = token.name.slice(1); - nameStart = index + 1; continue; } if (token.name === "fcharset" && token.param !== undefined) { charsetPage = codepageForFontCharset(token.param); - nameStart = index + 1; continue; } if (token.name === "cpg" && token.param !== undefined) { explicitPage = token.param; - nameStart = index + 1; - continue; - } - if (token.name === "fprq" || token.name === "fbias") { - nameStart = index + 1; } } if (number === undefined) { @@ -264,7 +264,7 @@ function readFontInfo( } const name = collectPlainText( tokens, - nameStart, + start, end, explicitPage ?? charsetPage ?? documentCodepage, sink, @@ -297,7 +297,8 @@ function parseColorTable( green = undefined; blue = undefined; }; - for (let index = contentStart; index < end; index += 1) { + // index increments by exactly 1 every iteration with no jump, so !== is exactly equivalent to < here -- see readFontInfo's own identical loop for the full reasoning. + for (let index = contentStart; index !== end; index += 1) { const token = tokens[index]; if (token === undefined) break; if (token.kind === "controlWord") { @@ -324,14 +325,15 @@ function parseStyleSheet( styles: Map, sink: RtfDiagnosticSink, ): void { - for (let index = contentStart; index < end; index += 1) { + // index either advances by exactly 1 or jumps to one entry's own close, clamped to end - 1 so the +1 above always lands at index === end at most -- !== is exactly equivalent to < here; see readFontInfo's own identical loop for the full reasoning. + for (let index = contentStart; index !== end; index += 1) { const token = tokens[index]; if (token?.kind !== "groupStart") { continue; } const entryEnd = Math.min(matchingGroupEnd(tokens, index), end); readStyle(tokens, index, entryEnd, documentCodepage, styles, sink); - index = entryEnd; + index = capBeforeBoundary(entryEnd, end); } } @@ -351,13 +353,13 @@ function readStyle( // "For