Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions packages/odf.js/README.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/odf.js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export {
} from "./typed/draw/shapes";
export type { DrawPageContent } from "./typed/draw/shapes";

// The write-side mirror of the shape reader above: one ContentShape -> the draw:frame element readDrawFrame reads back, shared between odp (typed/odp/write.ts, below) and a future odg writer -- see that module's own top-of-file note for the exact split.
//
// createDrawShapeWriteState, DrawShapeWriteState, and planShapeContent are deliberately NOT re-exported here, and neither is ShapeContentPlan, which only planShapeContent produces: they are the seam odp's own writer and a future odg writer hold between THEMSELVES (the state constructor takes a StyleRegistry plus the raw XmlElement container automatic styles get appended to -- this package's own internal plumbing), and no consumer outside this package exists for them. They stay ordinary exports of their own module, which is all an in-package caller needs; putting them on the published surface would freeze that plumbing into the package's public API ahead of any concrete requirement for it, and every later change to it into a breaking one.
export { writeDrawFrame, writeDrawShapes } from "./typed/draw/write-shapes";

export { readDrawObjectReference } from "./typed/draw/embedded";
export type {
EmbeddedDrawObject,
Expand All @@ -254,6 +259,14 @@ export type {
export { readOdp, readOdpContent } from "./typed/odp/read";
export type { OdpDocument } from "./typed/odp/read";

// The odp WRITER, the inverse of the two readers above and this package's third content writer (typed/odt/write.ts's own top-of-file note states the shared design philosophy; typed/odp/write.ts's own top-of-file note states what's genuinely new for a presentation). writeOdp takes the DocumentTree readOdp returns, writeOdpContent the flat ContentDocument readOdpContent returns, and both produce a real .odp Package. normaliseOdpContent states the one canonical form a written-and-reread document equals.
export {
normaliseOdpContent,
writeOdp,
writeOdpContent,
} from "./typed/odp/write";
export type { OdpWriteOptions } from "./typed/odp/write";

export { readOdt, readOdtContent } from "./typed/odt/read";
export type {
OdtDocument,
Expand Down
335 changes: 335 additions & 0 deletions packages/odf.js/src/package-io/namespace-declarations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import type {
ContentBlock,
ContentDocument,
ContentShape,
} from "document-schema.js";
import {
PAGE_SIZE_A4,
SLIDE_SIZE_WIDESCREEN,
rgbHexToColor,
} from "document-schema.js";
import type { Package } from "../model/package";
import type { XmlElement, XmlNode } from "../model/node";
import { rootElement } from "../xml/query";
import { writeOdtContent } from "../typed/odt/write";
import { writeOdsContent } from "../typed/ods/write";
import { writeOdpContent } from "../typed/odp/write";
import { writeSxwContent, writeSxcContent } from "../ooo1/write";

// THE STRUCTURAL GUARD AGAINST AN UNDECLARED NAMESPACE PREFIX, for every writer in this package at once.
//
// A qualified name reaches an emitted part verbatim: xml/build.ts writes whatever `tag`/attribute name an element carries, and nothing between a writer and the bytes ever checks that the prefix in that name is actually bound on the part's own root. A writer reaching for a prefix package-io/scaffold.ts's own ODF_DOCUMENT_PREFIXES does not declare therefore produces a part that is not namespace-well-formed XML -- bytes that look right, round-trip perfectly through this package's own (prefix-string-matching, namespace-unaware) reader, and that no real consumer can parse. That is not hypothetical: writeOdp shipped emitting presentation:notes/presentation:class against a root that declared no presentation: prefix at all, and LibreOffice's own import silently re-homed every slide's speaker notes onto its visible shape list rather than its notes page as a result.
//
// So rather than pinning the prefix list itself (which would only restate scaffold.ts's own constant), this suite drives each writer over a document exercising as much of its vocabulary as it has, then walks every XML part of the resulting package -- every element tag and every attribute name, at any depth -- and asserts each prefix used is one the part's own root binds. A future writer emitting a smil:/anim:/chart:/form: name fails here, whatever the prefix, without anyone having to remember this failure mode.
//
// The two ooo1 (OpenOffice.org 1.x) writers are included for the same reason and get the check for free: transformToOoo1Package rewrites a package's root namespace DECLARATIONS wholesale, so a prefix it renames on the root but not in the tree (or the reverse) is exactly this same defect wearing a different hat.

// xml: is bound implicitly by the XML specification itself and never declared; xmlns: is the declaration mechanism, not a prefix that needs binding.
const IMPLICITLY_BOUND_PREFIXES: ReadonlySet<string> = new Set([
"xml",
"xmlns",
]);

function prefixOf(qualifiedName: string): string | undefined {
const colon = qualifiedName.indexOf(":");
return colon === -1 ? undefined : qualifiedName.slice(0, colon);
}

function declaredPrefixes(root: XmlElement): ReadonlySet<string> {
const declared = new Set<string>(IMPLICITLY_BOUND_PREFIXES);
for (const attribute of root.attributes) {
if (attribute.name.startsWith("xmlns:")) {
declared.add(attribute.name.slice("xmlns:".length));
}
}
return declared;
}

interface PrefixUse {
readonly prefix: string;
readonly where: string;
}

function collectPrefixUses(
nodes: readonly XmlNode[],
path: string,
out: PrefixUse[],
): void {
for (const node of nodes) {
if (node.type !== "element") {
continue;
}
const here = `${path}/${node.tag}`;
const tagPrefix = prefixOf(node.tag);
if (tagPrefix !== undefined) {
out.push({ prefix: tagPrefix, where: here });
}
for (const attribute of node.attributes) {
const attributePrefix = prefixOf(attribute.name);
if (attributePrefix !== undefined) {
out.push({
prefix: attributePrefix,
where: `${here}@${attribute.name}`,
});
}
}
collectPrefixUses(node.children, here, out);
}
}

// Every (prefix, location) pair the package uses without its own part's root binding it. Returned rather than asserted inside so a failure names the exact element/attribute path, not just a count.
function undeclaredPrefixUses(pkg: Package): string[] {
const failures: string[] = [];
for (const [partPath, part] of Object.entries(pkg.parts)) {
if (part.kind !== "xml") {
continue;
}
const root = rootElement(part.nodes);
if (root === undefined) {
throw new Error(`${partPath}: an XML part with no root element`);
}
const declared = declaredPrefixes(root);
const uses: PrefixUse[] = [];
collectPrefixUses(part.nodes, partPath, uses);
for (const use of uses) {
if (!declared.has(use.prefix)) {
failures.push(`${use.where} uses undeclared prefix "${use.prefix}:"`);
}
}
}
return failures;
}

const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 };

// A 1x1 PNG, genuinely decodable (sniffImageFormat reads real magic bytes, not a name) -- the same fixture the writers' own suites use.
const PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";

const IMAGE_BLOCK = {
kind: "image",
format: "png",
base64: PNG_BASE64,
widthPt: 96,
heightPt: 96,
altText: "A tiny square",
} as const satisfies ContentBlock;

const TABLE_BLOCK = {
kind: "table",
columnWidthsPt: [80, 120],
rows: [
{
heightPt: 18,
cells: [
{
blocks: [{ kind: "paragraph", runs: [{ text: "Merged" }] }],
colSpan: 2,
background: rgbHexToColor("#DDEEFF"),
borders: {
top: {
style: "solid",
widthPt: 1,
color: rgbHexToColor("#112233"),
},
},
},
],
},
{
cells: [
{ blocks: [{ kind: "paragraph", runs: [{ text: "Left" }] }] },
{ blocks: [{ kind: "paragraph", runs: [{ text: "Right" }] }] },
],
},
],
} as const satisfies ContentBlock;

const TEXT_BLOCKS: ContentBlock[] = [
{ kind: "paragraph", runs: [{ text: "Plain" }] },
{
kind: "paragraph",
headingLevel: 1,
runs: [{ text: "Heading", bold: true }],
},
{
kind: "paragraph",
runs: [
{ text: "Linked", hyperlink: "https://example.invalid/", italic: true },
{ text: "\ttabbed and spaced" },
],
list: { numId: "L1", level: 0 },
},
{ kind: "pageBreak" },
TABLE_BLOCK,
// An odt image anchors into the paragraph before it (writeSectionBlocks refuses one that has none), so this paragraph is load-bearing rather than filler.
{ kind: "paragraph", runs: [{ text: "Figure:" }] },
IMAGE_BLOCK,
];

const WORDPROCESSING: ContentDocument = {
kind: "wordprocessing",
metadata: {
title: "Namespace audit",
author: "A. Author",
keywords: ["one", "two"],
},
sections: [{ pageSize: PAGE_SIZE_A4, margins: MARGINS, blocks: TEXT_BLOCKS }],
};

const SPREADSHEET: ContentDocument = {
kind: "spreadsheet",
metadata: { title: "Namespace audit" },
sheets: [
{
name: "Sheet1",
cells: [
{
row: 0,
column: 0,
value: { kind: "string", value: "Header" },
displayText: "Header",
background: rgbHexToColor("#FFEECC"),
alignment: "center",
verticalAlignment: "middle",
borders: {
bottom: {
style: "double",
widthPt: 2,
color: rgbHexToColor("#334455"),
},
},
colSpan: 2,
},
{
row: 1,
column: 0,
value: { kind: "number", value: 42 },
displayText: "42",
},
{
row: 1,
column: 1,
value: { kind: "number", value: 43 },
displayText: "43",
formula: "of:=SUM([.A2]+1)",
},
{
row: 2,
column: 0,
value: { kind: "date", value: "2026-01-31" },
displayText: "31/01/2026",
},
{
row: 2,
column: 1,
value: { kind: "boolean", value: true },
displayText: "TRUE",
},
],
columns: [
{ index: 0, widthPt: 90 },
{ index: 1, hidden: true },
],
rows: [{ index: 0, heightPt: 24 }],
images: [
{
...IMAGE_BLOCK,
anchorRow: 3,
anchorColumn: 0,
offsetXPt: 2,
offsetYPt: 3,
},
],
printSettings: {
pageSize: PAGE_SIZE_A4,
margins: MARGINS,
gridlines: true,
headers: true,
pageOrder: "downThenOver",
printRange: { startRow: 0, startColumn: 0, endRow: 9, endColumn: 3 },
repeatRows: { start: 0, end: 0 },
scalePercent: 90,
manualBreaks: { rows: [2], columns: [1] },
},
},
],
};

function shape(
overrides: Partial<ContentShape>,
blocks: ContentShape["blocks"],
): ContentShape {
return {
frame: { xPt: 10, yPt: 20, widthPt: 300, heightPt: 100 },
insetLeftPt: 0,
insetTopPt: 0,
insetRightPt: 0,
insetBottomPt: 0,
blocks,
...overrides,
};
}

const PRESENTATION: ContentDocument = {
kind: "presentation",
metadata: { title: "Namespace audit" },
slides: [
{
size: SLIDE_SIZE_WIDESCREEN,
shapes: [
shape({ name: "Title" }, [
{ kind: "paragraph", runs: [{ text: "Title", bold: true }] },
]),
shape({ rotationDeg: 30, insetLeftPt: 4, insetTopPt: 4 }, [
{ kind: "paragraph", runs: [{ text: "Rotated" }] },
{
kind: "paragraph",
runs: [{ text: "Bulleted" }],
list: { numId: "L1", level: 0 },
},
]),
shape({}, [TABLE_BLOCK]),
shape({}, [IMAGE_BLOCK]),
],
notes: "First note line\nSecond note line",
},
{ size: PAGE_SIZE_A4, shapes: [], notes: "" },
],
};

describe("every emitted prefix is declared on its own part's root", () => {
it.each([
["writeOdtContent", () => writeOdtContent(WORDPROCESSING)],
["writeOdsContent", () => writeOdsContent(SPREADSHEET)],
["writeOdpContent", () => writeOdpContent(PRESENTATION)],
["writeSxwContent", () => writeSxwContent(WORDPROCESSING)],
["writeSxcContent", () => writeSxcContent(SPREADSHEET)],
])("%s", (_name, write) => {
expect(undeclaredPrefixUses(write())).toEqual([]);
});

// The audit itself has to be able to fail, or an "everything passed" run above says nothing: an undeclared prefix planted in a real writer's own output is reported, with the element path that used it.
it("reports an undeclared prefix rather than passing it over", () => {
const pkg = writeOdpContent(PRESENTATION);
const content = pkg.parts["content.xml"];
if (content?.kind !== "xml") {
throw new Error("expected an XML content.xml");
}
const root = rootElement(content.nodes);
if (root === undefined) {
throw new Error("expected a content.xml root element");
}
root.children.push({
type: "element",
tag: "anim:par",
attributes: [{ name: "smil:begin", value: "0s" }],
children: [],
});
expect(undeclaredPrefixUses(pkg)).toEqual([
'content.xml/office:document-content/anim:par uses undeclared prefix "anim:"',
'content.xml/office:document-content/anim:par@smil:begin uses undeclared prefix "smil:"',
]);
});
});
6 changes: 5 additions & 1 deletion packages/odf.js/src/package-io/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { encodeXmlText } from "../xml/entities";
//
// META-INF/manifest.xml is NOT created here: it is derived from the package's own parts, so it can only be built once the writer has finished adding them. A writer calls syncManifest (src/manifest.ts) as its last step instead. meta.xml is likewise the metadata writer's own (src/typed/shared/metadata.ts), and settings.xml is not created at all -- it holds a producer's own view state, which a document assembled from a ContentDocument has none of, and every XML part a reader does not consume quarantines as package-tier residue on the way back in.

// The prefixes a text document's content.xml and styles.xml declare. One list for both parts, matching what real producers do: LibreOffice declares the same broad prefix set on every part of a package rather than a per-part minimum, and an undeclared prefix appearing later (a table inside a document whose root declared no table:) would make the part not well-formed at all.
// The prefixes a document's content.xml and styles.xml declare. One list for both parts and for every document kind this package writes, matching what real producers do: LibreOffice declares the same broad prefix set on every part of a package rather than a per-part minimum, and declares prefixes the document never uses at all (a plain text document's own content.xml, from `soffice --headless --convert-to odt`, declares chart:, dr3d:, math:, form:, and xforms: among others, none of which a paragraph of text can reference) -- an unused namespace declaration is valid XML and valid ODF, whereas an undeclared prefix appearing later (a table inside a document whose root declared no table:) makes the part not well-formed XML at all.
//
// That last hazard is silent rather than theoretical: this package's XML builder emits a qualified name verbatim, never checking that its prefix is in scope, so a writer reaching for an undeclared prefix produces bytes that look right and that no XML parser will accept. package-io/namespace-declarations.test.ts closes it structurally, auditing every prefix each writer actually emits -- across element names and attribute names, at any depth -- against what the part's own root declares.
const ODF_DOCUMENT_PREFIXES: readonly OdfNamespacePrefix[] = [
"office",
"style",
Expand All @@ -22,6 +24,8 @@ const ODF_DOCUMENT_PREFIXES: readonly OdfNamespacePrefix[] = [
"meta",
"number",
"svg",
// presentation: a slide's own presentation:notes element and its notes frame's presentation:class attribute (typed/odp/write.ts's writeSlideNotes).
"presentation",
];

// The current OASIS OpenDocument Format standard version, and this module's default for office:version. Matches manifest.ts's own DEFAULT_MANIFEST_VERSION, which is where the same fact reaches META-INF/manifest.xml.
Expand Down
Loading