diff --git a/packages/odf.js/eslint.config.ts b/packages/odf.js/eslint.config.ts index 2ab706909b..ec3cd18e39 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 0000000000..72ee9d6479 --- /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 3ae3c47408..14b78208d2 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 6f5361c472..c8ba1757b5 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 36e3a59c77..1c764b1a87 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 0000000000..2cce543d45 --- /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 e90bce8761..0c798e3caa 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 0000000000..a1d2450299 --- /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 ef7e0d0d1b..dd023adef1 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 79fcbd3c1e..9d1020a2bf 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 0000000000..1f6ce9062c --- /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 1c7d052e57..29c527a3d0 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 0000000000..74b196ecc6 --- /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 0000000000..9c396757ca --- /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 e9b777db6c..be208ea803 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 89f0cf9a98..9b08dddd29 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 062da93fff..9c5f7ed0ed 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 0cdf1419fb..76eb62a4c4 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 4a52502535..b3a6f6e09a 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 cec3a881bc..1c52f857b9 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 0942a1a4d9..11c9e8502a 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 387c336a3f..8b52dc2e1f 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 0000000000..27f30f943b --- /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 0000000000..37c7e55fe5 --- /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 7dd9f7fc12..b14d296c7d 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 0000000000..0087afe71a --- /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 ae995e3cd7..f0727c8a26 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 01beac4297..7f52ff0347 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 bb072afdfb..eeb55e83c2 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 cda69e4f6e..ad8f43ca27 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 0000000000..457922ab0f --- /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 0000000000..01941e1bd0 --- /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 0d5c7d8aea..77ec86fbf7 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 abc9a58254..eb1e212d6c 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 7f7304fdcf..65a054dfd8 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 ea474086b2..952c26f463 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 70e5318dbf..893dcddf55 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 cb392c9df7..4a7c78c9a6 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 ca95d0bd07..d5ee555c18 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 07ff809c2b..cebd53ea66 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 ffd2b0075d..0eebaef8a1 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 973f0b0ea3..5fa3d973ae 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 a40c1163c5..d6efd9ef78 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 05324d4fff..48f79893e8 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 895a182f2a..cec5ecb67f 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 514122d99c..e05d998730 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 d877ee3f10..522b6c2270 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 b9758f5775..9a983f65f3 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 a8c3da67cf..96c94ed8fe 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 2ff7a7cb48..aa46faa7c7 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 d0da0067ec..7b0b7d5b77 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 da522a036f..29748d2b98 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 2e4086958b..0d2cc60117 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 0000000000..987eb51a6e --- /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 0000000000..63249afa49 --- /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 15b5f07748..0df390a1fa 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 47ad09c824..8a994f4440 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 3d567a1267..79203ca894 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 0000000000..a8b7a4dff9 --- /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 28e20b9e3e..16f71ef4b7 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 0000000000..4de7f25de3 --- /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 ba4f288125..204f459e43 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 0000000000..1b3874c944 --- /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 0000000000..ed37f3c85e --- /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 0000000000..018c0d4af0 --- /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 cc0212e5d9..921f0b7359 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 929bec1f5c..cc8093c358 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 374b8f9052..4dfa944f30 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 711c4b4d38..cd8bb52d56 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 31c7ceeb80..9847580737 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 04d730d003..475e84753e 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 3f13527de4..09cba17e0b 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 53f2223de2..4802dcdeb6 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 9f05eaed35..1deee03b06 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 f2755c3459..ccf2cded80 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 44d47d4bbb..81dcab9d10 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 2f6ff3c3ca..12644d2ae9 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 8b0dd15db0..2e1b8b34be 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 0000000000..128cb91bf2 --- /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 3fe3feaa03..c446104439 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 0000000000..551b247055 --- /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 a285ddef57..8c7b0174af 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 0000000000..010f78c7dc --- /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 2f5854b88b..bb4c93a7f5 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, + // CI-confirmed (Mutation testing / shard 2, PR #1260): 91.40% of 8302 valid mutants (587 survived, 127 no-coverage, 112 timeout, 7476 killed), timeout share 1.35% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: 587 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/odt/write.ts, typed/ods/read.ts, typed/draw/shapes.ts, and typed/odt/read.ts -- raise this again once the next batch's own full run confirms the next real floor. + breakThreshold: 89, });