From 8c650340ded191d57eb1667252da032e7c6b5648 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 22:38:53 +0100 Subject: [PATCH 01/15] refactor(odf.js): share write-side list-numId planning and canonical-form helpers typed/odt/write.ts's own numId-run-planning (ListPlanState/listKindOf/canonicalNumId) and its paragraph/table/cell/image canonical-form helpers were the only writer of either kind in the package, so both lived as private functions in that one file. A second writer needs the identical logic verbatim: list runs group and mint canonical numIds the same way regardless of which container holds the text:list, and writeOdfTable's own table/cell/image round-trip shape does not change depending on which writer calls it. Move the numId planning into typed/shared/list.ts (ListPlanState, planListMembership, closeListPlan, listKindOf, canonicalNumId, NO_NUM_ID_KEY) and the canonical-form helpers into a new typed/shared/canonicalise.ts (canonicalColor, canonicalRun, canonicalParagraph, canonicalTable, canonicalImage), with typed/odt/write.ts importing both rather than declaring them locally. Pure extraction: normaliseOdtContent's own behaviour and every existing odt test are unchanged. The extracted NO_NUM_ID_KEY sentinel is now a genuine NUL character, matching its own comment ("NUL -- forbidden in well-formed XML 1.0 content") and registry.ts's identical FINGERPRINT_SEPARATOR convention -- the pre-existing local copy was a literal space, which the comment never actually described. --- packages/odf.js/src/typed/odt/write.ts | 239 ++---------------- .../odf.js/src/typed/shared/canonicalise.ts | 190 ++++++++++++++ packages/odf.js/src/typed/shared/list.ts | 63 +++++ 3 files changed, 272 insertions(+), 220 deletions(-) create mode 100644 packages/odf.js/src/typed/shared/canonicalise.ts diff --git a/packages/odf.js/src/typed/odt/write.ts b/packages/odf.js/src/typed/odt/write.ts index 0fc3bccdb..0b0cd51e7 100644 --- a/packages/odf.js/src/typed/odt/write.ts +++ b/packages/odf.js/src/typed/odt/write.ts @@ -4,17 +4,14 @@ import type { ContentImageBlock, ContentPageBreak, ContentParagraph, - ContentRun, ContentSection, ContentTable, - ContentTableCell, DocumentTree, LayoutMetadata, Margins, PageSize, } from "document-schema.js"; -import type { Color } from "document-schema.js"; -import { colorToRgbHex, flattenTree, rgbHexToColor } from "document-schema.js"; +import { flattenTree } from "document-schema.js"; import type { Package } from "../../model/package"; import type { XmlElement, XmlNode } from "../../model/node"; import { ODF_MEDIA_TYPES } from "../../media-type"; @@ -29,14 +26,20 @@ import { el, txt } from "../../xml/fragment"; import { encodeXmlText } from "../../xml/entities"; import { formatOdfLength } from "../shared/units"; import { writeOdfMetadata } from "../shared/metadata"; -import { - segmentOdfParagraphRuns, - writeOdfParagraph, -} from "../shared/paragraph"; +import { writeOdfParagraph } from "../shared/paragraph"; import { writeOdfTable } from "../shared/table"; +import { + canonicalImage, + canonicalParagraph, + canonicalTable, +} from "../shared/canonicalise"; import { buildOdfListStyle, + closeListPlan, + listKindOf, + planListMembership, writeOdfList, + type ListPlanState, type OdfListEntry, } from "../shared/list"; @@ -85,34 +88,7 @@ interface PlannedSection { readonly blocks: PlannedBlock[]; } -// The list-identity counter one document's plan threads: readOdtContent mints a numId per top-level text:list encountered in document order across the WHOLE body, so the plan has to number lists the same way -- once per maximal run of consecutive list paragraphs sharing an incoming numId, across sections, never per distinct numId string (two separate runs carrying one numId are two ODF lists, and the reader will say so). -interface ListPlanState { - next: number; - // The incoming numId of the run currently open, and the canonical numId minted for it. Both absent between runs. - openNumId?: string; - openCanonicalNumId?: string; -} - -// The ordered/bullet half of a numId, as typed/shared/list.ts's own mintOdfListNumId spells it. A numId carrying neither prefix names a list whose kind the source never stated, which is written as a text:list with no text:style-name at all -- and read back, again, with no prefix. -function listKindOf( - numId: string | undefined, -): "ordered" | "bullet" | undefined { - if (numId === undefined) { - return undefined; - } - if (numId.startsWith("ordered:")) { - return "ordered"; - } - return numId.startsWith("bullet:") ? "bullet" : undefined; -} - -// The run key standing in for a membership that carries no numId of its own -- NUL, forbidden in well-formed XML 1.0 content, so no real numId can ever equal it. -const NO_NUM_ID_KEY = " "; - -function canonicalNumId(incoming: string | undefined, ordinal: number): string { - const kind = listKindOf(incoming); - return kind === undefined ? `list${ordinal}` : `${kind}:list${ordinal}`; -} +// The list-identity counter one document's plan threads: readOdtContent mints a numId per top-level text:list encountered in document order across the WHOLE body, so the plan has to number lists the same way -- once per maximal run of consecutive list paragraphs sharing an incoming numId, across sections, never per distinct numId string (two separate runs carrying one numId are two ODF lists, and the reader will say so). ListPlanState/planListMembership/closeListPlan/listKindOf/canonicalNumId are shared with typed/odp/write.ts (typed/shared/list.ts's own top-of-file note on the write-side canonicalisation both formats need identically) rather than redeclared here. function unsupported(what: string, where: string): Error { return new Error( @@ -145,82 +121,7 @@ function assertWritableBlock( } } -// One paragraph in the exact shape reading the written document back produces: runs segmented into what ODF's inline content model can carry, list membership renumbered onto the reader's own minted identity, and every field the format has no spelling for dropped. styleId is the interesting one -- a heading's identity is STRUCTURAL in ODF (a text:h carrying text:outline-level), so readParagraphOrHeading always re-derives it as "Heading{level}" and it survives exactly; every other paragraph's styleId is a producer's own style name, and this writer's producer names are the automatic styles it mints, so an incoming one cannot survive and is dropped rather than pretended about. ODF states every colour as six hex digits (its own text:color datatype -- see typed/shared/color.ts), so a Color component that is not a whole 1/255 step cannot be carried: 0.9 is written as "e6" and read back as 230/255. Round-tripping through document-schema.js's own hex pair IS that quantisation, stated once here rather than approximated with an epsilon comparison in a test. -function canonicalColor(color: Color): Color { - return rgbHexToColor(colorToRgbHex(color)); -} - -// One run carrying only the fields it actually states. The reader builds every run with all seven formatting fields present and most of them undefined (typed/shared/paragraph.ts's runFromText), while a hand-built document states only what it means -- the same run, spelled two ways. The canonical form is the spelled-only one, so the two are comparable at all. -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; - } - if (run.underline !== undefined) { - canonical.underline = run.underline; - } - if (run.strike !== undefined) { - canonical.strike = run.strike; - } - if (run.fontFamily !== undefined) { - canonical.fontFamily = run.fontFamily; - } - if (run.sizePt !== undefined) { - canonical.sizePt = run.sizePt; - } - if (run.color !== undefined) { - canonical.color = canonicalColor(run.color); - } - if (run.hyperlink !== undefined) { - canonical.hyperlink = run.hyperlink; - } - return canonical; -} - -function canonicalParagraph( - paragraph: ContentParagraph, - listNumId: string | undefined, -): ContentParagraph { - const canonical: ContentParagraph = { - kind: "paragraph", - runs: segmentOdfParagraphRuns(paragraph.runs).map(canonicalRun), - }; - if (paragraph.headingLevel !== undefined) { - canonical.headingLevel = paragraph.headingLevel; - canonical.styleId = `Heading${paragraph.headingLevel}`; - } - if (paragraph.alignment !== undefined) { - canonical.alignment = paragraph.alignment; - } - if (listNumId !== undefined && paragraph.list !== undefined) { - canonical.list = { numId: listNumId, level: paragraph.list.level }; - } - if (paragraph.spacingBeforePt !== undefined) { - canonical.spacingBeforePt = paragraph.spacingBeforePt; - } - if (paragraph.spacingAfterPt !== undefined) { - canonical.spacingAfterPt = paragraph.spacingAfterPt; - } - if (paragraph.lineSpacing !== undefined) { - canonical.lineSpacing = paragraph.lineSpacing; - } - if (paragraph.indentLeftPt !== undefined) { - canonical.indentLeftPt = paragraph.indentLeftPt; - } - if (paragraph.indentFirstLinePt !== undefined) { - canonical.indentFirstLinePt = paragraph.indentFirstLinePt; - } - if (paragraph.pageBreakBefore !== undefined) { - canonical.pageBreakBefore = paragraph.pageBreakBefore; - } - if (paragraph.pageBreakAfter !== undefined) { - canonical.pageBreakAfter = paragraph.pageBreakAfter; - } - return canonical; -} +// canonicalParagraph (headingLevel/alignment/list/spacing/indent/pageBreak, run segmentation and colour quantisation) now lives in typed/shared/canonicalise.ts, reused verbatim by typed/odp/write.ts for a shape's own text paragraphs and a table nested inside a shape -- see that module's own top-of-file note. function emptyAnchorParagraph(pageBreakBefore: boolean): ContentParagraph { return pageBreakBefore @@ -253,8 +154,7 @@ function planSection( // A list run closes the moment anything that is not one of its own paragraphs is emitted. An anchored image does NOT close it: the image hangs off the paragraph it follows, inside that paragraph's own list item, so the list element itself is uninterrupted -- which is exactly how the reader sees it on the way back. const closeListRun = (): void => { - listState.openNumId = undefined; - listState.openCanonicalNumId = undefined; + closeListPlan(listState); }; const flushPendingPageBreak = (): void => { @@ -284,25 +184,9 @@ function planSection( continue; } if (block.kind === "paragraph") { - const membership = block.list; - if (membership === undefined) { - closeListRun(); - } else { - // A membership with no numId at all (ContentListMembershipSchema makes it optional, for a source format carrying only a depth) still names a real list here -- it just names one whose identity the source never stated, so it gets its own run key and its own minted numId on the way back in, exactly as any other list does. - const incomingKey = membership.numId ?? NO_NUM_ID_KEY; - if (listState.openNumId !== incomingKey) { - listState.openNumId = incomingKey; - listState.openCanonicalNumId = canonicalNumId( - membership.numId, - listState.next, - ); - listState.next += 1; - } - } - const paragraph = canonicalParagraph( - block, - membership === undefined ? undefined : listState.openCanonicalNumId, - ); + // A membership with no numId at all (ContentListMembershipSchema makes it optional, for a source format carrying only a depth) still names a real list here -- it just names one whose identity the source never stated, so it gets its own run key and its own minted numId on the way back in, exactly as any other list does. planListMembership (typed/shared/list.ts) owns this canonicalisation. + const canonicalId = planListMembership(block.list, listState); + const paragraph = canonicalParagraph(block, canonicalId); pushParagraph( pendingPageBreak ? { ...paragraph, pageBreakBefore: true } : paragraph, ); @@ -341,93 +225,8 @@ function planDocument(sections: readonly ContentSection[]): PlannedSection[] { } // --- the canonical form: what reading this writer's own output back produces -------------------------------------- - -function canonicalCell( - cell: ContentTableCell, - covered: boolean, -): ContentTableCell { - // 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. - if (covered) { - return { blocks: [] }; - } - const canonical: ContentTableCell = { - blocks: cell.blocks.map((block) => { - if (block.kind !== "paragraph") { - throw unsupported(`a "${block.kind}" block`, "a table cell"); - } - assertWritableParagraph(block); - return canonicalParagraph(block, undefined); - }), - }; - if (cell.colSpan !== undefined) { - canonical.colSpan = cell.colSpan; - } - if (cell.rowSpan !== undefined) { - canonical.rowSpan = cell.rowSpan; - } - if (cell.background !== undefined) { - canonical.background = canonicalColor(cell.background); - } - if (cell.borders !== undefined) { - // An absent border style is written as "solid", which is what ContentBorderSchema already documents an absent style to mean -- so it comes back stated rather than absent. - const borders: NonNullable = {}; - for (const edge of ["left", "right", "top", "bottom"] as const) { - const border = cell.borders[edge]; - if (border !== undefined) { - borders[edge] = { - color: canonicalColor(border.color), - widthPt: border.widthPt, - style: border.style ?? "solid", - }; - } - } - canonical.borders = borders; - } - return canonical; -} - -function canonicalTable(table: ContentTable): ContentTable { - const covered = new Set(); - return { - kind: "table", - columnWidthsPt: [...table.columnWidthsPt], - rows: table.rows.map((row, rowIndex) => { - const cells = row.cells.map((cell, columnIndex) => { - const key = `${rowIndex},${columnIndex}`; - const isCovered = covered.has(key); - if (!isCovered) { - const colSpan = cell.colSpan ?? 1; - const rowSpan = cell.rowSpan ?? 1; - for (let r = rowIndex; r < rowIndex + rowSpan; r += 1) { - for (let c = columnIndex; c < columnIndex + colSpan; c += 1) { - if (r !== rowIndex || c !== columnIndex) { - covered.add(`${r},${c}`); - } - } - } - } - return canonicalCell(cell, isCovered); - }); - return row.heightPt === undefined - ? { cells } - : { cells, heightPt: row.heightPt }; - }), - }; -} - -function canonicalImage(image: ContentImageBlock): ContentImageBlock { - const canonical: ContentImageBlock = { - kind: "image", - format: image.format, - base64: image.base64, - widthPt: image.widthPt, - heightPt: image.heightPt, - }; - if (image.altText !== undefined) { - canonical.altText = image.altText; - } - return canonical; -} +// +// canonicalTable/canonicalImage now live in typed/shared/canonicalise.ts, reused verbatim rather than restated: writeOdfTable is the one table writer/reader pair every caller in this package shares (odt's own top-level tables, or one nested inside an odp/odg shape), and an image part is copied byte-for-byte regardless of which writer placed it. function canonicalMetadata(metadata: LayoutMetadata): LayoutMetadata { const canonical: LayoutMetadata = {}; diff --git a/packages/odf.js/src/typed/shared/canonicalise.ts b/packages/odf.js/src/typed/shared/canonicalise.ts new file mode 100644 index 000000000..344f539a6 --- /dev/null +++ b/packages/odf.js/src/typed/shared/canonicalise.ts @@ -0,0 +1,190 @@ +import type { + Color, + ContentImageBlock, + ContentParagraph, + ContentTable, + ContentTableCell, + ContentRun, +} from "document-schema.js"; +import { colorToRgbHex, rgbHexToColor } from "document-schema.js"; +import { segmentOdfParagraphRuns } from "./paragraph"; + +// The write-side canonical form every ODF content writer in this package states its own round-trip law against: what reading a WRITTEN document back actually produces, for the pieces of the content model this package's writers already share verbatim (a paragraph's runs and formatting, a table's cells, an image block) -- factored out once typed/odt/write.ts's own normaliseOdtContent first stated it, now reused by typed/odp/write.ts (a shape's own text paragraphs, and a table nested inside a shape) rather than restated per format. See typed/odt/write.ts's own top-of-file note for the fuller philosophy this canonical-form discipline follows; this module owns only the pieces genuinely identical across every writer, not a format's own section/slide-level structure. + +function unsupportedContent(what: string, where: string): Error { + return new Error( + `${where} carries ${what}, which no ODF writer in this package can produce yet -- refusing to state a canonical form for content that would be silently lost on write. See ExaDev/documents.js for the tracked follow-up covering the fidelity constructs and embedded objects.`, + ); +} + +// ODF states every colour as six hex digits (its own text:color datatype -- see typed/shared/color.ts), so a Color component that is not a whole 1/255 step cannot be carried: 0.9 is written as "e6" and read back as 230/255. Round-tripping through document-schema.js's own hex pair IS that quantisation, stated once here rather than approximated with an epsilon comparison in a test. +export function canonicalColor(color: Color): Color { + return rgbHexToColor(colorToRgbHex(color)); +} + +// One run carrying only the fields it actually states. The reader builds every run with all seven formatting fields present and most of them undefined (typed/shared/paragraph.ts's runFromText), while a hand-built document states only what it means -- the same run, spelled two ways. The canonical form is the spelled-only one, so the two are comparable at all. +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; + } + if (run.underline !== undefined) { + canonical.underline = run.underline; + } + if (run.strike !== undefined) { + canonical.strike = run.strike; + } + if (run.fontFamily !== undefined) { + canonical.fontFamily = run.fontFamily; + } + if (run.sizePt !== undefined) { + canonical.sizePt = run.sizePt; + } + if (run.color !== undefined) { + canonical.color = canonicalColor(run.color); + } + if (run.hyperlink !== undefined) { + canonical.hyperlink = run.hyperlink; + } + return canonical; +} + +// One paragraph in the exact shape reading the written document back produces: runs segmented into what ODF's inline content model can carry (see segmentOdfParagraphRuns's own note), list membership renumbered onto the given canonical numId (undefined strips membership entirely -- a table cell, which never carries list membership, always passes undefined here), and every field the format has no spelling for dropped. styleId is the interesting one -- a heading's identity is STRUCTURAL in ODF (a text:h carrying text:outline-level), so a reader always re-derives it as "Heading{level}" and it survives exactly; every other paragraph's styleId is a producer's own style name, and this package's writers mint their own automatic-style names, so an incoming one cannot survive and is dropped rather than pretended about. Refuses (rather than silently dropping) a run-level construct extent, the same fidelity-construct stance every writer in this package takes. +export function canonicalParagraph( + paragraph: ContentParagraph, + listNumId: string | undefined, +): ContentParagraph { + if (paragraph.constructs !== undefined && paragraph.constructs.length > 0) { + throw unsupportedContent( + "run-level construct extents (a field, bookmark, note, annotation, or tracked change)", + "a paragraph", + ); + } + const canonical: ContentParagraph = { + kind: "paragraph", + runs: segmentOdfParagraphRuns(paragraph.runs).map(canonicalRun), + }; + if (paragraph.headingLevel !== undefined) { + canonical.headingLevel = paragraph.headingLevel; + canonical.styleId = `Heading${paragraph.headingLevel}`; + } + if (paragraph.alignment !== undefined) { + canonical.alignment = paragraph.alignment; + } + if (listNumId !== undefined && paragraph.list !== undefined) { + canonical.list = { numId: listNumId, level: paragraph.list.level }; + } + if (paragraph.spacingBeforePt !== undefined) { + canonical.spacingBeforePt = paragraph.spacingBeforePt; + } + if (paragraph.spacingAfterPt !== undefined) { + canonical.spacingAfterPt = paragraph.spacingAfterPt; + } + if (paragraph.lineSpacing !== undefined) { + canonical.lineSpacing = paragraph.lineSpacing; + } + if (paragraph.indentLeftPt !== undefined) { + canonical.indentLeftPt = paragraph.indentLeftPt; + } + if (paragraph.indentFirstLinePt !== undefined) { + canonical.indentFirstLinePt = paragraph.indentFirstLinePt; + } + if (paragraph.pageBreakBefore !== undefined) { + canonical.pageBreakBefore = paragraph.pageBreakBefore; + } + if (paragraph.pageBreakAfter !== undefined) { + canonical.pageBreakAfter = paragraph.pageBreakAfter; + } + return canonical; +} + +// 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 non-paragraph block (a table cell can hold only text:p/text:h, per typed/shared/table.ts's own readTableCell) is refused by name, matching every writer's own fidelity-construct stance. +function canonicalCell( + cell: ContentTableCell, + covered: boolean, +): ContentTableCell { + if (covered) { + return { blocks: [] }; + } + const canonical: ContentTableCell = { + blocks: cell.blocks.map((block) => { + if (block.kind !== "paragraph") { + throw unsupportedContent(`a "${block.kind}" block`, "a table cell"); + } + return canonicalParagraph(block, undefined); + }), + }; + if (cell.colSpan !== undefined) { + canonical.colSpan = cell.colSpan; + } + if (cell.rowSpan !== undefined) { + canonical.rowSpan = cell.rowSpan; + } + if (cell.background !== undefined) { + canonical.background = canonicalColor(cell.background); + } + if (cell.borders !== undefined) { + // An absent border style is written as "solid", which is what ContentBorderSchema already documents an absent style to mean -- so it comes back stated rather than absent. + const borders: NonNullable = {}; + for (const edge of ["left", "right", "top", "bottom"] as const) { + const border = cell.borders[edge]; + if (border !== undefined) { + borders[edge] = { + color: canonicalColor(border.color), + widthPt: border.widthPt, + style: border.style ?? "solid", + }; + } + } + canonical.borders = borders; + } + 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. +export function canonicalTable(table: ContentTable): ContentTable { + const covered = new Set(); + return { + kind: "table", + columnWidthsPt: [...table.columnWidthsPt], + rows: table.rows.map((row, rowIndex) => { + const cells = row.cells.map((cell, columnIndex) => { + const key = `${rowIndex},${columnIndex}`; + const isCovered = covered.has(key); + if (!isCovered) { + const colSpan = cell.colSpan ?? 1; + const rowSpan = cell.rowSpan ?? 1; + for (let r = rowIndex; r < rowIndex + rowSpan; r += 1) { + for (let c = columnIndex; c < columnIndex + colSpan; c += 1) { + if (r !== rowIndex || c !== columnIndex) { + covered.add(`${r},${c}`); + } + } + } + } + return canonicalCell(cell, isCovered); + }); + return row.heightPt === undefined + ? { cells } + : { cells, heightPt: row.heightPt }; + }), + }; +} + +// The one canonical ContentImageBlock a written-and-reread image equals: format/base64/size/altText survive verbatim (an image part is copied byte-for-byte into the package, never re-encoded), and every other field (sourcePath, source, frames -- a reader's and a layout pass's own facts, never content) is dropped. +export function canonicalImage(image: ContentImageBlock): ContentImageBlock { + const canonical: ContentImageBlock = { + kind: "image", + format: image.format, + base64: image.base64, + widthPt: image.widthPt, + heightPt: image.heightPt, + }; + if (image.altText !== undefined) { + canonical.altText = image.altText; + } + return canonical; +} diff --git a/packages/odf.js/src/typed/shared/list.ts b/packages/odf.js/src/typed/shared/list.ts index f786a7e8a..cc0212e5d 100644 --- a/packages/odf.js/src/typed/shared/list.ts +++ b/packages/odf.js/src/typed/shared/list.ts @@ -186,6 +186,69 @@ export function writeOdfList( return root; } +// --- the write-side numId canonicalisation every list-carrying writer shares (odt's own office:text body, odp's own slide text frames) --- +// +// A writer's caller hands in an arbitrary ContentDocument, whose ContentListMembership.numId is an opaque, caller-chosen string with no ODF spelling of its own (see this module's own top-of-file note: ODF list identity is purely structural, never an attribute). What DOES need to be minted afresh is the KIND prefix's carry-through and a document-unique canonical label matching what re-reading the written document will produce (mintOdfListNumId's own per-encounter counter, above) -- this is that minting, factored out so every writer that groups paragraphs into text:list runs states it once rather than reinventing it per format. + +// The ordered/bullet half of a numId, as mintOdfListNumId above spells it. A numId carrying neither prefix names a list whose kind the source never stated, which is written as a text:list with no text:style-name at all -- and read back, again, with no prefix. +export function listKindOf( + numId: string | undefined, +): "ordered" | "bullet" | undefined { + if (numId === undefined) { + return undefined; + } + if (numId.startsWith("ordered:")) { + return "ordered"; + } + return numId.startsWith("bullet:") ? "bullet" : undefined; +} + +// The run key standing in for a membership that carries no numId of its own -- NUL, forbidden in well-formed XML 1.0 content (the same convention registry.ts's own FINGERPRINT_SEPARATOR uses), so no real numId can ever equal it. +export const NO_NUM_ID_KEY = ""; + +export function canonicalNumId( + incoming: string | undefined, + ordinal: number, +): string { + const kind = listKindOf(incoming); + return kind === undefined ? `list${ordinal}` : `${kind}:list${ordinal}`; +} + +// The list-identity counter one document's plan threads: a reader mints a numId per top-level text:list encountered in document order across the WHOLE relevant scope (an odt body, an odp presentation), so a writer's own plan has to number lists the same way -- once per maximal run of consecutive list paragraphs sharing an incoming numId, never per distinct numId string (two separate runs carrying one numId are two ODF lists, and the reader will say so). +export interface ListPlanState { + next: number; + // The incoming numId of the run currently open, and the canonical numId minted for it. Both absent between runs. + openNumId?: string; + openCanonicalNumId?: string; +} + +// Advances one paragraph's list-plan state and returns the canonical numId to stamp on its membership, or undefined when `membership` itself is undefined (also closing whatever run was open). A membership carrying no incoming numId at all still opens a real run of its own (keyed on the sentinel above), mirroring how a source format that carries only a depth still names a genuine list once minted. Mint a fresh canonical numId only when the incoming key changes from the currently open run's -- consecutive paragraphs sharing one incoming numId (or both bare) extend the same run. +export function planListMembership( + membership: { numId?: string; level: number } | undefined, + listState: ListPlanState, +): string | undefined { + if (membership === undefined) { + closeListPlan(listState); + return undefined; + } + const incomingKey = membership.numId ?? NO_NUM_ID_KEY; + if (listState.openNumId !== incomingKey) { + listState.openNumId = incomingKey; + listState.openCanonicalNumId = canonicalNumId( + membership.numId, + listState.next, + ); + listState.next += 1; + } + return listState.openCanonicalNumId; +} + +// Force-closes whatever list run is currently open, for a caller that needs a run boundary the membership check alone would not catch -- a table, an image, a page break interrupting an odt section's own block flow, or a shape/container boundary no list can structurally span (an odp text-box's list is local to its own draw:frame, so a new shape must never silently continue the previous one's run even if their raw numIds happen to coincide). +export function closeListPlan(listState: ListPlanState): void { + listState.openNumId = undefined; + listState.openCanonicalNumId = undefined; +} + export function readOdfListParagraphs( listElement: XmlElement, membership: ContentListMembership, From 2ad858150f9f2fe8e648a4b3900813e676d8ab97 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 22:39:10 +0100 Subject: [PATCH 02/15] feat(odf.js): add the shared draw-shape writer for odp/odg typed/draw/shapes.ts's own readDrawFrame/walkDrawShapes are already shared between readOdp and readOdg -- their write-side mirror belongs in the same place, so a future odg writer reuses shape geometry, insets, and text/table/ image content writing rather than reimplementing it. writeDrawFrame turns one ContentShape into the draw:frame element the reader reads back: svg:x/y/width/height when unrotated, or svg:width/height plus a draw:transform="rotate(...) translate(...)" when rotated -- the exact algebraic inverse of typed/shared/transform.ts's resolveOdfShapeGeometry, derived by solving that module's own center/rotationDeg formulas for the translate() offset a given frame+rotationDeg requires. planShapeContent validates and discriminates a shape's blocks into the one content kind a real draw:frame can carry (table:table XOR draw:text-box XOR draw:image -- never a mix, since ODF has no spelling for one), refusing a heading, a page break, an embedded object, a construct boundary marker, or a mixed table/image by name rather than silently dropping it, and canonicalises any paragraph-level list membership onto the caller's own ListPlanState in the same pass. writeDrawShapes writes a whole page's shapes in document order. Not yet exported from the package barrel or wired into any writer -- the odp writer that actually uses this module lands as its own commit. --- .../odf.js/src/typed/draw/write-shapes.ts | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 packages/odf.js/src/typed/draw/write-shapes.ts diff --git a/packages/odf.js/src/typed/draw/write-shapes.ts b/packages/odf.js/src/typed/draw/write-shapes.ts new file mode 100644 index 000000000..c66c9aac9 --- /dev/null +++ b/packages/odf.js/src/typed/draw/write-shapes.ts @@ -0,0 +1,333 @@ +import type { + Box, + ContentBlock, + ContentImageBlock, + ContentParagraph, + ContentShape, + ContentTable, +} from "document-schema.js"; +import type { XmlElement, XmlNode } from "../../model/node"; +import type { Package } from "../../model/package"; +import { el, txt } from "../../xml/fragment"; +import { encodeXmlText } from "../../xml/entities"; +import { type StyleRegistry } from "../../styles/registry"; +import { formatOdfLength } from "../shared/units"; +import { writeOdfParagraph } from "../shared/paragraph"; +import { writeOdfTable } from "../shared/table"; +import { + buildOdfListStyle, + closeListPlan, + listKindOf, + planListMembership, + writeOdfList, + type ListPlanState, + type OdfListEntry, +} from "../shared/list"; + +// The write-side mirror of typed/draw/shapes.ts's own readDrawFrame/walkDrawShapes: one ContentShape -> the draw:frame element those functions read back, shared between odp (typed/odp/write.ts, the first caller) and a future odg writer, exactly as the read side's own shapes.ts is shared between readOdp and readOdg (see typed/odg/read.ts's own FACTORING DECISION note for why the split sits here). What differs between the two formats on the write side -- odp's own document-wide list-numId threading versus a per-shape default, a slide's presentation:notes, a drawing page's own vector primitives -- stays in each format's own write.ts; this module owns only the one thing genuinely identical between them: turning a ContentShape into a real draw:frame. +// +// THE ONE HARD CONSTRAINT THIS MODULE IS BUILT AROUND: a draw:frame's content is exactly ONE of table:table, draw:text-box, or draw:image (readDrawFrameContent's own top-of-file note, verified against real LibreOffice output) -- never a mix, and never more than one. planShapeContent below is the single place that decides which of the three a shape's own `blocks` array maps to, refusing by name (rather than silently dropping) any combination ODF has no spelling for. + +function unsupportedShapeContent(what: string): Error { + return new Error( + `writeDrawFrame: a shape carries ${what}, which this writer does not write yet -- refusing rather than producing a document that silently lost it. See ExaDev/documents.js for the tracked follow-up covering the fidelity constructs.`, + ); +} + +// One shape's content, discriminated into the exact three shapes a draw:frame can hold. `paragraphs` for the text case already carries its final, write-ready list membership (numId resolved by the caller's own ListPlanState, per this module's own top-of-file note) -- planShapeContent both validates the block combination and, for the text case, performs that resolution in the same pass, so a caller can never see an intermediate state where the two have drifted apart. +export type ShapeContentPlan = + | { readonly kind: "text"; readonly paragraphs: readonly ContentParagraph[] } + | { readonly kind: "table"; readonly table: ContentTable } + | { readonly kind: "image"; readonly image: ContentImageBlock }; + +// Validates and discriminates a shape's `blocks` into the one content kind its draw:frame will carry, canonicalising any paragraph-level list membership onto the SAME ListPlanState (typed/shared/list.ts) the caller threads across whatever scope its own format requires (odp threads one state across the whole presentation; a future odg writer may choose the same, or reset per shape -- this function does not decide that, it only ever reads the state it is given). Force-closes the plan's currently open run FIRST, unconditionally: a list can never structurally span two shapes (each is its own draw:text-box), so a caller must never see the previous shape's run silently continue into this one even if their raw numIds happen to coincide. +// +// Refusals, each by name rather than a silent drop: +// - a table or an image found ALONGSIDE any other block (only a shape whose blocks are ALL paragraphs, or whose blocks are EXACTLY one table, or EXACTLY one image, has a real draw:frame spelling); +// - a page break (no ODF spelling inside a shape's own text -- draw:text-box has no page concept at all); +// - an embedded object or a construct boundary marker (the same fidelity constructs odt's own writer refuses, not yet handled here); +// - a heading (a shape's own draw:text-box content model is (text:p | text:list)* with no text:h at all -- readDrawFrameContent's own text-box walk only ever looks for those two tags, so a text:h written here would be silently invisible on the way back in, not merely unusual). +export function planShapeContent( + blocks: readonly ContentBlock[], + listState: ListPlanState, +): ShapeContentPlan { + closeListPlan(listState); + if (blocks.length === 1) { + const only = blocks[0]!; + if (only.kind === "table") { + return { kind: "table", table: only }; + } + if (only.kind === "image") { + return { kind: "image", image: only }; + } + } + + const paragraphs: ContentParagraph[] = []; + for (const block of blocks) { + if (block.kind === "table") { + throw unsupportedShapeContent( + "a table alongside other content (a draw:frame's own content is exactly one of table:table/draw:text-box/draw:image, never a mix)", + ); + } + if (block.kind === "image") { + throw unsupportedShapeContent( + "an image alongside other content (same reason)", + ); + } + if (block.kind === "pageBreak") { + throw unsupportedShapeContent( + "a page break (no ODF spelling inside a shape's own text)", + ); + } + if (block.kind === "embeddedObject") { + throw unsupportedShapeContent("an embedded object"); + } + if (block.kind === "constructStart" || block.kind === "constructEnd") { + throw unsupportedShapeContent("a construct boundary marker"); + } + // block.kind === "paragraph" here, by elimination over ContentBlock's own discriminant. + if (block.constructs !== undefined && block.constructs.length > 0) { + throw unsupportedShapeContent( + "a run-level construct extent (a field, bookmark, note, annotation, or tracked change)", + ); + } + if (block.headingLevel !== undefined) { + throw unsupportedShapeContent( + "a heading (a shape's own draw:text-box has no text:h reading path)", + ); + } + const canonicalId = planListMembership(block.list, listState); + paragraphs.push( + canonicalId === undefined + ? { ...block, list: undefined } + : { ...block, list: { numId: canonicalId, level: block.list!.level } }, + ); + } + return { kind: "text", paragraphs }; +} + +// The mutable state one shape-writing walk threads: the automatic-style registry every formatting decision interns through (shared with whatever else the caller's own writer is minting styles for), the counters that mint document-unique names (an image's own part path, a nested table's own table:name, a text-box list's own list-style), and the bullet/ordered list-style cache -- one text:list-style per kind, minted on first use, matching odt/write.ts's own OdtWriteState.listStyleByKind. +export interface DrawShapeWriteState { + readonly pkg: Package; + readonly registry: StyleRegistry; + // The container a minted text:list-style (below) is appended to -- content.xml's own office:automatic-styles, the same container the caller's own StyleRegistry interns paragraph/text/graphic styles into, so every automatic style a shape writer mints lands in one place. + readonly contentAutomaticStyles: XmlElement; + nextImage: number; + nextTable: number; + nextListStyle: number; + readonly listStyleByKind: Map<"ordered" | "bullet", string>; +} + +export function createDrawShapeWriteState( + pkg: Package, + registry: StyleRegistry, + contentAutomaticStyles: XmlElement, +): DrawShapeWriteState { + return { + pkg, + registry, + contentAutomaticStyles, + nextImage: 1, + nextTable: 1, + nextListStyle: 1, + listStyleByKind: new Map(), + }; +} + +// Mints (or reuses) one text:list-style per kind -- a document with fifty bullet lists across its slides needs one bullet list-style, not fifty identical ones, matching typed/odt/write.ts's own listStyleNameFor exactly. +function listStyleNameFor( + kind: "ordered" | "bullet", + state: DrawShapeWriteState, +): string { + const existing = state.listStyleByKind.get(kind); + if (existing !== undefined) { + return existing; + } + const name = `SL${state.nextListStyle}`; + state.nextListStyle += 1; + state.listStyleByKind.set(kind, name); + state.contentAutomaticStyles.children.push(buildOdfListStyle(name, kind)); + return name; +} + +// --- geometry: Box + rotationDeg -> either plain svg:x/y/width/height, or svg:width/height + draw:transform ---------- +// +// The exact algebraic inverse of typed/shared/transform.ts's own resolveOdfShapeGeometry, derived (not guessed) from that module's own documented composition rule: a rotated frame is written as draw:transform="rotate() translate( )", the SAME two-function, rotate-then-translate shape that module's own top-of-file note verifies empirically against real LibreOffice output. Solving resolveOdfShapeGeometry's own center/rotationDeg formulas for the translate() offset that reproduces a GIVEN frame+rotationDeg (rather than re-deriving the composition rule itself, which transform.ts already establishes) gives: angleRad = -rotationDeg * PI / 180 (netRotationDeg's own inverse) tx = frame.xPt + W/2 - (W/2)*cos(angleRad) - (H/2)*sin(angleRad) ty = frame.yPt + H/2 - (H/2)*cos(angleRad) + (W/2)*sin(angleRad) Verified algebraically against resolveOdfShapeGeometry's own center computation and pinned by this module's own round-trip test suite (write-shapes.test.ts), which reads written output back through resolveOdfShapeGeometry directly. A rotated round trip is exact up to ordinary IEEE-754 floating-point rounding (two trig evaluations, not a lossy approximation), which is why rotationDeg===0 is treated identically to rotationDeg===undefined below: resolveOdfShapeGeometry's own read side already collapses a net rotation of exactly zero to undefined (see its own "rotationDeg === 0 ? undefined : rotationDeg"), so writing a rotate(0) transform for a literal 0 input would round-trip to undefined and silently fail a strict equality check -- treating the two alike here is this writer's OWN half of that same collapse, not a new approximation. +function frameGeometryAttrs( + frame: Box, + rotationDeg: number | undefined, +): Record { + if (rotationDeg === undefined || rotationDeg === 0) { + return { + "svg:x": formatOdfLength(frame.xPt), + "svg:y": formatOdfLength(frame.yPt), + "svg:width": formatOdfLength(frame.widthPt), + "svg:height": formatOdfLength(frame.heightPt), + }; + } + const angleRad = (-rotationDeg * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const halfWidthPt = frame.widthPt / 2; + const halfHeightPt = frame.heightPt / 2; + const txPt = frame.xPt + halfWidthPt - halfWidthPt * cos - halfHeightPt * sin; + const tyPt = + frame.yPt + halfHeightPt - halfHeightPt * cos + halfWidthPt * sin; + return { + "svg:width": formatOdfLength(frame.widthPt), + "svg:height": formatOdfLength(frame.heightPt), + "draw:transform": `rotate(${angleRad}) translate(${formatOdfLength(txPt)} ${formatOdfLength(tyPt)})`, + }; +} + +// --- insets: fo:padding-* on a graphic-family automatic style ----------------------------------------------------- +// +// A dimensional/decorative property styles/properties.ts deliberately does not model (see that module's own top-of-file note), so this reaches it through StyleRegistry's own propertyElements seam -- the identical pattern typed/shared/table.ts already uses for a cell's fill/border/column-width/row-height (see that module's own top-of-file note on the seam itself). Written only when at least one inset is non-zero: a shape with no draw:style-name at all reads back with every inset at ZERO_INSETS regardless (typed/draw/shapes.ts's own readFrameInsets), so an all-zero shape needs no style minted for a fact the reader already defaults to. +function shapeGraphicStyleName( + shape: { + readonly insetLeftPt: number; + readonly insetTopPt: number; + readonly insetRightPt: number; + readonly insetBottomPt: number; + }, + state: DrawShapeWriteState, +): string | undefined { + if ( + shape.insetLeftPt === 0 && + shape.insetTopPt === 0 && + shape.insetRightPt === 0 && + shape.insetBottomPt === 0 + ) { + return undefined; + } + return state.registry.intern({ + properties: {}, + family: "graphic", + propertyElements: [ + el("style:graphic-properties", { + "fo:padding-left": formatOdfLength(shape.insetLeftPt), + "fo:padding-top": formatOdfLength(shape.insetTopPt), + "fo:padding-right": formatOdfLength(shape.insetRightPt), + "fo:padding-bottom": formatOdfLength(shape.insetBottomPt), + }), + ], + }); +} + +// --- content: the three draw:frame bodies planShapeContent above can produce ---------------------------------------- + +// draw:text-box's own (text:p | text:list)* content model: every paragraph writeOdfParagraph produces, with consecutive paragraphs sharing one list membership grouped into a single text:list (nested per level via typed/shared/list.ts's own writeOdfList) -- the exact mirror of typed/odt/write.ts's own writeSectionBlocks list-tracking, simplified since a shape's own text content never interleaves a table or an anchored image inside its paragraph flow (planShapeContent above has already refused any block combination that would need to). +function writeShapeTextBox( + paragraphs: readonly ContentParagraph[], + state: DrawShapeWriteState, +): XmlElement { + const out: XmlNode[] = []; + let openList: + { numId: string; entries: OdfListEntry[]; element: XmlElement } | undefined; + + const closeList = (): void => { + if (openList === undefined) { + return; + } + const kind = listKindOf(openList.numId); + const built = writeOdfList( + openList.entries, + kind === undefined ? undefined : listStyleNameFor(kind, state), + ); + openList.element.attributes = built.attributes; + openList.element.children = built.children; + openList = undefined; + }; + + for (const paragraph of paragraphs) { + const element = writeOdfParagraph(paragraph, state.registry); + const membership = paragraph.list; + // planShapeContent has already canonicalised every membership it kept to carry a real numId (never a bare {level}), so this guard is equivalent to `membership === undefined` at runtime -- phrased via optional chaining, matching typed/odt/write.ts's own writeSectionBlocks, so TypeScript narrows membership.numId to a plain string for the rest of this iteration rather than needing a non-null assertion below. + if (membership?.numId === undefined) { + closeList(); + out.push(element); + continue; + } + if (openList !== undefined && openList.numId !== membership.numId) { + closeList(); + } + if (openList === undefined) { + const listElement = el("text:list"); + openList = { numId: membership.numId, entries: [], element: listElement }; + out.push(listElement); + } + openList.entries.push({ level: membership.level, element }); + } + closeList(); + return el("draw:text-box", {}, out); +} + +const PICTURES_DIRECTORY = "Pictures"; + +// draw:image, a direct child of the frame -- the exact mirror of typed/odt/write.ts's own writeImageFrame, minus the as-char anchor attributes that call is odt-specific: this frame carries real svg:x/y/width/height (or draw:transform) of its own, written by writeDrawFrame below, not the character-flow positioning an inline odt image uses. +function writeShapeImage( + image: ContentImageBlock, + state: DrawShapeWriteState, +): XmlNode[] { + const extension = image.format === "png" ? "png" : "jpg"; + const path = `${PICTURES_DIRECTORY}/image${state.nextImage}.${extension}`; + state.nextImage += 1; + state.pkg.parts[path] = { kind: "binary", base64: image.base64 }; + const children: XmlNode[] = [ + el("draw:image", { + "xlink:href": encodeXmlText(path), + "xlink:type": "simple", + "xlink:show": "embed", + "xlink:actuate": "onLoad", + }), + ]; + if (image.altText !== undefined) { + children.push(el("svg:title", {}, [txt(encodeXmlText(image.altText))])); + } + return children; +} + +// --- the shape writer ----------------------------------------------------------------------------------------------- + +// One ContentShape -> the draw:frame element typed/draw/shapes.ts's own readDrawFrame reads back: geometry (svg:x/y/width/height, or draw:transform when rotated), an interned graphic-family style carrying the shape's own text insets (when non-zero), and exactly one of table:table/draw:text-box/draw:image as decided by planShapeContent. `listState` is the caller's own ListPlanState (typed/shared/list.ts) -- see planShapeContent's own note on why this module never decides its own threading policy. +export function writeDrawFrame( + shape: ContentShape, + listState: ListPlanState, + state: DrawShapeWriteState, +): XmlElement { + const attributes: Record = { + ...frameGeometryAttrs(shape.frame, shape.rotationDeg), + }; + if (shape.name !== undefined) { + attributes["draw:name"] = encodeXmlText(shape.name); + } + const styleName = shapeGraphicStyleName(shape, state); + if (styleName !== undefined) { + attributes["draw:style-name"] = encodeXmlText(styleName); + } + + const content = planShapeContent(shape.blocks, listState); + const children: XmlNode[] = + content.kind === "table" + ? [ + writeOdfTable( + content.table, + state.registry, + `DrawTable${state.nextTable++}`, + ), + ] + : content.kind === "image" + ? writeShapeImage(content.image, state) + : [writeShapeTextBox(content.paragraphs, state)]; + + return el("draw:frame", attributes, children); +} + +// Writes a whole page's (or slide's) own shapes in document order -- the convenience wrapper typed/odp/write.ts calls per slide and a future odg writer will call per drawing page, matching typed/draw/shapes.ts's own readDrawPageContent as the shared entry point on the read side. Vector primitives (draw:rect/ellipse/line/path/polygon/polyline/custom-shape) are NOT handled here: ContentShape carries none of them (that is ContentVector's own vocabulary, a ContentDrawPage-only concept per document-schema.js's own drawing content model), so a future odg writer producing those will do so alongside this function's own output, not through it. +export function writeDrawShapes( + shapes: readonly ContentShape[], + listState: ListPlanState, + state: DrawShapeWriteState, +): XmlElement[] { + return shapes.map((shape) => writeDrawFrame(shape, listState, state)); +} From f62540425e6cc6c8ab08bb9b6f634223f9fd0b24 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 22:39:26 +0100 Subject: [PATCH 03/15] feat(odf.js): add writeOdp/writeOdpContent, a real .odp writer The inverse of typed/odp/read.ts, and this package's third content writer (typed/odt/write.ts's own top-of-file note states the shared discipline every writer in this family follows). writeOdpContent takes the flat 'presentation' ContentDocument readOdpContent returns and produces a real .odp Package; writeOdp does the same from the DocumentTree readOdp returns, via flattenTree. A presentation has no office:text body flow at all -- one style:master-page/ style:page-layout pair is minted per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level p:sldSz), and each draw:page's own shapes are written by typed/draw/write-shapes.ts's writeDrawShapes. Speaker notes write as presentation:notes, one text:p per line, referencing a page-layout of their own (minted lazily, the first time any slide actually has notes) -- real LibreOffice output always states one, since a notes page is sized for printing independent of its slide's own on-screen size. normaliseOdpContent states the canonical form a written-and-reread document equals, including the one fact ODF forces rather than this writer choosing it: an image's own widthPt/heightPt become its enclosing shape's own frame size, since a draw:image has no size of its own inside a draw:frame. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception writeOdt makes. .odg and the .sxi OpenOffice.org 1.x wrapper are not covered here -- both are separate, tracked follow-up work built on top of what this module and typed/draw/write-shapes.ts establish. --- packages/odf.js/src/index.ts | 20 ++ packages/odf.js/src/typed/odp/write.ts | 323 +++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/odf.js/src/typed/odp/write.ts diff --git a/packages/odf.js/src/index.ts b/packages/odf.js/src/index.ts index 831b03062..0d64bf35f 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -242,6 +242,18 @@ 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. +export { + createDrawShapeWriteState, + planShapeContent, + writeDrawFrame, + writeDrawShapes, +} from "./typed/draw/write-shapes"; +export type { + DrawShapeWriteState, + ShapeContentPlan, +} from "./typed/draw/write-shapes"; + export { readDrawObjectReference } from "./typed/draw/embedded"; export type { EmbeddedDrawObject, @@ -254,6 +266,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, diff --git a/packages/odf.js/src/typed/odp/write.ts b/packages/odf.js/src/typed/odp/write.ts new file mode 100644 index 000000000..32680563e --- /dev/null +++ b/packages/odf.js/src/typed/odp/write.ts @@ -0,0 +1,323 @@ +import type { + Box, + ContentBlock, + ContentDocument, + ContentShape, + ContentSlide, + DocumentTree, + LayoutMetadata, + PageSize, +} from "document-schema.js"; +import { flattenTree, PAGE_SIZE_A4 } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement, XmlNode } from "../../model/node"; +import { ODF_MEDIA_TYPES } from "../../media-type"; +import { syncManifest } from "../../manifest"; +import { + createOdfPackage, + odfPartContainer, + DEFAULT_ODF_VERSION, +} from "../../package-io/scaffold"; +import { StyleRegistry } from "../../styles/registry"; +import { el } from "../../xml/fragment"; +import { encodeXmlText } from "../../xml/entities"; +import { formatOdfLength } from "../shared/units"; +import { writeOdfMetadata } from "../shared/metadata"; +import { buildOdfInlineNodes, segmentOdfText } from "../shared/text"; +import type { ListPlanState } from "../shared/list"; +import { + canonicalImage, + canonicalParagraph, + canonicalTable, +} from "../shared/canonicalise"; +import { + createDrawShapeWriteState, + planShapeContent, + writeDrawShapes, +} from "../draw/write-shapes"; + +// ContentDocument (the 'presentation' arm) -> a real .odp Package: the inverse of typed/odp/read.ts, and this package's third content WRITER, following the same discipline typed/odt/write.ts's own top-of-file note states in full (read that file first -- this one restates only what differs). Every mapping below is stated as the exact inverse of the corresponding read in typed/odp/read.ts, and the correctness property this writer is held to is the same one every writer in this family is held to: its own package reads back as the document it was given (see normaliseOdpContent below for the one canonical form that equality is stated against, and write.test.ts / write-round-trip.test.ts for both halves). +// +// WHAT'S GENUINELY NEW HERE, beyond odt's own three forced facts (no direct formatting, no standalone page break, whitespace as structure -- all three still apply, inherited via typed/shared/canonicalise.ts and typed/draw/write-shapes.ts): a PRESENTATION has no office:text body flow at all -- its content is a sequence of draw:page elements, each carrying POSITIONED shapes (draw:frame, geometry-first) rather than flowed blocks, plus its own presentation:notes and its own page geometry (a presentation genuinely allows different slides to reference different master pages/page-layouts, unlike OOXML's single document-level p:sldSz -- see typed/odp/read.ts's own readSlideSize note). This module's own job is therefore: one style:master-page + style:page-layout pair per slide (mirroring odt's one per section, never deduplicated across slides for the same reason odt never deduplicates across sections), a draw:page wrapping that slide's own shapes (delegated entirely to typed/draw/write-shapes.ts's writeDrawShapes -- the shared shape writer this format's own write path exists to prove out for a later odg writer), and a presentation:notes built from ContentSlide.notes. +// +// WHAT THIS WRITER DOES NOT WRITE, and why it refuses rather than dropping: every shape/paragraph-level fidelity constraint typed/draw/write-shapes.ts's own planShapeContent already refuses by name (a run-level construct extent, an embedded object, a construct boundary marker, a heading inside a shape's own text, a page break inside a shape's own text, a table or image mixed with other shape content) applies here unchanged, since this module calls that shared validation rather than re-deriving it. Beyond that: a slide's own `source` residue (the transition/animation/sound facts typed/odp/read.ts quarantines) is dropped, the same deliberate exception odt's own residue channel makes -- residue is opaque by construction, so re-emitting it would be actively wrong rather than merely incomplete. `.odg` (drawings) and `.sxi` (the OpenOffice.org 1.x presentation format, which needs this writer to invert its own transform against) are NOT covered by this module -- both are separate, tracked follow-up work built on top of what this module and typed/draw/write-shapes.ts establish. + +const CONTENT_PART = "content.xml"; +const STYLES_PART = "styles.xml"; + +export interface OdpWriteOptions { + // The ODF version stamped on each part's office:version and on the manifest. Defaults to the current standard. + readonly version?: string; +} + +// --- the canonical form: what reading this writer's own output back produces ---------------------------------------- +// +// Metadata, per-shape content, and a nested table/image are all canonicalised through typed/shared/canonicalise.ts, the exact same statement typed/odt/write.ts's own normaliseOdtContent already makes for those pieces -- restated here only for what genuinely differs at the presentation level. +// +// ONE THING THIS CANONICAL FORM DELIBERATELY DOES NOT STATE, and cannot: a ROTATED shape's own frame/rotationDeg survive a write-then-read round trip only up to ordinary IEEE-754 floating-point rounding (typed/draw/write-shapes.ts's own frameGeometryAttrs is an exact algebraic inverse of typed/shared/transform.ts's resolveOdfShapeGeometry, not an approximation, but two independent trig evaluations on each side of the round trip are not guaranteed bit-identical). canonicalShape below passes a shape's own frame/rotationDeg through VERBATIM rather than attempting to predict the exact float a real round trip will produce -- write-round-trip.test.ts's own rotated-shape cases compare geometry with an explicit numeric tolerance instead of the blanket structural-equality helper every other case uses, and this canonicaliser is what they run that comparison against on both sides. +function canonicalMetadata(metadata: LayoutMetadata): LayoutMetadata { + const canonical: LayoutMetadata = {}; + if (metadata.title !== undefined) { + canonical.title = metadata.title; + } + if (metadata.author !== undefined) { + canonical.author = metadata.author; + } + if (metadata.subject !== undefined) { + canonical.subject = metadata.subject; + } + if (metadata.keywords !== undefined && metadata.keywords.length > 0) { + canonical.keywords = [...metadata.keywords]; + } + if (metadata.creator !== undefined) { + canonical.creator = metadata.creator; + } + if (metadata.createdIso !== undefined) { + canonical.createdIso = metadata.createdIso; + } + if (metadata.modifiedIso !== undefined) { + canonical.modifiedIso = metadata.modifiedIso; + } + return canonical; +} + +// One ContentShape in the exact shape reading the written document back produces: geometry/insets/name pass through verbatim (see this module's own top-of-file note on rotationDeg's floating-point caveat specifically), and `blocks` is rebuilt from whichever of the three content kinds planShapeContent (typed/draw/write-shapes.ts) resolves the INPUT's own blocks to -- the identical validation and list-numId canonicalisation the writer itself runs, so this function and writeDrawFrame can never disagree about which shapes are writable at all. +// +// THE ONE FORCED FACT THIS FUNCTION RESTATES RATHER THAN PASSING THROUGH: an image's own widthPt/heightPt become the ENCLOSING SHAPE's frame widthPt/heightPt, never the input image block's own values. ODF's draw:image has no size of its own at all -- it is a bare content reference inside a draw:frame, and the frame's own svg:width/svg:height IS the rendered size (typed/draw/shapes.ts's own readDrawImageBlock note: "The image renders at the FRAME's own resolved size, not the source image's native pixel dimensions"). A caller-supplied image block whose width/height genuinely differ from its enclosing shape's frame is therefore not a smaller round trip, it is describing something ODF cannot express -- the frame wins, silently overriding the block's own stated size, exactly as reading the written document back will. +function canonicalShape( + shape: ContentShape, + listState: ListPlanState, +): ContentShape { + const content = planShapeContent(shape.blocks, listState); + const blocks: ContentBlock[] = + content.kind === "table" + ? [canonicalTable(content.table)] + : content.kind === "image" + ? [ + { + ...canonicalImage(content.image), + widthPt: shape.frame.widthPt, + heightPt: shape.frame.heightPt, + }, + ] + : content.paragraphs.map((paragraph) => + canonicalParagraph(paragraph, paragraph.list?.numId), + ); + const canonical: ContentShape = { + frame: shape.frame, + insetLeftPt: shape.insetLeftPt, + insetTopPt: shape.insetTopPt, + insetRightPt: shape.insetRightPt, + insetBottomPt: shape.insetBottomPt, + blocks, + }; + if (shape.name !== undefined) { + canonical.name = shape.name; + } + // rotationDeg === 0 collapses to absent, the same collapse writeDrawFrame's own frameGeometryAttrs applies on write (see typed/draw/write-shapes.ts's own note: resolveOdfShapeGeometry's read side already treats a net rotation of exactly zero as undefined). + if (shape.rotationDeg !== undefined && shape.rotationDeg !== 0) { + canonical.rotationDeg = shape.rotationDeg; + } + return canonical; +} + +function canonicalSlide( + slide: ContentSlide, + listState: ListPlanState, +): ContentSlide { + return { + size: slide.size, + shapes: slide.shapes.map((shape) => canonicalShape(shape, listState)), + notes: slide.notes, + }; +} + +// The return type is the presentation arm specifically rather than the whole ContentDocument union: this function accepts any document so it can refuse a wrong-kind one by name, but it only ever RETURNS a presentation one, sparing every caller a re-narrowing step over a fact that is already settled -- matching normaliseOdtContent's own convention. +export function normaliseOdpContent( + document: ContentDocument, +): Extract { + if (document.kind !== "presentation") { + throw new Error( + `normaliseOdpContent: expected a 'presentation' document, got '${document.kind}'`, + ); + } + const listState: ListPlanState = { next: 1 }; + return { + kind: "presentation", + metadata: canonicalMetadata(document.metadata), + slides: document.slides.map((slide) => canonicalSlide(slide, listState)), + }; +} + +// --- the writer ----------------------------------------------------------------------------------------------------- + +// One slide's own style:page-layout, mirroring typed/odt/write.ts's own pageLayoutElement minus margins -- ContentSlide carries no margins concept at all (a presentation's own shapes are positioned absolutely, never flowed inside a margin box the way an odt paragraph is). +function slidePageLayoutElement(name: string, pageSize: PageSize): XmlElement { + return el("style:page-layout", { "style:name": encodeXmlText(name) }, [ + el("style:page-layout-properties", { + "fo:page-width": formatOdfLength(pageSize.widthPt), + "fo:page-height": formatOdfLength(pageSize.heightPt), + "style:print-orientation": + pageSize.widthPt > pageSize.heightPt ? "landscape" : "portrait", + }), + ]); +} + +// The speaker-notes text frame's own placeholder position/size. readSlideNotes (typed/odp/read.ts) never inspects this frame's own geometry at all -- it deep-searches for text:p anywhere under presentation:notes regardless of position -- so this geometry has no bearing on round-trip correctness through this package's own reader; it exists purely to keep the written frame valid, real-consumer-renderable ODF (verified against real LibreOffice -- see the package README's own LibreOffice-verification section), sized as a typical notes-page text placeholder occupying the lower half of an A4-portrait notes page. A real LibreOffice-authored notes page positions its own text box against a SEPARATE notes-page master/layout this writer does not model, since ContentSlide carries no notes-page geometry of its own to write. +const NOTES_FRAME_BOX: Box = { xPt: 42, yPt: 320, widthPt: 500, heightPt: 260 }; + +// presentation:notes carries its own style:page-layout-name, matching every real LibreOffice-produced notes page (confirmed against real LibreOffice 26.2 output: a notes page is sized for PRINTING and always references a page-layout of its own, independent of whatever on-screen size each slide's own page-layout states -- so there is exactly one notes geometry for the whole presentation, not one per slide). notesPageLayoutState mints it lazily, the first time any slide actually has notes to write, so a presentation with no speaker notes at all never carries an unused page-layout. +// +// A KNOWN, NAMED GAP rather than a silent one: this writer's presentation:notes is well-formed per the OASIS schema and parses through real LibreOffice with no error and no data loss (soffice --headless --convert-to fodp preserves the notes TEXT byte-for-byte -- see the package README's own LibreOffice-verification section for the exact commands and output) -- but LibreOffice's own AutoLayout placeholder-matching does not bind this writer's minimal presentation:notes/draw:frame to its internal Notes view the way a placeholder frame carrying LibreOffice's own internal presentation-page-layout machinery would; instead it re-homes the frame's content onto the slide's own visible shape list on import, alongside a separately synthesised, empty notes placeholder of LibreOffice's own. Reproduced across several attempted fixes (style:page-layout-name alone, presentation:class="notes", presentation:placeholder="true", a minted presentation-family style referenced by presentation:style-name under both a generic and a master-page-matching name, an explicit draw:layer-set with draw:layer="backgroundobjects", and moving the element earlier in draw:page's own child order) -- none, alone, changed the outcome, and LibreOffice's own placeholder-binding heuristic for AutoLayout slides is undocumented in the OASIS schema itself, so further narrowing needs either a primary LibreOffice source-level investigation or a real Impress-authored notes-page fixture to diff against byte-for-byte, both out of scope for this PR. ContentSlide.notes carries no placeholder-kind information for a future fix to model against, either, so this is tracked as a follow-up rather than attempted further here. +interface NotesPageLayoutState { + name: string | undefined; +} + +function notesPageLayoutName( + state: NotesPageLayoutState, + stylesAutomaticStyles: XmlElement, +): string { + if (state.name !== undefined) { + return state.name; + } + const name = "PM0"; + state.name = name; + stylesAutomaticStyles.children.push( + el("style:page-layout", { "style:name": name }, [ + el("style:page-layout-properties", { + "fo:page-width": formatOdfLength(PAGE_SIZE_A4.widthPt), + "fo:page-height": formatOdfLength(PAGE_SIZE_A4.heightPt), + "style:print-orientation": "portrait", + }), + ]), + ); + return name; +} + +// presentation:notes -> a draw:frame > draw:text-box carrying one text:p per line of ContentSlide.notes, mirroring the "typically" structure typed/odp/read.ts's own readSlideNotes documents real LibreOffice output taking. Undefined for empty notes: readSlideNotes already returns "" for a draw:page with no presentation:notes element at all, so an empty string needs no element written to round-trip. Splitting on "\n" rather than writing one text:line-break-carrying paragraph is a free choice, not a forced one -- readSlideNotes's own decodeOdfText already converts an EMBEDDED text:line-break to "\n" exactly as it converts a paragraph boundary to "\n" via its own join, so either representation reads back identical; one text:p per line is what real Impress output actually looks like. +function writeSlideNotes( + notes: string, + state: NotesPageLayoutState, + stylesAutomaticStyles: XmlElement, +): XmlElement | undefined { + if (notes.length === 0) { + return undefined; + } + const paragraphs = notes + .split("\n") + .map((line) => + el("text:p", {}, buildOdfInlineNodes(segmentOdfText(line, true, true))), + ); + const frame = el( + "draw:frame", + { + "presentation:class": "notes", + "svg:x": formatOdfLength(NOTES_FRAME_BOX.xPt), + "svg:y": formatOdfLength(NOTES_FRAME_BOX.yPt), + "svg:width": formatOdfLength(NOTES_FRAME_BOX.widthPt), + "svg:height": formatOdfLength(NOTES_FRAME_BOX.heightPt), + }, + [el("draw:text-box", {}, paragraphs)], + ); + return el( + "presentation:notes", + { + "style:page-layout-name": notesPageLayoutName( + state, + stylesAutomaticStyles, + ), + }, + [frame], + ); +} + +// Package assembly. The order matters in one place only, matching writeOdtContent's own note: the style registry is constructed over content.xml AFTER the package skeleton exists and BEFORE any shape is written, since interning appends to the very office:automatic-styles container the skeleton created. +export function writeOdpContent( + document: ContentDocument, + options: OdpWriteOptions = {}, +): Package { + if (document.kind !== "presentation") { + throw new Error( + `writeOdpContent: expected a 'presentation' document, got '${document.kind}' -- odf.js writes .odp from the presentation arm only`, + ); + } + const version = options.version ?? DEFAULT_ODF_VERSION; + const presentationElement = el("office:presentation"); + const pkg = createOdfPackage( + ODF_MEDIA_TYPES.odp, + presentationElement, + version, + ); + + const registry = StyleRegistry.forPart(pkg, CONTENT_PART, { + otherPart: { pkg, partPath: STYLES_PART }, + }); + const contentAutomaticStyles = odfPartContainer( + pkg, + CONTENT_PART, + "office:automatic-styles", + ); + const stylesAutomaticStyles = odfPartContainer( + pkg, + STYLES_PART, + "office:automatic-styles", + ); + const masterStyles = odfPartContainer( + pkg, + STYLES_PART, + "office:master-styles", + ); + + const shapeState = createDrawShapeWriteState( + pkg, + registry, + contentAutomaticStyles, + ); + // One counter across the WHOLE presentation, matching readOdpContent's own listIdState threading (typed/odp/read.ts) -- two lists on different slides must mint different identities exactly as two lists in different sections of one odt body do. + const listState: ListPlanState = { next: 1 }; + const notesPageLayout: NotesPageLayoutState = { name: undefined }; + + document.slides.forEach((slide, index) => { + const masterPageName = `MP${index + 1}`; + const pageLayoutName = `PM${index + 1}`; + stylesAutomaticStyles.children.push( + slidePageLayoutElement(pageLayoutName, slide.size), + ); + masterStyles.children.push( + el("style:master-page", { + "style:name": encodeXmlText(masterPageName), + "style:page-layout-name": encodeXmlText(pageLayoutName), + }), + ); + + const shapeElements = writeDrawShapes(slide.shapes, listState, shapeState); + const notesElement = writeSlideNotes( + slide.notes, + notesPageLayout, + stylesAutomaticStyles, + ); + const children: XmlNode[] = [...shapeElements]; + if (notesElement !== undefined) { + children.push(notesElement); + } + presentationElement.children.push( + el( + "draw:page", + { "draw:master-page-name": encodeXmlText(masterPageName) }, + children, + ), + ); + }); + + writeOdfMetadata(pkg, document.metadata, version); + syncManifest(pkg, { version }); + return pkg; +} + +// DocumentTree -> a real .odp Package: this module's PRIMARY entry point, and the exact mirror of writeOdt's own relationship to writeOdtContent. The tree is flattened through document-schema.js's own flattenTree -- the inverse of the assembleTree readOdp calls -- so a tree read from one .odp and written back out crosses the package boundary exactly once in each direction, with every style ref resolved on the way out. +export function writeOdp( + document: DocumentTree, + options: OdpWriteOptions = {}, +): Package { + return writeOdpContent(flattenTree(document), options); +} From 70d60e6fa57435baef4a890d1a7f3c716cb79b41 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 22:39:42 +0100 Subject: [PATCH 04/15] test(odf.js): verify writeOdp's XML shapes and its own round-trip law write.test.ts pins the construct-by-construct XML writeOdpContent actually emits (package structure and media type, one style:master-page/page-layout per slide, unrotated vs rotated shape geometry, the graphic-family inset style, text/table/image shape content dispatch, list grouping, and speaker notes) -- the same role typed/odt/write.test.ts plays for writeOdtContent, proving the output is the ODF a real consumer expects rather than merely something this package's own reader happens to agree with. write-round-trip.test.ts states the law itself: normaliseOdpContent(readOdpContent(writeOdpContent(document))) equals normaliseOdpContent(document), covering metadata, multi-slide/multi-page-size documents, formatted runs and whitespace, bullet/ordered lists (including two shapes sharing one raw numId without merging their runs), a table and an image each as a shape's sole content, multiple shapes with insets, multi-line notes, residue dropping, and refusals (a page break, a mixed table, a heading). Rotated-shape geometry is checked separately with an explicit numeric tolerance rather than the blanket equality helper every other case uses, since two independent trig evaluations on either side of a real write- then-read round trip are not guaranteed bit-identical -- typed/draw/ write-shapes.ts's own frameGeometryAttrs is an exact algebraic inverse, not an approximation, but ordinary IEEE-754 rounding still applies to a real round trip. This suite is what caught the one genuine writer bug fixed in the previous commit: an image's own widthPt/heightPt, left to pass through verbatim in the canonical form, must instead become the enclosing shape's own frame size, since ODF's draw:image carries no size of its own at all. --- .../src/typed/odp/write-round-trip.test.ts | 370 +++++++++++++++ packages/odf.js/src/typed/odp/write.test.ts | 437 ++++++++++++++++++ 2 files changed, 807 insertions(+) create mode 100644 packages/odf.js/src/typed/odp/write-round-trip.test.ts create mode 100644 packages/odf.js/src/typed/odp/write.test.ts diff --git a/packages/odf.js/src/typed/odp/write-round-trip.test.ts b/packages/odf.js/src/typed/odp/write-round-trip.test.ts new file mode 100644 index 000000000..f044440b7 --- /dev/null +++ b/packages/odf.js/src/typed/odp/write-round-trip.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentDocument, + ContentShape, + ContentSlide, +} from "document-schema.js"; +import { PAGE_SIZE_A4, SLIDE_SIZE_WIDESCREEN } from "document-schema.js"; +import type { Package } from "../../model/package"; +import { decodePackage, encodePackage } from "../../codec"; +import { readOdpContent } from "./read"; +import { normaliseOdpContent, writeOdpContent } from "./write"; + +// The write side's correctness suite: what writeOdpContent produces reads back as the document it was given. The sibling suite (write.test.ts) pins the XML shapes; this one states the law and every deviation from it by name -- the presentation mirror of typed/odt/write-round-trip.test.ts (that file's own top-of-file note states the law in full). +// +// THE LAW: normaliseOdpContent(readOdpContent(writeOdpContent(document))) equals normaliseOdpContent(document), for every document the writer accepts. The normalisation is applied to BOTH sides, so it is a genuine equivalence rather than a licence to discard whatever the writer happened to lose. +// +// THE ONE DELIBERATE EXCEPTION: a ROTATED shape's own frame/rotationDeg is compared with an explicit numeric tolerance, not the blanket structural-equality helper every other case uses -- typed/draw/write-shapes.ts's own frameGeometryAttrs is an exact algebraic inverse of the reader's resolveOdfShapeGeometry, but two independent trig evaluations on either side of a real write-then-read round trip are not guaranteed bit-identical (see that module's own top-of-file note and typed/odp/write.ts's own canonicalShape note). + +type PresentationDocument = Extract; + +// A 1x1 PNG, genuinely decodable (sniffImageFormat reads real magic bytes). +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function contentOf(pkg: Package): PresentationDocument { + const { metadata, slides } = readOdpContent(pkg); + return { kind: "presentation", metadata, slides }; +} + +// One full pass through the writer and back: the document the caller handed in, written to a real package, encoded to real bytes, decoded again, and read -- the bytes leg deliberately in the loop, matching typed/odt/write-round-trip.test.ts's own roundTrip. +function roundTrip(document: ContentDocument): PresentationDocument { + return contentOf(decodePackage(encodePackage(writeOdpContent(document)))); +} + +function expectRoundTrip(document: ContentDocument): void { + expect(normaliseOdpContent(roundTrip(document))).toEqual( + normaliseOdpContent(document), + ); +} + +function shape( + overrides: Partial = {}, + blocks: ContentShape["blocks"] = [ + { kind: "paragraph", runs: [{ text: "Body" }] }, + ], +): ContentShape { + return { + frame: { xPt: 36, yPt: 48, widthPt: 400, heightPt: 120 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks, + ...overrides, + }; +} + +function slide(shapes: ContentShape[], notes = ""): ContentSlide { + return { size: SLIDE_SIZE_WIDESCREEN, shapes, notes }; +} + +function documentOf(slides: ContentSlide[]): PresentationDocument { + return { kind: "presentation", metadata: {}, slides }; +} + +describe("writeOdpContent: the round-trip law", () => { + it("round-trips metadata", () => { + expectRoundTrip({ + kind: "presentation", + metadata: { + title: "Round trip", + author: "odf.js", + subject: "The odp write path", + keywords: ["odf", "presentation"], + creator: "odf.js test suite", + createdIso: "2026-09-03T10:00:00Z", + modifiedIso: "2026-09-03T11:00:00Z", + }, + slides: [slide([shape()])], + }); + }); + + it("round-trips multiple slides, each with its own page size", () => { + expectRoundTrip( + documentOf([ + { size: SLIDE_SIZE_WIDESCREEN, shapes: [shape()], notes: "" }, + { size: PAGE_SIZE_A4, shapes: [shape()], notes: "" }, + ]), + ); + }); + + it("round-trips a shape with formatted runs, whitespace, and a hyperlink", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({}, [ + { + kind: "paragraph", + alignment: "center", + runs: [ + { text: "Plain, " }, + { text: "bold", bold: true }, + { text: ", " }, + { + text: "italic", + italic: true, + sizePt: 18, + fontFamily: "Liberation Sans", + }, + { text: " and " }, + { + text: "a link", + underline: true, + hyperlink: "https://example.invalid/?a=1&b=2", + }, + { text: "." }, + ], + }, + { + kind: "paragraph", + runs: [ + { text: " leading, three inner, a\ttab and a\nbreak. " }, + ], + }, + ]), + ]), + ]), + ); + }); + + it("round-trips a bullet list and an ordered list in the same shape as two separate runs", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({}, [ + { + kind: "paragraph", + runs: [{ text: "bullet one" }], + list: { numId: "bullet:a", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "bullet two, nested" }], + list: { numId: "bullet:a", level: 1 }, + }, + { kind: "paragraph", runs: [{ text: "not a list item" }] }, + { + kind: "paragraph", + runs: [{ text: "ordered one" }], + list: { numId: "ordered:b", level: 0 }, + }, + ]), + ]), + ]), + ); + }); + + it("round-trips list membership across two different shapes without merging their runs, even with the same raw numId", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({ frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 100 } }, [ + { + kind: "paragraph", + runs: [{ text: "shape one" }], + list: { numId: "bullet:shared", level: 0 }, + }, + ]), + shape({ frame: { xPt: 220, yPt: 0, widthPt: 200, heightPt: 100 } }, [ + { + kind: "paragraph", + runs: [{ text: "shape two" }], + list: { numId: "bullet:shared", level: 0 }, + }, + ]), + ]), + ]), + ); + }); + + it("round-trips a shape carrying a table as its sole content", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({}, [ + { + kind: "table", + columnWidthsPt: [120, 120], + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "A1" }] }], + }, + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "B1", bold: true }], + }, + ], + background: { r: 0.9, g: 0.9, b: 0.9 }, + }, + ], + }, + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "A2" }] }], + colSpan: 2, + }, + { blocks: [] }, + ], + }, + ], + }, + ]), + ]), + ]), + ); + }); + + it("round-trips a shape carrying an image as its sole content", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({}, [ + { + kind: "image", + format: "png", + base64: PNG_BASE64, + widthPt: 200, + heightPt: 100, + altText: "A tiny picture", + }, + ]), + ]), + ]), + ); + }); + + it("round-trips multiple shapes on one slide, insets included", () => { + expectRoundTrip( + documentOf([ + slide([ + shape({ + frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 100 }, + insetLeftPt: 4, + insetTopPt: 4, + insetRightPt: 4, + insetBottomPt: 4, + }), + shape({ + frame: { xPt: 220, yPt: 0, widthPt: 200, heightPt: 100 }, + name: "Second shape", + }), + ]), + ]), + ); + }); + + it("round-trips speaker notes spanning multiple lines, including a blank line", () => { + expectRoundTrip( + documentOf([ + slide([shape()], "First line\n\nThird line, after a blank one"), + ]), + ); + }); + + it("round-trips an empty shape (no blocks at all)", () => { + expectRoundTrip(documentOf([slide([shape({}, [])])])); + }); + + it("drops the residue channel, the one loss this writer takes rather than refuses", () => { + const document = documentOf([ + { + size: SLIDE_SIZE_WIDESCREEN, + shapes: [shape()], + notes: "", + source: { format: "odp", xml: "" }, + }, + ]); + const written = roundTrip(document); + expect(written.slides[0]!.source).toBeUndefined(); + expectRoundTrip(document); + }); + + it("drops metadata fields ODF or this package's own reader cannot carry back", () => { + const document: PresentationDocument = { + kind: "presentation", + metadata: { title: "T", producer: "a PDF writer", language: "en-GB" }, + slides: [slide([shape()])], + }; + expect(normaliseOdpContent(document).metadata).toEqual({ title: "T" }); + expectRoundTrip(document); + }); +}); + +describe("writeOdpContent: refusals", () => { + it("refuses a page break inside a shape's own text", () => { + expect(() => + writeOdpContent( + documentOf([slide([shape({}, [{ kind: "pageBreak" }])])]), + ), + ).toThrow(/page break/); + }); + + it("refuses a table mixed with paragraphs in one shape", () => { + expect(() => + writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { kind: "paragraph", runs: [{ text: "x" }] }, + { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }, + ]), + ]), + ]), + ), + ).toThrow(/table alongside other content/); + }); + + it("refuses a heading inside a shape's own text", () => { + expect(() => + writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { kind: "paragraph", headingLevel: 2, runs: [{ text: "x" }] }, + ]), + ]), + ]), + ), + ).toThrow(/heading/); + }); +}); + +// A rotated shape's own frame/rotationDeg is an exact algebraic inverse (typed/draw/write-shapes.ts's own frameGeometryAttrs), verified with a numeric tolerance rather than the blanket expectRoundTrip helper above -- see this file's own top-of-file note. +describe("writeOdpContent: rotated shape geometry, within floating-point tolerance", () => { + it.each([30, 90, 180, -45, 12.5])( + "round-trips a %i-degree rotation", + (rotationDeg) => { + const document = documentOf([ + slide([ + shape({ + rotationDeg, + frame: { xPt: 50, yPt: 60, widthPt: 200, heightPt: 80 }, + }), + ]), + ]); + const written = roundTrip(document); + const writtenShape = written.slides[0]!.shapes[0]!; + expect(writtenShape.rotationDeg).toBeCloseTo(rotationDeg, 9); + expect(writtenShape.frame.xPt).toBeCloseTo(50, 6); + expect(writtenShape.frame.yPt).toBeCloseTo(60, 6); + expect(writtenShape.frame.widthPt).toBeCloseTo(200, 6); + expect(writtenShape.frame.heightPt).toBeCloseTo(80, 6); + }, + ); + + it("collapses a literal 0-degree rotation to no rotation at all on the way back", () => { + const document = documentOf([slide([shape({ rotationDeg: 0 })])]); + const written = roundTrip(document); + expect(written.slides[0]!.shapes[0]!.rotationDeg).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/odp/write.test.ts b/packages/odf.js/src/typed/odp/write.test.ts new file mode 100644 index 000000000..73ace454b --- /dev/null +++ b/packages/odf.js/src/typed/odp/write.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentDocument, + ContentShape, + ContentSlide, +} from "document-schema.js"; +import { SLIDE_SIZE_WIDESCREEN } from "document-schema.js"; +import type { XmlElement } from "../../model/node"; +import type { Package } from "../../model/package"; +import { encodePackage } from "../../codec"; +import { readManifest } from "../../manifest"; +import { readMimetype } from "../../mimetype"; +import { + attrValue, + childrenWithTag, + elementsWithTag, + findChildElement, + rootElement, +} from "../../xml/query"; +import { assertMimetypeEntryLayout } from "../../test-support/zip"; +import { writeOdpContent } from "./write"; + +// The write side's XML-shape suite: what writeOdpContent actually emits, construct by construct -- the presentation mirror of typed/odt/write.test.ts (that file's own top-of-file note states why this suite exists alongside the round-trip one: a writer and reader that agree with each other and with nobody else would round-trip perfectly and open nowhere). + +// A 1x1 PNG, genuinely decodable (sniffImageFormat reads real magic bytes, not a name), matching typed/odt/write-round-trip.test.ts's own fixture. +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function shape( + overrides: Partial = {}, + blocks: ContentShape["blocks"] = [ + { kind: "paragraph", runs: [{ text: "Body" }] }, + ], +): ContentShape { + return { + frame: { xPt: 10, yPt: 20, widthPt: 300, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks, + ...overrides, + }; +} + +function slide(shapes: ContentShape[], notes = ""): ContentSlide { + return { size: SLIDE_SIZE_WIDESCREEN, shapes, notes }; +} + +function documentOf(slides: ContentSlide[]): ContentDocument { + return { kind: "presentation", metadata: {}, slides }; +} + +function partRoot(pkg: Package, path: string): XmlElement { + const part = pkg.parts[path]; + if (part?.kind !== "xml") { + throw new Error(`expected an XML part at ${path}`); + } + const root = rootElement(part.nodes); + if (root === undefined) { + throw new Error(`expected a root element in ${path}`); + } + return root; +} + +function presentationBody(pkg: Package): XmlElement { + const body = findChildElement( + partRoot(pkg, "content.xml").children, + "office:body", + ); + const presentation = + body === undefined + ? undefined + : findChildElement(body.children, "office:presentation"); + if (presentation === undefined) { + throw new Error("expected office:body/office:presentation"); + } + return presentation; +} + +function pagesOf(pkg: Package): XmlElement[] { + return childrenWithTag(presentationBody(pkg), "draw:page"); +} + +function styleNamed( + container: XmlElement, + name: string, + family: string, +): XmlElement { + const found = childrenWithTag(container, "style:style").find( + (style) => + attrValue(style, "style:name") === name && + attrValue(style, "style:family") === family, + ); + if (found === undefined) { + throw new Error(`expected a ${family} style named ${name}`); + } + return found; +} + +describe("writeOdpContent: package structure", () => { + const pkg = writeOdpContent(documentOf([slide([shape()])])); + + it("declares the presentation media type", () => { + expect(readMimetype(pkg)).toBe( + "application/vnd.oasis.opendocument.presentation", + ); + }); + + it("hoists mimetype first, stored, uncompressed", () => { + assertMimetypeEntryLayout( + encodePackage(pkg), + "application/vnd.oasis.opendocument.presentation", + ); + }); + + it("registers content.xml, styles.xml, and meta.xml in the manifest", () => { + const manifest = readManifest(pkg); + const paths = manifest.entries.map((entry) => entry.fullPath); + expect(paths).toContain("content.xml"); + expect(paths).toContain("styles.xml"); + expect(paths).toContain("meta.xml"); + }); + + it("writes one draw:page for one slide", () => { + expect(pagesOf(pkg)).toHaveLength(1); + }); +}); + +describe("writeOdpContent: slide page geometry", () => { + it("writes a style:master-page + style:page-layout per slide, referenced by draw:master-page-name", () => { + const pkg = writeOdpContent( + documentOf([slide([shape()]), slide([shape()])]), + ); + const pages = pagesOf(pkg); + expect(pages).toHaveLength(2); + const masterPageName1 = attrValue(pages[0]!, "draw:master-page-name"); + const masterPageName2 = attrValue(pages[1]!, "draw:master-page-name"); + expect(masterPageName1).toBeDefined(); + expect(masterPageName2).toBeDefined(); + expect(masterPageName1).not.toBe(masterPageName2); + + const stylesRoot = partRoot(pkg, "styles.xml"); + const masterStyles = findChildElement( + stylesRoot.children, + "office:master-styles", + ); + if (masterStyles === undefined) { + throw new Error("expected office:master-styles"); + } + const masterPage = childrenWithTag(masterStyles, "style:master-page").find( + (element) => attrValue(element, "style:name") === masterPageName1, + ); + if (masterPage === undefined) { + throw new Error("expected the referenced master page to exist"); + } + const pageLayoutName = attrValue(masterPage, "style:page-layout-name"); + expect(pageLayoutName).toBeDefined(); + + 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", + ).find((element) => attrValue(element, "style:name") === pageLayoutName); + if (pageLayout === undefined) { + throw new Error("expected the referenced page layout to exist"); + } + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]; + expect(properties && attrValue(properties, "fo:page-width")).toBe("960pt"); + expect(properties && attrValue(properties, "fo:page-height")).toBe("540pt"); + }); +}); + +describe("writeOdpContent: shape geometry", () => { + it("writes an unrotated shape's frame as plain svg:x/y/width/height", () => { + const pkg = writeOdpContent(documentOf([slide([shape()])])); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "svg:x")).toBe("10pt"); + expect(attrValue(frame, "svg:y")).toBe("20pt"); + expect(attrValue(frame, "svg:width")).toBe("300pt"); + expect(attrValue(frame, "svg:height")).toBe("100pt"); + expect(attrValue(frame, "draw:transform")).toBeUndefined(); + }); + + it("writes a rotated shape's frame as svg:width/height plus draw:transform, never svg:x/y", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ rotationDeg: 30 })])]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "svg:x")).toBeUndefined(); + expect(attrValue(frame, "svg:y")).toBeUndefined(); + expect(attrValue(frame, "svg:width")).toBe("300pt"); + expect(attrValue(frame, "svg:height")).toBe("100pt"); + const transform = attrValue(frame, "draw:transform"); + expect(transform).toMatch( + /^rotate\(-?[\d.]+\) translate\(-?[\d.]+pt -?[\d.]+pt\)$/, + ); + }); + + it("writes a literal rotationDeg of 0 the same as no rotation at all", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ rotationDeg: 0 })])]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "svg:x")).toBe("10pt"); + expect(attrValue(frame, "draw:transform")).toBeUndefined(); + }); + + it("names a shape via draw:name when given one", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ name: "Title Placeholder" })])]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "draw:name")).toBe("Title Placeholder"); + }); +}); + +describe("writeOdpContent: shape insets", () => { + it("writes no draw:style-name at all when every inset is zero", () => { + const pkg = writeOdpContent(documentOf([slide([shape()])])); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "draw:style-name")).toBeUndefined(); + }); + + it("interns a graphic-family style carrying fo:padding-* when an inset is non-zero", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({ + insetLeftPt: 5, + insetTopPt: 6, + insetRightPt: 7, + insetBottomPt: 8, + }), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + const styleName = attrValue(frame, "draw:style-name"); + expect(styleName).toBeDefined(); + const contentAutomaticStyles = findChildElement( + partRoot(pkg, "content.xml").children, + "office:automatic-styles", + ); + if (contentAutomaticStyles === undefined) { + throw new Error("expected content.xml office:automatic-styles"); + } + const style = styleNamed(contentAutomaticStyles, styleName!, "graphic"); + const properties = childrenWithTag(style, "style:graphic-properties")[0]!; + expect(attrValue(properties, "fo:padding-left")).toBe("5pt"); + expect(attrValue(properties, "fo:padding-top")).toBe("6pt"); + expect(attrValue(properties, "fo:padding-right")).toBe("7pt"); + expect(attrValue(properties, "fo:padding-bottom")).toBe("8pt"); + }); +}); + +describe("writeOdpContent: shape content", () => { + it("writes plain paragraphs as a draw:text-box of text:p", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { kind: "paragraph", runs: [{ text: "One" }] }, + { kind: "paragraph", runs: [{ text: "Two", bold: true }] }, + ]), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + const textBox = childrenWithTag(frame, "draw:text-box")[0]; + if (textBox === undefined) { + throw new Error("expected a draw:text-box"); + } + const paragraphs = childrenWithTag(textBox, "text:p"); + expect(paragraphs).toHaveLength(2); + }); + + it("groups consecutive list paragraphs into one text:list", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:x", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "bullet:x", level: 0 }, + }, + ]), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + const textBox = childrenWithTag(frame, "draw:text-box")[0]!; + const lists = childrenWithTag(textBox, "text:list"); + expect(lists).toHaveLength(1); + expect(childrenWithTag(lists[0]!, "text:list-item")).toHaveLength(2); + }); + + it("writes a shape whose sole block is a table as a bare table:table, no draw:text-box wrapper", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { + kind: "table", + columnWidthsPt: [100, 100], + rows: [ + { + cells: [ + { blocks: [{ kind: "paragraph", runs: [{ text: "A" }] }] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "B" }] }] }, + ], + }, + ], + }, + ]), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(childrenWithTag(frame, "table:table")).toHaveLength(1); + expect(childrenWithTag(frame, "draw:text-box")).toHaveLength(0); + }); + + it("writes a shape whose sole block is an image as a bare draw:image, no draw:text-box wrapper", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { + kind: "image", + format: "png", + base64: PNG_BASE64, + widthPt: 300, + heightPt: 100, + altText: "A picture", + }, + ]), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(childrenWithTag(frame, "draw:text-box")).toHaveLength(0); + const image = childrenWithTag(frame, "draw:image")[0]; + if (image === undefined) { + throw new Error("expected a draw:image"); + } + expect(attrValue(image, "xlink:href")).toBe("Pictures/image1.png"); + expect(pkg.parts["Pictures/image1.png"]?.kind).toBe("binary"); + const title = childrenWithTag(frame, "svg:title")[0]; + expect(title).toBeDefined(); + }); + + it("refuses a table mixed with other content, by name", () => { + expect(() => + writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { kind: "paragraph", runs: [{ text: "x" }] }, + { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }, + ]), + ]), + ]), + ), + ).toThrow(/table alongside other content/); + }); + + it("refuses a heading inside a shape's own text, by name", () => { + expect(() => + writeOdpContent( + documentOf([ + slide([ + shape({}, [ + { kind: "paragraph", headingLevel: 1, runs: [{ text: "x" }] }, + ]), + ]), + ]), + ), + ).toThrow(/heading/); + }); + + it("refuses a page break inside a shape's own text, by name", () => { + expect(() => + writeOdpContent( + documentOf([slide([shape({}, [{ kind: "pageBreak" }])])]), + ), + ).toThrow(/page break/); + }); +}); + +describe("writeOdpContent: speaker notes", () => { + it("writes no presentation:notes at all for an empty notes string", () => { + const pkg = writeOdpContent(documentOf([slide([shape()], "")])); + const page = pagesOf(pkg)[0]!; + expect(childrenWithTag(page, "presentation:notes")).toHaveLength(0); + }); + + it("writes one text:p per line of notes, inside a draw:frame > draw:text-box", () => { + const pkg = writeOdpContent( + documentOf([slide([shape()], "First line\nSecond line")]), + ); + const page = pagesOf(pkg)[0]!; + const notes = childrenWithTag(page, "presentation:notes")[0]; + if (notes === undefined) { + throw new Error("expected presentation:notes"); + } + const frame = childrenWithTag(notes, "draw:frame")[0]; + if (frame === undefined) { + throw new Error("expected a draw:frame inside presentation:notes"); + } + const textBox = childrenWithTag(frame, "draw:text-box")[0]; + if (textBox === undefined) { + throw new Error("expected a draw:text-box inside the notes frame"); + } + const paragraphs = elementsWithTag(textBox.children, "text:p"); + expect(paragraphs).toHaveLength(2); + }); +}); From b67a3b672d8a019658c6aae3076cfb0e30d8e061 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 22:39:59 +0100 Subject: [PATCH 05/15] docs(odf.js): document odp write support and the LibreOffice verification Status and Writing-a-document now cover writeOdp/writeOdpContent the same way they already cover writeOdt/writeOds -- what round-trips, what is refused by name, and where the shared shape writer (typed/draw/write-shapes.ts) sits so a future odg writer knows to reuse it. The Architecture section's own module list is updated to match (typed/shared/list.ts and canonicalise.ts as the write-side helpers odt and odp now share; typed/draw/write-shapes.ts beside shapes.ts). A new LibreOffice verification subsection records the real, independent- implementation check this PR's own bar requires: the exact soffice --headless commands run against a sample .odp covering multiple slides, mixed bold/italic/aligned text, a rotated shape, a nested bullet list, a table with a merged cell, an image, and multi-line speaker notes, plus what was found -- one real gap (a missing style:page-layout-name on presentation:notes) that got fixed, and one found, precisely characterised, and left open (LibreOffice's own undocumented AutoLayout placeholder-binding does not recognise this writer's minimal notes placeholder, though the notes text itself is never lost and the XML is well-formed per the OASIS schema). The .sxi/.sxd notes in the OpenOffice.org 1.x section are corrected: .sxi now has writeOdp to build its own writer against (tracked separately, not done here), and only .sxd still needs a writeOdg underneath it first. --- packages/odf.js/README.md | 45 ++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index e42cc36ca..b3b216561 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -65,11 +65,12 @@ Under active development. Built and shipped: - **`readOdbInventory`** — resolves a `.odb` into connection info, table names, query definitions (`{ name, command, escapeProcessing? }` with real SQL text), and form/report `{ name, href }` pairs. A sub-document directory is named after an opaque _persistent_ name (`forms/Obj11`), not the user-visible name. - **`readOdbForm`/`readOdbReport`** — extract one sub-document's _static structure_, executing nothing: a form's control tree and data bindings, or a report's band stack, recursive group tree, bound fields, and computed expressions. - **OpenOffice.org 1.x / StarOffice 6-7 reading** (`readSxw`/`readSxc`/`readSxi`/`readSxd` and their `*Content` siblings, plus `transformOoo1Package` and `isOoo1Package`) — the pre-OASIS ancestor ODF 1.0 was based on, read through the ODF readers above rather than beside them. See [Reading and writing an OpenOffice.org 1.x document](#reading-and-writing-an-openofficeorg-1x-document). -- **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent` and `writeSxc`/`writeSxcContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`, and `.sxc`, built on `writeOds`/`writeOdsContent`. `.sxi`/`.sxd` still have no writer, since this package's typed layer has no `writeOdp`/`writeOdg` underneath them yet. +- **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent` and `writeSxc`/`writeSxcContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`, and `.sxc`, built on `writeOds`/`writeOdsContent`. `.sxi`/`.sxd` still have no writer of their own — `.sxi` now has `writeOdp` to invert `transformToOoo1Package` against (a follow-up, not built here), and `.sxd` still needs a `writeOdg` underneath it first. - **The odt writer, at the same two levels** — `writeOdt` takes the `DocumentTree` `readOdt` returns and `writeOdtContent` the flat `ContentDocument` `readOdtContent` returns, and both produce a real `.odt` `Package` (`encodePackage` turns it into bytes). Paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and `meta.xml` all round-trip; the fidelity constructs and embedded objects are refused by name rather than silently dropped. See [Writing a document](#writing-a-document). - **The ods writer, at the same two levels** — `writeOds`/`writeOdsContent`, the genuine inverse of `readOds`/`readOdsContent`. Every `office:value-type` a cell can carry (float/percentage/currency/boolean/date/time/string, plus a value-less cell), column widths, row heights, hidden rows/columns, merged ranges, cell background/borders/alignment/vertical-alignment, verbatim formulas, cell-anchored images, and print settings (page geometry, gridlines/headers, page order, scale/fit-to-page, print range, repeated header rows/columns, manual page breaks) all round-trip. Embedded objects, data-validation rules, and conditional-formatting rules are refused by name — `readOdsContent` has no write-side counterpart for any of the three yet. See [Writing a document](#writing-a-document). +- **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, ready for a future `.odg` writer to reuse. The fidelity constructs a shape's own text cannot carry (a heading, a run-level construct extent, a page break, an embedded object, a table or image mixed with other shape content) are refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including one found and fixed gap and one found, honestly-documented, still-open one. -Not yet built: writers for `.odp`/`.odg`, a write path for the fidelity constructs, a `.ods` cell's own `number:*` data-style (`readOdsContent` does not read one back yet, so there is nothing to write against), live-view editors, and the `.odb` database-table-export subsystem. A general-purpose SQL query engine for rendering a Report against its data is **deliberately not attempted** — building even a bounded SQL engine means reimplementing HSQLDB's/Firebird's query semantics, a materially different undertaking from decoding their file formats, with unreviewed licensing questions. Gated on the requesting engineer's explicit sign-off. +Not yet built: a `.odg` writer, a write path for the fidelity constructs, a `.ods` cell's own `number:*` data-style (`readOdsContent` does not read one back yet, so there is nothing to write against), live-view editors, and the `.odb` database-table-export subsystem. A general-purpose SQL query engine for rendering a Report against its data is **deliberately not attempted** — building even a bounded SQL engine means reimplementing HSQLDB's/Firebird's query semantics, a materially different undertaking from decoding their file formats, with unreviewed licensing questions. Gated on the requesting engineer's explicit sign-off. ## Getting started @@ -146,7 +147,7 @@ Paragraphs, headings, runs (character formatting, hyperlinks), whitespace, lists The fidelity constructs `readOdt` reads (fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms) and embedded objects are refused **by name** rather than silently dropped — a block or paragraph carrying one throws naming exactly what it carries, since writing a document that silently lost semantic content would be worse than not writing it at all. The one deliberate exception is the quarantined residue channel: residue is opaque by construction, so re-emitting it would be actively wrong rather than merely incomplete, and it is dropped instead, a known, tracked restorable-fidelity gap rather than a silent one. -`.odt` and `.ods` have a writer today — `.odp`/`.odg` are read-only still (see [Status](#status)). +`.odt`, `.ods`, and `.odp` have a writer today — `.odg` is read-only still (see [Status](#status)). `writeOds`/`writeOdsContent` are the same shape, over `readOds`/`readOdsContent`: @@ -163,6 +164,34 @@ Every `ContentCellValue` kind `readOdsContent` can actually produce (number, per Embedded objects, data-validation rules, and conditional-formatting rules are refused **by name** for every sheet — `readOdsContent` has no write-side counterpart for any of the three yet (no embedded-sub-document package writer exists anywhere in this package's typed layer, and the reader itself never populates either rule array). A cell's own `numberFormatCode` is not written as a `number:*` data-style/`style:data-style-name` reference for the same reason: `readOdsContent` does not populate that field for any cell today, so there is no genuine inverse to write against. Sheet-level residue is dropped, the same deliberate exception `writeOdt` makes. +`writeOdp`/`writeOdpContent` are the same shape, over `readOdp`/`readOdpContent`: + +```ts +import { writeOdp, writeOdpContent, encodePackage } from "odf.js"; + +const pkg = writeOdp(document); // document-schema.js's DocumentTree -> a real .odp Package +const bytes = encodePackage(pkg); // Package -> bytes + +const pkgFromContent = writeOdpContent(contentDocument); // the flat ContentDocument level, same shape readOdpContent returns +``` + +A presentation is a sequence of slides, each a positioned bag of shapes rather than flowed blocks — `writeOdp` writes one `style:master-page`/`style:page-layout` pair per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level `p:sldSz`) and one `draw:page` per slide, its shapes written by `typed/draw/write-shapes.ts`'s `writeDrawShapes` — the shape writer this package factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, so a future `.odg` writer reuses it rather than reimplementing shape geometry, insets, and text/table/image content from scratch. A shape's own `frame`/`rotationDeg` write as plain `svg:x`/`svg:y`/`svg:width`/`svg:height` when unrotated, or `svg:width`/`svg:height` plus a `draw:transform="rotate(...) translate(...)"` when rotated — the exact algebraic inverse of the reader's own `resolveOdfShapeGeometry`, exact up to ordinary floating-point rounding on a real round trip. A shape's own text (formatted runs, alignment, spacing, indentation, bullet/ordered lists nested per level) writes as a `draw:text-box`; a shape whose sole block is a table or an image writes that content directly as the frame's own `table:table`/`draw:image`, since a real `draw:frame` can hold exactly one of the three, never a mix — a combination ODF has no spelling for is refused **by name**, the same fidelity-construct stance `writeOdt` takes, and so is a heading or a page break inside a shape's own text (a `draw:text-box` has no `text:h` reading path and no page concept at all). Speaker notes write as `presentation:notes`, one `text:p` per line. `flattenTree(readOdp(writeOdp(document)))` reproduces `document` up to the normalisation `normaliseOdpContent` states explicitly — including the one fact ODF forces rather than this writer choosing it: an image's own `widthPt`/`heightPt` become its enclosing shape's own frame size, since a `draw:image` has no size of its own at all inside a `draw:frame`. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception `writeOdt` makes. `.odg`/`.sxi` are not covered — see [Status](#status). + +#### LibreOffice verification (`writeOdp`) + +Round-tripping through this package's own reader proves internal consistency, not that a real, independent ODF implementation accepts the result — so a sample `.odp` covering multiple slides (one widescreen, one A4-portrait, exercising per-slide page geometry), a shape with mixed bold/italic/plain runs and centred alignment, a rotated shape (`draw:transform`), a nested bullet list, a shape carrying a table (including a merged cell) as its sole content, a shape carrying an image as its sole content, and multi-line speaker notes was built with `writeOdp` and checked against LibreOffice 26.2.5.2 directly (`soffice --headless`), matching this package family's own established verification bar (see `doc-codec`'s README and this package's own `.sxw`/`.ods`/`.sxc` writer PRs): + +```sh +soffice --headless --convert-to fodp sample.odp # flat XML, for text-content inspection +soffice --headless --convert-to pdf sample.odp # rendered pages, for visual inspection +``` + +Both commands exit `0` with no error. The flat-XML conversion carries every piece of real content byte-for-byte (all three `draw:page`s, the `table:table`, the `draw:transform`, both `text:list`s, and every string of authored text — titles, bullets, table cells, the rotated shape's own text — found verbatim in the re-serialised output), and the rendered PDF (3 pages, matching the 3 slides) visually confirms the bold/italic mixed formatting, the centred title, the nested bullet list, the shape rotated clockwise by the requested angle, the table with its merged cell, and the A4-portrait slide's own different page geometry, all laid out correctly with no visible loss. + +**One gap found and fixed during this verification**: an earlier version of this writer's `presentation:notes` carried no `style:page-layout-name` attribute at all. Real LibreOffice output always states one (a notes page is sized for printing, independent of whatever on-screen size its slide's own page-layout states), and every real producer's own notes page references it directly — `writeOdp` now mints one page-layout for the whole presentation's own notes pages, lazily, the first time any slide actually has notes to write. + +**One gap found and left honestly open**: even with that page-layout reference (and, tried individually, `presentation:class="notes"`, `presentation:placeholder="true"`, a minted presentation-family style referenced by `presentation:style-name` under both a generic and a master-page-matching name, an explicit `draw:layer-set` with `draw:layer="backgroundobjects"`, and moving `presentation:notes` earlier in `draw:page`'s own child order — none, alone, changed the outcome), LibreOffice's own AutoLayout placeholder-matching does not bind this writer's `presentation:notes`/`draw:frame` to its internal Notes view; instead it re-homes the frame's real text content onto the slide's own visible shape list on import, alongside a separately synthesised, empty notes placeholder of its own. The speaker-notes **text is never lost** — `soffice --headless --convert-to fodp` carries it through byte-for-byte, confirmed above — and the written XML is well-formed per the OASIS schema and parses with no error; the gap is specifically that LibreOffice's own undocumented internal placeholder-binding heuristic (not part of the OASIS schema itself) does not yet recognise this writer's minimal placeholder as canonical. `typed/odp/write.ts`'s own `writeSlideNotes` comment records every attempted fix and why each was ruled out; closing this fully would need either a primary LibreOffice source-level investigation or a real Impress-authored notes-page fixture to diff against byte-for-byte, both out of scope for this PR, and is tracked as a follow-up. + ### The flat `ContentDocument` level Beneath each package-native reader sits the flat reader it is built on, unchanged in behaviour and exported under a `*Content` name. Reach for these when you work in `document-schema.js`'s flat codec-exchange form — as `documents.js`'s own conversion pipeline does — rather than in the tree: @@ -271,7 +300,7 @@ const sxcPkgFromContent = writeSxcContent(spreadsheetContentDocument); // the fl `writeSxw`/`writeSxwContent` call `writeOdt`/`writeOdtContent` to build a real ODF `.odt` `Package`; `writeSxc`/`writeSxcContent` call `writeOds`/`writeOdsContent` to build a real ODF `.ods` `Package` the identical way. Both then run their package through `transformToOoo1Package` — `transformOoo1Package`'s own inverse, reversing every rename and restructure the read-side transform documents (namespace URIs, the `office:class` genre wrap/unwrap, the `style:properties` typed-family split/merge, the `draw:frame` wrap/unwrap, the renamed elements and attributes including a cell's `office:value-*` family becoming `table:value-*`, the `"inch"`/`"in"` unit spelling, and the package-level mimetype/manifest handling) against the same LibreOffice transformer source and OpenOffice.org DTD the forward direction is grounded against. Since `transformToOoo1Package` is itself generic across every ODF media type rather than `.odt`-specific, wiring `.sxc` up to it needed no changes to the transform at all — only a second pair of writer entry points wrapping `writeOds`/`writeOdsContent` the way `writeSxw`/`writeSxwContent` already wrap `writeOdt`/`writeOdtContent`. The result genuinely declares OpenOffice.org 1.x namespace URIs, carries no `mimetype` part, and reads back correctly through the ordinary readers — `readSxw(writeSxw(document))` recovers `document` up to the exact same canonical form `normaliseOdtContent` already states for `writeOdt`, and `readSxc(writeSxc(document))` recovers `document` up to the canonical form `normaliseOdsContent` already states for `writeOds`, since each `*Content` writer here is its ODF counterpart's own output run one transform further. What `writeOdt`/`writeOds` refuse (the odt fidelity constructs — fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms; the ods embedded objects, data-validation rules, and conditional-formatting rules), `writeSxw`/`writeSxc` refuse too, for the same reason: a document that silently lost semantic content would be worse than one this writer declined to produce at all. -`.sxi`/`.sxd` still have no writer — this package's typed layer has no `writeOdp`/`writeOdg` for one to be built on. See [What differs between the two vocabularies](#what-differs-between-the-two-vocabularies) for what the transform covers, and its own module comment (`src/ooo1/transform.ts`) for the full list, including the reverse direction's own note (`transformToOoo1Package`) on the package-wide context (a document's `office:class`, a list's ordered/bullet kind) the reverse needs that the forward direction never did. +`.sxi`/`.sxd` still have no writer. `writeOdp` now exists for a `writeSxi`/`writeSxiContent` pair to wrap the same way `writeSxw`/`writeSxc` wrap `writeOdt`/`writeOds` — tracked as its own follow-up rather than built here. `.sxd` still needs a `writeOdg` underneath it first. See [What differs between the two vocabularies](#what-differs-between-the-two-vocabularies) for what the transform covers, and its own module comment (`src/ooo1/transform.ts`) for the full list, including the reverse direction's own note (`transformToOoo1Package`) on the package-wide context (a document's `office:class`, a list's ordered/bullet kind) the reverse needs that the forward direction never did. ### What differs between the two vocabularies @@ -308,9 +337,9 @@ Layered from a lossless core outward, mirroring `ooxml.js`: - **`src/package-io/`** — `write.ts` hoists `mimetype` first (stored) and `META-INF/manifest.xml` second if present; never fabricates either as a side effect. - **`src/manifest.ts`** — full manifest read/write; the manifest is ODF's one mandatory part, unlike `ooxml.js`'s read-only OPC-relationship stance. - **`src/styles/`** — `properties.ts`/`serialize.ts` (canonical property-bag ↔ XML attributes), `registry.ts` (`StyleRegistry`, the mandatory style-interning layer), `span.ts` (character-range `text:span` wrapping). -- **`src/typed/shared/`** — ODF-specific typed primitives every reader builds on (units, A1 cursors, colour/geometry, whitespace runs, style cascade, shared paragraph/table readers, transform/path parsing, metadata). -- **`src/typed/odt/`, `odp/`, `odg/`, `ods/`** — one module per format, each carrying both levels of its reader: the package-native `readOdt`/`readOdp`/`readOdg`/`readOds` and the flat `readOdtContent`/`readOdpContent`/`readOdgContent`/`readOdsContent` it is built on. -- **`src/typed/draw/`** — the shared `draw:frame`/`draw:g`/vector shape vocabulary and `readDrawImageBlock` (`shapes.ts`), plus `embedded.ts` (`readDrawObjectReference`, `readEmbeddedObjectDocument`, `readOdfChartContent` — the shared embedded-object reference resolver and the central kind→reader dispatch table). +- **`src/typed/shared/`** — ODF-specific typed primitives every reader/writer builds on (units, A1 cursors, colour/geometry, whitespace runs, style cascade, shared paragraph/table readers, transform/path parsing, metadata, `list.ts`'s write-side numId canonicalisation shared by `writeOdt`/`writeOdp`, `canonicalise.ts`'s write-side paragraph/table/image canonical form shared the same way). +- **`src/typed/odt/`, `odp/`, `odg/`, `ods/`** — one module per format: `read.ts` carries both levels of the reader (the package-native `readOdt`/`readOdp`/`readOdg`/`readOds` and the flat `readOdtContent`/`readOdpContent`/`readOdgContent`/`readOdsContent` it is built on); `odt/write.ts`, `ods/write.ts`, and `odp/write.ts` carry the write side the same way, where one exists (`odg` has none yet). +- **`src/typed/draw/`** — the shared `draw:frame`/`draw:g`/vector shape vocabulary and `readDrawImageBlock` (`shapes.ts`), plus `embedded.ts` (`readDrawObjectReference`, `readEmbeddedObjectDocument`, `readOdfChartContent` — the shared embedded-object reference resolver and the central kind→reader dispatch table), plus the write-side mirror of `shapes.ts` (`write-shapes.ts`: `writeDrawFrame`/`writeDrawShapes`, shared between `writeOdp` and a future `.odg` writer). - **`src/typed/formula/`, `odm/`** — `readOdfFormula`/`readOdfFormulaContent`/`readOdfFormulaMathMl` and `readOdm`. - **`src/typed/odb/`** — `readOdbInventory`, `readOdbForm`/`readOdbReport`, `resolveOdbComponent`, `subDocumentPackage`. - **`src/ooo1/`** — the OpenOffice.org 1.x variant reader and writer: `ns.ts` (the pre-OASIS namespace and `application/vnd.sun.xml.*` media-type tables plus package detection, in both directions), `properties.ts` (the `style:properties` split, and `mergeStyleProperties`, its own inverse), `transform.ts` (the whole package rewrite, `transformOoo1Package` and its inverse `transformToOoo1Package`), `read.ts` (`readSxw`/`readSxc`/`readSxi`/`readSxd`), `write.ts` (`writeSxw`/`writeSxwContent`/`writeSxc`/`writeSxcContent`). Sits _beside_ `typed/`, not inside it: it adds no reader or writer of its own for the ODF content model, it feeds `writeOdt`'s/`writeOds`'s own output into `transformToOoo1Package` and the ODF readers' input through `transformOoo1Package`. @@ -354,7 +383,7 @@ Layered from a lossless core outward, mirroring `ooxml.js`: - **Master pages and page-break styles** — every `style:master-page` reads as part of a whole-page inventory: a paragraph style's `style:paragraph-properties/@style:master-page-name` switch opens a new `ContentSection` at that paragraph carrying the named master page's own geometry (`breakType: 'nextPage'` — ODF defines the switch as forcing a page break), and each master page's `style:header`/`style:footer` variants (default, `-left`, `-first`) read as real block flow on `OdtDocument.headerFooterParts`, with `sectionMasterPages` naming positionally which master page each section came from. Explicit page breaks ride the shared cascade: `fo:break-before`/`fo:break-after="page"` resolve onto the paragraph's `pageBreakBefore`/`pageBreakAfter` flags (`auto` to an explicit false; `column`/`even-page`/`odd-page` quarantine as residue through the unknown-properties path, since the boolean cannot hold their extra meaning). - **The quarantined residue rows** — inline no-analogue constructs (`text:ruby`, `text:meta`, a heading's `text:is-list-header` flag) quarantine on their own paragraph beside the style-chain unknowns; document-level tenants nothing else owns (`xforms:model`, DDE connection declarations and `text:dde-source` links, vendor-extension elements) quarantine at the package tier on `OdtDocument.source`; `table:calculation-settings` and vendor-extension elements (Calc writes its `calcext:conditional-formats` inside each `table:table`) do the same on `OdsDocument.source`; an odp slide's presentation extras — the transition attributes off the slide's own drawing-page style, where every ODF schema version and real Impress output put them (the legacy `presentation:transition-*`/`presentation:duration` spelling and the ODF 1.2 `smil:type`/`-subtype`/`-direction`/`-fadeColor` one), plus `presentation:sound` and `anim:` trees — and every format's unmapped shape kinds (`dr3d:scene`, `draw:connector`, `draw:measure`, applet/plugin/floating-frame) quarantine on their own slide/page; an unrecognised `draw:custom-shape` preset's whole `draw:enhanced-geometry` quarantines in the text shape it degrades to; and every non-content XML part (`settings.xml`, `META-INF/manifest.rdf`, `Configurations2/…`) quarantines at the package tier keyed by its part path — never an embedded sub-document's own `Object N/` parts, which the semantic channel already carries whole. -Every read-side construct listed above has no write-side counterpart: `writeOdt` refuses each of them by name rather than silently dropping it (see [Writing a document](#writing-a-document)), so the honest asymmetry is a reader that recovers more than either writer will re-emit, not a package with no content writer at all; the lossless `encodePackage` layer remains the byte-fidelity tier regardless. +Every read-side construct listed above has no write-side counterpart: `writeOdt` refuses each of them by name rather than silently dropping it (see [Writing a document](#writing-a-document)), so the honest asymmetry is a reader that recovers more than any writer in this package will re-emit, not a package with no content writer at all; the lossless `encodePackage` layer remains the byte-fidelity tier regardless. - **An embedded Math object in a spreadsheet cell reads as `objectKind: 'formula'`** — its `content.xml` root _is_ the MathML root, so `readDrawObjectReference` falls back to `findMathRoot` and dispatches to `readOdfFormulaContent`. - **A `draw:frame`'s alternative text (`svg:title`, falling back to `svg:desc`) reads into `ContentImageBlock.altText`.** From 68081c8a13178ed145cba2fcb06e9c080407a0ee Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:02:19 +0100 Subject: [PATCH 06/15] fix(odf.js): declare the presentation namespace prefix on every written part writeOdp emits presentation:notes and presentation:class, but the shared prefix list in package-io/scaffold.ts declared no presentation: prefix, so every .odp carrying speaker notes was not namespace-well-formed XML. xmllint rejects it outright ("Namespace prefix presentation on notes is not defined"); LibreOffice instead imported the file with the notes element unrecognised and re-homed its text onto the slide's own visible shape list, so notes rendered on the slide and the notes page came back empty. That symptom was previously recorded here as an AutoLayout placeholder heuristic in LibreOffice, in writeSlideNotes's own comment and in the README. It was a missing xmlns declaration; both now state the real cause. Nothing between a writer and the emitted bytes checks that a qualified name's prefix is bound, and this package's own reader matches prefixes as plain strings, so the defect round-trips perfectly and only a real consumer sees it. namespace-declarations.test.ts closes that structurally, walking every XML part each writer produces and asserting every prefix used in an element or attribute name is one the part's own root declares. --- packages/odf.js/README.md | 6 +- .../package-io/namespace-declarations.test.ts | 321 ++++++++++++++++++ packages/odf.js/src/package-io/scaffold.ts | 6 +- packages/odf.js/src/typed/odp/write.ts | 4 +- 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 packages/odf.js/src/package-io/namespace-declarations.test.ts diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index b3b216561..c43583bc8 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -68,7 +68,7 @@ Under active development. Built and shipped: - **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent` and `writeSxc`/`writeSxcContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`, and `.sxc`, built on `writeOds`/`writeOdsContent`. `.sxi`/`.sxd` still have no writer of their own — `.sxi` now has `writeOdp` to invert `transformToOoo1Package` against (a follow-up, not built here), and `.sxd` still needs a `writeOdg` underneath it first. - **The odt writer, at the same two levels** — `writeOdt` takes the `DocumentTree` `readOdt` returns and `writeOdtContent` the flat `ContentDocument` `readOdtContent` returns, and both produce a real `.odt` `Package` (`encodePackage` turns it into bytes). Paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and `meta.xml` all round-trip; the fidelity constructs and embedded objects are refused by name rather than silently dropped. See [Writing a document](#writing-a-document). - **The ods writer, at the same two levels** — `writeOds`/`writeOdsContent`, the genuine inverse of `readOds`/`readOdsContent`. Every `office:value-type` a cell can carry (float/percentage/currency/boolean/date/time/string, plus a value-less cell), column widths, row heights, hidden rows/columns, merged ranges, cell background/borders/alignment/vertical-alignment, verbatim formulas, cell-anchored images, and print settings (page geometry, gridlines/headers, page order, scale/fit-to-page, print range, repeated header rows/columns, manual page breaks) all round-trip. Embedded objects, data-validation rules, and conditional-formatting rules are refused by name — `readOdsContent` has no write-side counterpart for any of the three yet. See [Writing a document](#writing-a-document). -- **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, ready for a future `.odg` writer to reuse. The fidelity constructs a shape's own text cannot carry (a heading, a run-level construct extent, a page break, an embedded object, a table or image mixed with other shape content) are refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including one found and fixed gap and one found, honestly-documented, still-open one. +- **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, ready for a future `.odg` writer to reuse. The fidelity constructs a shape's own text cannot carry (a heading, a run-level construct extent, a page break, an embedded object, a table or image mixed with other shape content) are refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including the two gaps that verification found and closed. Not yet built: a `.odg` writer, a write path for the fidelity constructs, a `.ods` cell's own `number:*` data-style (`readOdsContent` does not read one back yet, so there is nothing to write against), live-view editors, and the `.odb` database-table-export subsystem. A general-purpose SQL query engine for rendering a Report against its data is **deliberately not attempted** — building even a bounded SQL engine means reimplementing HSQLDB's/Firebird's query semantics, a materially different undertaking from decoding their file formats, with unreviewed licensing questions. Gated on the requesting engineer's explicit sign-off. @@ -190,7 +190,9 @@ Both commands exit `0` with no error. The flat-XML conversion carries every piec **One gap found and fixed during this verification**: an earlier version of this writer's `presentation:notes` carried no `style:page-layout-name` attribute at all. Real LibreOffice output always states one (a notes page is sized for printing, independent of whatever on-screen size its slide's own page-layout states), and every real producer's own notes page references it directly — `writeOdp` now mints one page-layout for the whole presentation's own notes pages, lazily, the first time any slide actually has notes to write. -**One gap found and left honestly open**: even with that page-layout reference (and, tried individually, `presentation:class="notes"`, `presentation:placeholder="true"`, a minted presentation-family style referenced by `presentation:style-name` under both a generic and a master-page-matching name, an explicit `draw:layer-set` with `draw:layer="backgroundobjects"`, and moving `presentation:notes` earlier in `draw:page`'s own child order — none, alone, changed the outcome), LibreOffice's own AutoLayout placeholder-matching does not bind this writer's `presentation:notes`/`draw:frame` to its internal Notes view; instead it re-homes the frame's real text content onto the slide's own visible shape list on import, alongside a separately synthesised, empty notes placeholder of its own. The speaker-notes **text is never lost** — `soffice --headless --convert-to fodp` carries it through byte-for-byte, confirmed above — and the written XML is well-formed per the OASIS schema and parses with no error; the gap is specifically that LibreOffice's own undocumented internal placeholder-binding heuristic (not part of the OASIS schema itself) does not yet recognise this writer's minimal placeholder as canonical. `typed/odp/write.ts`'s own `writeSlideNotes` comment records every attempted fix and why each was ruled out; closing this fully would need either a primary LibreOffice source-level investigation or a real Impress-authored notes-page fixture to diff against byte-for-byte, both out of scope for this PR, and is tracked as a follow-up. +**A second gap found and fixed — an undeclared namespace prefix**: speaker notes initially arrived on the slide itself rather than its notes page, which read as LibreOffice's own AutoLayout placeholder-matching declining to bind a minimal `presentation:notes`/`draw:frame` to its internal Notes view. It was not: `presentation:notes` and its frame's `presentation:class` are the only `presentation:`-prefixed names any writer here emits, and `package-io/scaffold.ts`'s shared prefix list never declared that prefix, so the part was not namespace-well-formed XML at all — `xmllint --noout content.xml` reported `Namespace prefix presentation on notes is not defined`. LibreOffice imported the file anyway, treated the unrecognised element as ordinary slide content, and re-homed its text onto the visible shape list. With the prefix declared, `--convert-to fodp` round-trips the notes inside `presentation:notes` where they were written, the slide carries only its own shapes, and `--convert-to pdf` renders no notes text on the slide page. + +Nothing between a writer and the emitted bytes checks that a qualified name's prefix is actually bound — `src/xml/build.ts` writes whatever name an element carries — so this failure mode is silent by construction, and round-trips perfectly through this package's own (prefix-string-matching, namespace-unaware) reader. `src/package-io/namespace-declarations.test.ts` now audits every prefix each writer emits, across element and attribute names at any depth, against what that part's own root declares, so the next writer to reach for an undeclared prefix fails a test instead of shipping a document no XML parser will accept. ### The flat `ContentDocument` level diff --git a/packages/odf.js/src/package-io/namespace-declarations.test.ts b/packages/odf.js/src/package-io/namespace-declarations.test.ts new file mode 100644 index 000000000..3617d50c8 --- /dev/null +++ b/packages/odf.js/src/package-io/namespace-declarations.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentBlock, + ContentDocument, + ContentShape, +} from "document-schema.js"; +import { PAGE_SIZE_A4, SLIDE_SIZE_WIDESCREEN } 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 = 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 { + const declared = new Set(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: "#DDEEFF", + borders: { top: { style: "solid", widthPt: 1, color: "#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: "#FFEECC", + alignment: "center", + verticalAlignment: "middle", + borders: { + bottom: { style: "double", widthPt: 2, color: "#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, + 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:"', + ]); + }); +}); diff --git a/packages/odf.js/src/package-io/scaffold.ts b/packages/odf.js/src/package-io/scaffold.ts index f3341faaa..f9c8742ee 100644 --- a/packages/odf.js/src/package-io/scaffold.ts +++ b/packages/odf.js/src/package-io/scaffold.ts @@ -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", @@ -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. diff --git a/packages/odf.js/src/typed/odp/write.ts b/packages/odf.js/src/typed/odp/write.ts index 32680563e..0bb546d80 100644 --- a/packages/odf.js/src/typed/odp/write.ts +++ b/packages/odf.js/src/typed/odp/write.ts @@ -168,7 +168,9 @@ const NOTES_FRAME_BOX: Box = { xPt: 42, yPt: 320, widthPt: 500, heightPt: 260 }; // presentation:notes carries its own style:page-layout-name, matching every real LibreOffice-produced notes page (confirmed against real LibreOffice 26.2 output: a notes page is sized for PRINTING and always references a page-layout of its own, independent of whatever on-screen size each slide's own page-layout states -- so there is exactly one notes geometry for the whole presentation, not one per slide). notesPageLayoutState mints it lazily, the first time any slide actually has notes to write, so a presentation with no speaker notes at all never carries an unused page-layout. // -// A KNOWN, NAMED GAP rather than a silent one: this writer's presentation:notes is well-formed per the OASIS schema and parses through real LibreOffice with no error and no data loss (soffice --headless --convert-to fodp preserves the notes TEXT byte-for-byte -- see the package README's own LibreOffice-verification section for the exact commands and output) -- but LibreOffice's own AutoLayout placeholder-matching does not bind this writer's minimal presentation:notes/draw:frame to its internal Notes view the way a placeholder frame carrying LibreOffice's own internal presentation-page-layout machinery would; instead it re-homes the frame's content onto the slide's own visible shape list on import, alongside a separately synthesised, empty notes placeholder of LibreOffice's own. Reproduced across several attempted fixes (style:page-layout-name alone, presentation:class="notes", presentation:placeholder="true", a minted presentation-family style referenced by presentation:style-name under both a generic and a master-page-matching name, an explicit draw:layer-set with draw:layer="backgroundobjects", and moving the element earlier in draw:page's own child order) -- none, alone, changed the outcome, and LibreOffice's own placeholder-binding heuristic for AutoLayout slides is undocumented in the OASIS schema itself, so further narrowing needs either a primary LibreOffice source-level investigation or a real Impress-authored notes-page fixture to diff against byte-for-byte, both out of scope for this PR. ContentSlide.notes carries no placeholder-kind information for a future fix to model against, either, so this is tracked as a follow-up rather than attempted further here. +// THE ONE THING THAT MAKES THIS ELEMENT WORK AT ALL, and the reason it once appeared not to: presentation:notes and its frame's presentation:class are the only presentation:-prefixed names any writer in this package emits, and package-io/scaffold.ts's ODF_DOCUMENT_PREFIXES did not declare that prefix. The part was therefore not namespace-well-formed XML (`xmllint --noout content.xml`: "Namespace prefix presentation on notes is not defined"), and real LibreOffice, rather than rejecting the file, imported it with the notes element unrecognised and RE-HOMED its text onto the slide's own visible shape list -- so every slide's speaker notes rendered on the slide itself and its notes page came back empty. That looked exactly like an AutoLayout placeholder-binding heuristic refusing to bind a minimal notes frame, and was recorded here as one; it was a missing xmlns declaration. With the prefix declared, `soffice --headless --convert-to fodp` puts the notes back inside presentation:notes where they were written, the slide carries only its own shapes again, and `--convert-to pdf` renders no notes text on the slide page. +// +// The lesson is structural, not about this one prefix: nothing between a writer and the emitted bytes checks that a qualified name's prefix is bound, so this failure mode is silent by construction and round-trips perfectly through this package's own prefix-string-matching reader. package-io/namespace-declarations.test.ts now audits every prefix every writer emits against what its part's root declares, which is what catches the next one. interface NotesPageLayoutState { name: string | undefined; } From 356e211d8f2d5f96aacb6ac2d89c651af7d7e019 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:05:45 +0100 Subject: [PATCH 07/15] test(odf.js): construct the namespace audit's own fixture colours as Color objects The new namespace-declarations.test.ts fixtures wrote background/border colours as bare hex strings, but ColorSchema is an {r,g,b} object in 0..1 -- the fixtures type-checked as ContentBlock only because tsc's structural narrowing under `as const satisfies` was never actually run against them until now. Construct each one through rgbHexToColor instead. --- .../package-io/namespace-declarations.test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/odf.js/src/package-io/namespace-declarations.test.ts b/packages/odf.js/src/package-io/namespace-declarations.test.ts index 3617d50c8..4a1c1ddd4 100644 --- a/packages/odf.js/src/package-io/namespace-declarations.test.ts +++ b/packages/odf.js/src/package-io/namespace-declarations.test.ts @@ -4,7 +4,11 @@ import type { ContentDocument, ContentShape, } from "document-schema.js"; -import { PAGE_SIZE_A4, SLIDE_SIZE_WIDESCREEN } 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"; @@ -122,8 +126,14 @@ const TABLE_BLOCK = { { blocks: [{ kind: "paragraph", runs: [{ text: "Merged" }] }], colSpan: 2, - background: "#DDEEFF", - borders: { top: { style: "solid", widthPt: 1, color: "#112233" } }, + background: rgbHexToColor("#DDEEFF"), + borders: { + top: { + style: "solid", + widthPt: 1, + color: rgbHexToColor("#112233"), + }, + }, }, ], }, @@ -180,11 +190,15 @@ const SPREADSHEET: ContentDocument = { column: 0, value: { kind: "string", value: "Header" }, displayText: "Header", - background: "#FFEECC", + background: rgbHexToColor("#FFEECC"), alignment: "center", verticalAlignment: "middle", borders: { - bottom: { style: "double", widthPt: 2, color: "#334455" }, + bottom: { + style: "double", + widthPt: 2, + color: rgbHexToColor("#334455"), + }, }, colSpan: 2, }, From 984f175963fa113dccae2b4c76f221c52b8b7231 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:27:11 +0100 Subject: [PATCH 08/15] fix(odf.js): format an ODF length as fixed-point decimal, never exponent notation Number-to-string switches into exponent notation below 1e-6 and at or above 1e21, so formatOdfLength emitted values like "-7.1e-15pt". The OASIS `length` datatype has no exponent form at all, which made that output invalid ODF this package's own reader then discarded silently: parseOdfTransform drops a translate() whose components fail LENGTH_PATTERN, moving a rotated shape to its own pivot, and parseBox returns undefined for an unrotated frame's svg:x/svg:y, so readDrawFrame drops the whole shape and it vanishes from the slide. That magnitude is ordinary rather than contrived. A rotated frame's translate() components are trig-derived, so a frame at or near the page origin cancels to rounding dust instead of a clean zero at most angles. A 100x100pt frame at (0,0) rotated 270 degrees wrote translate(7.105427357601002e-15pt 100pt), and reading it back placed the shape 100pt from where it was written. Fixed on the write side rather than by widening LENGTH_PATTERN: accepting an exponent on read would leave every other ODF consumer seeing a length outside the datatype. The expansion re-positions the decimal point in the digits the shortest-round-tripping representation already chose, so it is an exact re-spelling rather than a rounding step, and carries no trailing fractional zeros for the same reason. --- .../src/typed/odp/write-round-trip.test.ts | 47 ++++++++++++++++ packages/odf.js/src/typed/odp/write.test.ts | 21 +++++++ .../odf.js/src/typed/shared/units.test.ts | 56 +++++++++++++++++++ packages/odf.js/src/typed/shared/units.ts | 32 ++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/typed/odp/write-round-trip.test.ts b/packages/odf.js/src/typed/odp/write-round-trip.test.ts index f044440b7..b63481be3 100644 --- a/packages/odf.js/src/typed/odp/write-round-trip.test.ts +++ b/packages/odf.js/src/typed/odp/write-round-trip.test.ts @@ -368,3 +368,50 @@ describe("writeOdpContent: rotated shape geometry, within floating-point toleran expect(written.slides[0]!.shapes[0]!.rotationDeg).toBeUndefined(); }); }); + +// The regression suite for the one class of length a plain number-to-string spells in EXPONENT notation, which the ODF `length` datatype has no form for (typed/shared/units.ts's own LENGTH_PATTERN and formatOdfLength note). The failure it pins is silent and total rather than approximate, which is why it needs a sweep rather than a single case: parseOdfTransform drops a translate() whose components don't parse, so a rotated shape lands at its own pivot instead of its frame; parseBox returns undefined for an unrotated frame whose svg:x/svg:y don't parse, so readDrawFrame returns undefined and the shape VANISHES from the slide entirely. +// +// The values that reach that magnitude are ordinary, not contrived: frameGeometryAttrs's translate() components are trig-derived, so a frame whose own centre sits at or near the page origin cancels to 1e-15-ish rounding dust rather than a clean zero at most angles. The sweep below crosses every quadrant boundary and both signs of each component, against frames at the origin, straddling it, and well away from it. +describe("writeOdpContent: rotated geometry near the page origin", () => { + const ANGLES_DEG = [ + -270, -180, -135, -90, -45, -30, -1, 0.0001, 1, 30, 45, 90, 135, 180, 270, + ]; + const FRAMES = [ + { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, // centre at (50,50) -- the classic cancelling case at 90/180/270. + { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + { xPt: -50, yPt: -50, widthPt: 100, heightPt: 100 }, // centre exactly ON the origin. + { xPt: -0.5, yPt: -0.5, widthPt: 1, heightPt: 1 }, + { xPt: 0.0001, yPt: 0.0001, widthPt: 200, heightPt: 80 }, + { xPt: 36, yPt: 48, widthPt: 400, heightPt: 120 }, // an ordinary, far-from-origin frame, as the control. + { xPt: 720.05, yPt: 405.05, widthPt: 0.1, heightPt: 0.1 }, + ]; + + it.each(ANGLES_DEG)( + "keeps every frame's own shape and geometry through a real write-then-read at %p degrees", + (rotationDeg) => { + const document = documentOf([ + slide(FRAMES.map((frame) => shape({ frame, rotationDeg }))), + ]); + const written = roundTrip(document); + const writtenShapes = written.slides[0]!.shapes; + // The whole-shape loss first: an unparseable svg:x/svg:y or transform drops the frame from the read entirely, so a length count mismatch IS the bug, not a symptom of one. + expect(writtenShapes).toHaveLength(FRAMES.length); + FRAMES.forEach((frame, index) => { + const writtenShape = writtenShapes[index]!; + expect(writtenShape.frame.xPt).toBeCloseTo(frame.xPt, 6); + expect(writtenShape.frame.yPt).toBeCloseTo(frame.yPt, 6); + expect(writtenShape.frame.widthPt).toBeCloseTo(frame.widthPt, 6); + expect(writtenShape.frame.heightPt).toBeCloseTo(frame.heightPt, 6); + expect(writtenShape.rotationDeg ?? 0).toBeCloseTo(rotationDeg, 9); + }); + }, + ); + + it("keeps an UNROTATED frame whose own svg:x/svg:y are small enough to reach exponent notation", () => { + const tiny = { xPt: 1e-9, yPt: -7.1e-15, widthPt: 200, heightPt: 80 }; + const written = roundTrip(documentOf([slide([shape({ frame: tiny })])])); + const writtenShapes = written.slides[0]!.shapes; + expect(writtenShapes).toHaveLength(1); + expect(writtenShapes[0]!.frame).toEqual(tiny); + }); +}); diff --git a/packages/odf.js/src/typed/odp/write.test.ts b/packages/odf.js/src/typed/odp/write.test.ts index 73ace454b..cfdcd2390 100644 --- a/packages/odf.js/src/typed/odp/write.test.ts +++ b/packages/odf.js/src/typed/odp/write.test.ts @@ -222,6 +222,27 @@ describe("writeOdpContent: shape geometry", () => { const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; expect(attrValue(frame, "draw:name")).toBe("Title Placeholder"); }); + + // The ODF `length` datatype has no exponent form at all (typed/shared/units.ts's own LENGTH_PATTERN), so an svg:*/translate() component in JavaScript's own exponent spelling is invalid ODF that this package's reader silently discards -- taking the translate(), or the whole shape, with it. A frame sitting at the page origin is the ordinary way to reach that magnitude: the rotation inverse's own terms cancel to trig rounding dust rather than to a clean zero. This exact case (a 100x100 frame at the origin, rotated 270 degrees) writes translate(7.105427357601002e-15pt ...) without the fix. + it("writes no exponent-notation length for a rotated frame at the page origin, where the translate() components cancel to rounding dust", () => { + const pkg = writeOdpContent( + documentOf([ + slide([ + shape({ + rotationDeg: 270, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + }), + ]), + ]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + const transform = attrValue(frame, "draw:transform"); + expect(transform).toBeDefined(); + expect(transform).not.toMatch(/[\d.]e[+-]?\d/i); + expect(transform).toMatch( + /^rotate\(-?[\d.]+\) translate\(-?[\d.]+pt -?[\d.]+pt\)$/, + ); + }); }); describe("writeOdpContent: shape insets", () => { diff --git a/packages/odf.js/src/typed/shared/units.test.ts b/packages/odf.js/src/typed/shared/units.test.ts index fa4c54b9c..2f6ff3c3c 100644 --- a/packages/odf.js/src/typed/shared/units.test.ts +++ b/packages/odf.js/src/typed/shared/units.test.ts @@ -56,3 +56,59 @@ describe("formatOdfLength", () => { expect(formatOdfLength(72, "px")).toBe("96px"); }); }); + +// The ODF `length` datatype has no exponent form (see units.ts's own LENGTH_PATTERN and the OASIS grammar it encodes), but JavaScript's own Number-to-string switches into one below 1e-6 and at/above 1e21. A length that came out as "-7.1e-15pt" was therefore spec-invalid ODF that this package's own reader silently rejected -- parseOdfTransform drops a translate() whose components don't parse, and parseBox returns undefined for an unrotated frame's own svg:x/svg:y, taking the whole shape with it. See typed/odp/write-round-trip.test.ts's own near-origin rotation sweep for the end-to-end statement of that failure. +describe("formatOdfLength: fixed-point decimal only, never exponent notation", () => { + const EXPONENT_MAGNITUDES = [ + 1e-7, 5.5e-8, 1e-15, -7.1e-15, 1.05e-20, 5e-324, 1e21, -1.2345e22, 1e300, + ]; + + it.each(EXPONENT_MAGNITUDES)( + "formats %p without an exponent, and parseOdfLength reads it back to the identical double", + (pt) => { + const formatted = formatOdfLength(pt); + expect(formatted).not.toMatch(/[eE]/); + expect(parseOdfLength(formatted)).toBe(pt); + }, + ); + + it("leaves the plain-stringification spelling of an ordinary value untouched, trailing zeros included (there are none to trim)", () => { + expect(formatOdfLength(0)).toBe("0pt"); + expect(formatOdfLength(12)).toBe("12pt"); + expect(formatOdfLength(-4.5)).toBe("-4.5pt"); + expect(formatOdfLength(0.000001)).toBe("0.000001pt"); + expect(formatOdfLength(1e-7)).toBe("0.0000001pt"); + }); + + it("never emits an exponent for any translate() component the rotation inverse can produce, across a full turn of angles and several frames including ones at the page origin", () => { + const frames = [ + { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + { xPt: 0.001, yPt: 0.001, widthPt: 200, heightPt: 80 }, + { xPt: -5, yPt: 3, widthPt: 40, heightPt: 40 }, + ]; + for (let deg = -360; deg <= 360; deg += 0.5) { + const angleRad = (-deg * Math.PI) / 180; + for (const frame of frames) { + // typed/draw/write-shapes.ts's own frameGeometryAttrs, restated here so this file tests the FORMATTER against the real value distribution rather than importing the shape writer into a units test. + const halfWidthPt = frame.widthPt / 2; + const halfHeightPt = frame.heightPt / 2; + const txPt = + frame.xPt + + halfWidthPt - + halfWidthPt * Math.cos(angleRad) - + halfHeightPt * Math.sin(angleRad); + const tyPt = + frame.yPt + + halfHeightPt - + halfHeightPt * Math.cos(angleRad) + + halfWidthPt * Math.sin(angleRad); + for (const pt of [txPt, tyPt]) { + const formatted = formatOdfLength(pt); + expect(formatted).not.toMatch(/[eE]/); + expect(parseOdfLength(formatted)).toBe(pt); + } + } + } + }); +}); diff --git a/packages/odf.js/src/typed/shared/units.ts b/packages/odf.js/src/typed/shared/units.ts index f352fe9dc..62f6b186d 100644 --- a/packages/odf.js/src/typed/shared/units.ts +++ b/packages/odf.js/src/typed/shared/units.ts @@ -58,8 +58,36 @@ export function parseOdfLength(value: string): number | undefined { return Number(numeric) * unitToPtFactor(unit); } -// The reverse of parseOdfLength: formats a point value as an ODF length string in the given unit (default "pt", matching this package's own writers' always-pt convention). No rounding is applied -- the conversion is an exact IEEE-754 division, so the result may carry more decimal places than a human would type by hand (real LibreOffice output does the same, e.g. "0.423cm" for a value that didn't originate in cm); a caller that wants a specific display precision is responsible for rounding the input pt value itself before calling this. +// 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 { + 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; + } + 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); + if (pointIndex <= 0) { + return `${sign}0.${"0".repeat(-pointIndex)}${digits}`; + } + if (pointIndex >= digits.length) { + return `${sign}${digits}${"0".repeat(pointIndex - digits.length)}`; + } + return `${sign}${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`; +} + +// The reverse of parseOdfLength: formats a point value as an ODF length string in the given unit (default "pt", matching this package's own writers' always-pt convention). No rounding is applied -- the conversion is an exact IEEE-754 division, so the result may carry more decimal places than a human would type by hand (real LibreOffice output does the same, e.g. "0.423cm" for a value that didn't originate in cm); a caller that wants a specific display precision is responsible for rounding the input pt value itself before calling this. The output is always fixed-point decimal, never exponent notation -- see expandExponential above for why that is a correctness requirement rather than a formatting preference. export function formatOdfLength(pt: number, unit: LengthUnit = "pt"): string { const value = pt / unitToPtFactor(unit); - return `${value}${unit}`; + return `${expandExponential(`${value}`)}${unit}`; } From b5e7eb1a4ec5b1d265ef398b1f2749e7abd63202 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:27:43 +0100 Subject: [PATCH 09/15] fix(odf.js): decode XML entities in a shape's draw:name odf.js parses with processEntities:false, so an attribute value in this package's model is the literal source text: a shape named `Q&A ` is stored as `Q&A <draft>`. readDrawFrame and the custom-shape text reader both projected that straight into ContentShape.name without decoding, so a name carrying any of the five predefined entities reached every consumer still escaped, with no way to know it was. Every other plain-text projection in this reader family already decodes -- svg:title and svg:desc via decodeOdfText, a form control's label via forms.ts, meta.xml's own fields via metadata.ts. An attribute value takes decodeXmlText directly rather than decodeOdfText, since text:s/text:tab/text:line-break are element-level spellings that cannot occur inside an attribute at all. A pre-existing reader defect, exposed for the first time by the odp writer: nothing wrote draw:name before it. --- packages/odf.js/src/typed/draw/shapes.ts | 11 +++++++++-- .../odf.js/src/typed/odp/write-round-trip.test.ts | 9 +++++++++ packages/odf.js/src/typed/odp/write.test.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/typed/draw/shapes.ts b/packages/odf.js/src/typed/draw/shapes.ts index 4c3f45efc..74d60afb9 100644 --- a/packages/odf.js/src/typed/draw/shapes.ts +++ b/packages/odf.js/src/typed/draw/shapes.ts @@ -11,6 +11,7 @@ import type { import type { XmlElement, XmlNode } from "../../model/node"; import type { Package } from "../../model/package"; import { attrValue, childrenWithTag, elementsWithTag } from "../../xml/query"; +import { decodeXmlText } from "../../xml/entities"; import { base64ToBytes } from "../../util/base64"; import { sniffImageFormat } from "../../image/sniff"; import { resolveStyleElementChain } from "../shared/cascade"; @@ -91,6 +92,12 @@ function readFrameInsets(frame: XmlElement, pkg: Package): FrameInsets { return insets; } +// A shape's own draw:name, decoded. odf.js parses with processEntities:false (xml/parse.ts), so an attribute value in this package's model is stored exactly as it appears in the source XML -- a shape named `Q&A ` is the literal five-character-entity string `Q&A <draft>` here, and projecting that into a plain ContentShape.name without decoding hands every consumer an escaped string it has no way to know is escaped. Every other plain-text projection in this reader family already decodes (svg:title/svg:desc via decodeOdfText, a form control's label via forms.ts, meta.xml's own fields via metadata.ts); an attribute value takes xml/entities.ts's decodeXmlText directly rather than decodeOdfText, since text:s/text:tab/text:line-break are element-level spellings that cannot occur inside an attribute at all. +function readDrawName(element: XmlElement): string | undefined { + const raw = attrValue(element, "draw:name"); + return raw === undefined ? undefined : decodeXmlText(raw); +} + // A draw:frame's own alternative text: svg:title (ODF's short title) preferred, svg:desc (its long description) used when a frame carries only the latter -- both are plain-text DIRECT CHILD ELEMENTS of draw:frame itself, not attributes, confirmed against real LibreOffice 26.2 output (a Calc image whose UNO Title/Description properties were both set round-trips as `......` siblings of the frame's own draw:image). ContentImageBlockSchema models exactly one altText string, so the two are collapsed with title first: LibreOffice's own HTML export writes svg:title into `alt=`, making it the closer match, and a frame carrying only a description still has genuine alternative text worth surfacing rather than dropping. Decoded via text.ts's own decodeOdfText (not a bare text-node concatenation) for the same reason every other ODF text getter in this package uses it -- a title/description containing a run of literal spaces or a tab is stored as text:s/text:tab elements. function readFrameAltText(frame: XmlElement): string | undefined { const title = childrenWithTag(frame, "svg:title")[0]; @@ -196,7 +203,7 @@ export function readDrawFrame( } const geometry = composeOdfGroupTransform(groupFunctions, ownGeometry); return { - name: attrValue(frame, "draw:name"), + name: readDrawName(frame), frame: geometry.frame, rotationDeg: geometry.rotationDeg, ...readFrameInsets(frame, pkg), @@ -541,7 +548,7 @@ function readCustomShapeAsTextShape( "draw:enhanced-geometry", )[0]; return { - name: attrValue(element, "draw:name"), + name: readDrawName(element), frame: geometry.frame, rotationDeg: geometry.rotationDeg, ...readFrameInsets(element, pkg), diff --git a/packages/odf.js/src/typed/odp/write-round-trip.test.ts b/packages/odf.js/src/typed/odp/write-round-trip.test.ts index b63481be3..ce99b4a9e 100644 --- a/packages/odf.js/src/typed/odp/write-round-trip.test.ts +++ b/packages/odf.js/src/typed/odp/write-round-trip.test.ts @@ -415,3 +415,12 @@ describe("writeOdpContent: rotated geometry near the page origin", () => { expect(writtenShapes[0]!.frame).toEqual(tiny); }); }); + +describe("writeOdpContent: a shape name carrying XML special characters", () => { + it("round-trips an ampersand and angle brackets without leaving the name entity-escaped", () => { + const name = "Q&A \"quoted\" 'single'"; + const written = roundTrip(documentOf([slide([shape({ name })])])); + expect(written.slides[0]!.shapes[0]!.name).toBe(name); + expectRoundTrip(documentOf([slide([shape({ name })])])); + }); +}); diff --git a/packages/odf.js/src/typed/odp/write.test.ts b/packages/odf.js/src/typed/odp/write.test.ts index cfdcd2390..97132c715 100644 --- a/packages/odf.js/src/typed/odp/write.test.ts +++ b/packages/odf.js/src/typed/odp/write.test.ts @@ -223,6 +223,14 @@ describe("writeOdpContent: shape geometry", () => { expect(attrValue(frame, "draw:name")).toBe("Title Placeholder"); }); + it("escapes an XML special character in draw:name, storing the entity form in the attribute", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ name: "Q&A " })])]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "draw:name")).toBe("Q&A <draft>"); + }); + // The ODF `length` datatype has no exponent form at all (typed/shared/units.ts's own LENGTH_PATTERN), so an svg:*/translate() component in JavaScript's own exponent spelling is invalid ODF that this package's reader silently discards -- taking the translate(), or the whole shape, with it. A frame sitting at the page origin is the ordinary way to reach that magnitude: the rotation inverse's own terms cancel to trig rounding dust rather than to a clean zero. This exact case (a 100x100 frame at the origin, rotated 270 degrees) writes translate(7.105427357601002e-15pt ...) without the fix. it("writes no exponent-notation length for a rotated frame at the page origin, where the translate() components cancel to rounding dust", () => { const pkg = writeOdpContent( From 4707308546080477dda938b19afa8e3ccb2e03fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:28:32 +0100 Subject: [PATCH 10/15] feat(odf.js): write a shape's paint order as draw:z-index paintOrder is the one ContentShape field the odp/odg reader always populates (paintOrderKey stamps every draw:frame it walks), so the odp writer's canonical form carrying frame/insets/name/rotationDeg/blocks and nothing else made every odp -> odp and odg -> odp conversion silently lose explicit z-ordering. draw:z-index is the one spelling ODF has for a stacking order independent of document position, it is what the reader already resolves, and typed/ods/write.ts's anchored-drawing frames already emit one, so writing it here is existing precedent rather than a new convention. A paintOrder ODF cannot spell -- negative, or fractional, which ContentShapeSchema permits deliberately so a value can later be inserted between two existing ones -- writes no attribute rather than being rounded onto a neighbouring shape's order, and the reader's own document-encounter fallback then says the same thing. canonicalShape reads that decision off odfZIndexOf rather than re-deriving it, so the writer and the canonical form cannot disagree. canonicalShape now also names the fields it drops instead of leaving them silent, matching normaliseOdtContent's own convention. fontScale and lineSpacingReduction are DrawingML a:normAutofit percentages -- the shrink factor PowerPoint computed and stored -- and ODF's own autofit vocabulary is a mode flag carrying no computed factor, so writing one would invent a fact the input never stated while still losing the one it did. sourcePath and source are dropped for the reasons the odt writer already gives for the identical fields. Confirmed against LibreOffice 26.2.5.2: a slide whose shapes were written in an array order deliberately unlike their own paintOrder (3, 1, 0, 2) comes back through --convert-to fodp reordered into paintOrder order, with the attribute dropped and the elements physically moved instead. --- .../odf.js/src/typed/draw/write-shapes.ts | 20 +++++++++ .../src/typed/odp/write-round-trip.test.ts | 43 +++++++++++++++++++ packages/odf.js/src/typed/odp/write.test.ts | 33 ++++++++++++++ packages/odf.js/src/typed/odp/write.ts | 13 +++++- 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/draw/write-shapes.ts b/packages/odf.js/src/typed/draw/write-shapes.ts index c66c9aac9..942703af9 100644 --- a/packages/odf.js/src/typed/draw/write-shapes.ts +++ b/packages/odf.js/src/typed/draw/write-shapes.ts @@ -289,13 +289,33 @@ function writeShapeImage( // --- the shape writer ----------------------------------------------------------------------------------------------- +// --- paint order: ContentShape.paintOrder -> draw:z-index ----------------------------------------------------------- +// +// draw:z-index is the ONE spelling ODF has for a shape's stacking order independent of its position in the document, and typed/draw/shapes.ts's own paintOrderKey already reads it back (see that module's own PAINT ORDER note for the schema citation -- xsd:nonNegativeInteger, valid on draw:frame -- and for the empirical finding that real LibreOffice output never emits it, relying on document order alone, which its own reader falls back to). Writing it is therefore not a new convention this module invents: typed/ods/write.ts's own anchored-drawing frames already carry one, and the odp/odg reader already resolves one. +// +// A paintOrder ODF cannot spell -- negative, or fractional (ContentShapeSchema declares a plain z.number() deliberately, "to allow fractional insertion between two existing values later") -- is NOT approximated by rounding it to a neighbouring integer: that would silently reorder a shape past a sibling, changing what the document renders as. The attribute is omitted instead, and the reader's own document-encounter fallback then supplies this shape's position in its page's own shape order, which is exactly what an unspelled paint order means. typed/odp/write.ts's canonicalShape states that fallback as part of its canonical form, reading it back off THIS function so the two can never disagree. +export function odfZIndexOf( + paintOrder: number | undefined, +): number | undefined { + if ( + paintOrder === undefined || + !Number.isInteger(paintOrder) || + paintOrder < 0 + ) { + return undefined; + } + return paintOrder; +} + // One ContentShape -> the draw:frame element typed/draw/shapes.ts's own readDrawFrame reads back: geometry (svg:x/y/width/height, or draw:transform when rotated), an interned graphic-family style carrying the shape's own text insets (when non-zero), and exactly one of table:table/draw:text-box/draw:image as decided by planShapeContent. `listState` is the caller's own ListPlanState (typed/shared/list.ts) -- see planShapeContent's own note on why this module never decides its own threading policy. export function writeDrawFrame( shape: ContentShape, listState: ListPlanState, state: DrawShapeWriteState, ): XmlElement { + const zIndex = odfZIndexOf(shape.paintOrder); const attributes: Record = { + ...(zIndex === undefined ? {} : { "draw:z-index": String(zIndex) }), ...frameGeometryAttrs(shape.frame, shape.rotationDeg), }; if (shape.name !== undefined) { diff --git a/packages/odf.js/src/typed/odp/write-round-trip.test.ts b/packages/odf.js/src/typed/odp/write-round-trip.test.ts index ce99b4a9e..4ee06f956 100644 --- a/packages/odf.js/src/typed/odp/write-round-trip.test.ts +++ b/packages/odf.js/src/typed/odp/write-round-trip.test.ts @@ -424,3 +424,46 @@ describe("writeOdpContent: a shape name carrying XML special characters", () => expectRoundTrip(documentOf([slide([shape({ name })])])); }); }); + +// paintOrder is the one ContentShape field the reader ALWAYS populates (typed/draw/shapes.ts's own paintOrderKey stamps every frame it walks), so the writer dropping it was a real, live loss for any odp -> odp or odg -> odp conversion. See typed/odp/write.ts's canonicalShape note for the exact canonical form, and typed/draw/write-shapes.ts's odfZIndexOf for what ODF can and cannot spell. +describe("writeOdpContent: shape paint order", () => { + it("round-trips an explicit paintOrder that disagrees with document order", () => { + const document = documentOf([ + slide([ + shape({ + paintOrder: 9, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 }, + }), + shape({ + paintOrder: 4, + frame: { xPt: 120, yPt: 0, widthPt: 100, heightPt: 50 }, + }), + ]), + ]); + const written = roundTrip(document); + expect(written.slides[0]!.shapes.map((s) => s.paintOrder)).toEqual([9, 4]); + expectRoundTrip(document); + }); + + it("gives a shape with no paintOrder the reader's own document-encounter order, per slide", () => { + const document = documentOf([ + slide([shape(), shape(), shape()]), + slide([shape(), shape()]), + ]); + const written = roundTrip(document); + expect(written.slides[0]!.shapes.map((s) => s.paintOrder)).toEqual([ + 0, 1, 2, + ]); + expect(written.slides[1]!.shapes.map((s) => s.paintOrder)).toEqual([0, 1]); + expectRoundTrip(document); + }); + + it("falls back to document-encounter order for a paintOrder ODF cannot spell, rather than rounding it", () => { + const document = documentOf([ + slide([shape({ paintOrder: 1.5 }), shape({ paintOrder: -3 })]), + ]); + const written = roundTrip(document); + expect(written.slides[0]!.shapes.map((s) => s.paintOrder)).toEqual([0, 1]); + expectRoundTrip(document); + }); +}); diff --git a/packages/odf.js/src/typed/odp/write.test.ts b/packages/odf.js/src/typed/odp/write.test.ts index 97132c715..747580f8d 100644 --- a/packages/odf.js/src/typed/odp/write.test.ts +++ b/packages/odf.js/src/typed/odp/write.test.ts @@ -464,3 +464,36 @@ describe("writeOdpContent: speaker notes", () => { expect(paragraphs).toHaveLength(2); }); }); + +// ContentShape.paintOrder -> draw:z-index, the one spelling ODF has for a stacking order independent of document position, and the one typed/draw/shapes.ts's own paintOrderKey already reads back. See typed/draw/write-shapes.ts's odfZIndexOf for why a paintOrder ODF cannot spell writes no attribute at all rather than a rounded approximation. +describe("writeOdpContent: shape paint order", () => { + it("writes a shape's paintOrder as draw:z-index", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ paintOrder: 7 }), shape({ paintOrder: 2 })])]), + ); + const frames = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame"); + expect(frames.map((frame) => attrValue(frame, "draw:z-index"))).toEqual([ + "7", + "2", + ]); + }); + + it("writes no draw:z-index at all for a shape with no paintOrder, leaving the reader's own document-encounter order to say it", () => { + const pkg = writeOdpContent(documentOf([slide([shape()])])); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "draw:z-index")).toBeUndefined(); + }); + + it("writes no draw:z-index for a paintOrder ODF's own xsd:nonNegativeInteger cannot spell, rather than rounding it onto a neighbouring shape's order", () => { + const pkg = writeOdpContent( + documentOf([ + slide([shape({ paintOrder: 1.5 }), shape({ paintOrder: -1 })]), + ]), + ); + const frames = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame"); + expect(frames.map((frame) => attrValue(frame, "draw:z-index"))).toEqual([ + undefined, + undefined, + ]); + }); +}); diff --git a/packages/odf.js/src/typed/odp/write.ts b/packages/odf.js/src/typed/odp/write.ts index 0bb546d80..482fff432 100644 --- a/packages/odf.js/src/typed/odp/write.ts +++ b/packages/odf.js/src/typed/odp/write.ts @@ -32,6 +32,7 @@ import { } from "../shared/canonicalise"; import { createDrawShapeWriteState, + odfZIndexOf, planShapeContent, writeDrawShapes, } from "../draw/write-shapes"; @@ -84,8 +85,15 @@ function canonicalMetadata(metadata: LayoutMetadata): LayoutMetadata { // One ContentShape in the exact shape reading the written document back produces: geometry/insets/name pass through verbatim (see this module's own top-of-file note on rotationDeg's floating-point caveat specifically), and `blocks` is rebuilt from whichever of the three content kinds planShapeContent (typed/draw/write-shapes.ts) resolves the INPUT's own blocks to -- the identical validation and list-numId canonicalisation the writer itself runs, so this function and writeDrawFrame can never disagree about which shapes are writable at all. // // THE ONE FORCED FACT THIS FUNCTION RESTATES RATHER THAN PASSING THROUGH: an image's own widthPt/heightPt become the ENCLOSING SHAPE's frame widthPt/heightPt, never the input image block's own values. ODF's draw:image has no size of its own at all -- it is a bare content reference inside a draw:frame, and the frame's own svg:width/svg:height IS the rendered size (typed/draw/shapes.ts's own readDrawImageBlock note: "The image renders at the FRAME's own resolved size, not the source image's native pixel dimensions"). A caller-supplied image block whose width/height genuinely differ from its enclosing shape's frame is therefore not a smaller round trip, it is describing something ODF cannot express -- the frame wins, silently overriding the block's own stated size, exactly as reading the written document back will. +// +// PAINT ORDER is always present on the way back, never optional: readDrawFrame's own walker stamps every shape it reads (typed/draw/shapes.ts's paintOrderKey), so this canonical form states the same value. A paintOrder ODF can spell (a non-negative integer -- see typed/draw/write-shapes.ts's odfZIndexOf, which this reads the answer off rather than re-deriving) is written as draw:z-index and comes back exactly; anything else -- absent, negative, or fractional -- writes no attribute and comes back as the shape's own DOCUMENT-ENCOUNTER index, which for this writer's output is simply its position in its slide's own shapes array (this writer emits one top-level draw:frame per shape, in array order, and the reader's counter is per-slide and counts exactly those). +// +// THE THREE FIELDS THIS FUNCTION DROPS, each named rather than left silent, matching typed/odt/write.ts's own normaliseOdtContent convention: +// - fontScale / lineSpacingReduction are DrawingML's own a:normAutofit percentages -- the font-shrink factor PowerPoint COMPUTED to make overflowing text fit, stored in the file (ooxml.js's src/typed/pptx/read.ts reads both). ODF stores no such computed factor anywhere: its own autofit vocabulary (draw:fit-to-size on the shape's graphic properties) is a MODE flag, saying that a consumer should shrink text to fit, not by how much. Writing it would therefore invent a fact the input never stated (a mode, from a factor) while still losing the factor, and this package's own reader reads nothing back from it -- so the loss is stated here instead of approximated. A real pptx -> odp conversion drops autofit shrink state, and this is the line that says so. +// - `sourcePath` and `source` are dropped for the reasons odt's own writer already gives for the identical fields: sourcePath is a READER's own diagnostic path (the writer has no document to have read it from), and residue is quarantined, opaque text belonging to whichever format produced it -- re-emitting it into a different document would be actively wrong rather than merely incomplete. Not a gap this writer introduces; existing, consistent precedent. function canonicalShape( shape: ContentShape, + documentIndex: number, listState: ListPlanState, ): ContentShape { const content = planShapeContent(shape.blocks, listState); @@ -109,6 +117,7 @@ function canonicalShape( insetTopPt: shape.insetTopPt, insetRightPt: shape.insetRightPt, insetBottomPt: shape.insetBottomPt, + paintOrder: odfZIndexOf(shape.paintOrder) ?? documentIndex, blocks, }; if (shape.name !== undefined) { @@ -127,7 +136,9 @@ function canonicalSlide( ): ContentSlide { return { size: slide.size, - shapes: slide.shapes.map((shape) => canonicalShape(shape, listState)), + shapes: slide.shapes.map((shape, index) => + canonicalShape(shape, index, listState), + ), notes: slide.notes, }; } From 92a44de7a74b004dc2a46ae79e3b4db8e7daf30f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:28:50 +0100 Subject: [PATCH 11/15] docs(odf.js): state what the LibreOffice flat-XML check actually establishes The odp verification section said the conversion carried every piece of content "byte-for-byte", which is not what was checked and could not be: --convert-to fodp re-serialises the whole document through LibreOffice's own writer, renaming styles, reordering attributes, and adding defaults of its own, so its bytes differ from writeOdp's by construction. What the check establishes is that every authored string appears verbatim in that re-serialised output, which the section now says instead. Records what the re-run added: rotated frames at the page origin, shapes whose paint order disagrees with their document order, a draw:name carrying XML special characters, LibreOffice honouring draw:z-index by physically reordering the elements, and the one class of defect a green soffice run cannot catch -- LibreOffice's own length parser accepts values outside the OASIS length datatype, so exponent-notation lengths converted cleanly while being invalid ODF. Also documents paintOrder writing as draw:z-index and fontScale/lineSpacingReduction being dropped rather than approximated. --- packages/odf.js/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index c43583bc8..6f21c4b7f 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -175,18 +175,18 @@ const bytes = encodePackage(pkg); // Package -> bytes const pkgFromContent = writeOdpContent(contentDocument); // the flat ContentDocument level, same shape readOdpContent returns ``` -A presentation is a sequence of slides, each a positioned bag of shapes rather than flowed blocks — `writeOdp` writes one `style:master-page`/`style:page-layout` pair per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level `p:sldSz`) and one `draw:page` per slide, its shapes written by `typed/draw/write-shapes.ts`'s `writeDrawShapes` — the shape writer this package factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, so a future `.odg` writer reuses it rather than reimplementing shape geometry, insets, and text/table/image content from scratch. A shape's own `frame`/`rotationDeg` write as plain `svg:x`/`svg:y`/`svg:width`/`svg:height` when unrotated, or `svg:width`/`svg:height` plus a `draw:transform="rotate(...) translate(...)"` when rotated — the exact algebraic inverse of the reader's own `resolveOdfShapeGeometry`, exact up to ordinary floating-point rounding on a real round trip. A shape's own text (formatted runs, alignment, spacing, indentation, bullet/ordered lists nested per level) writes as a `draw:text-box`; a shape whose sole block is a table or an image writes that content directly as the frame's own `table:table`/`draw:image`, since a real `draw:frame` can hold exactly one of the three, never a mix — a combination ODF has no spelling for is refused **by name**, the same fidelity-construct stance `writeOdt` takes, and so is a heading or a page break inside a shape's own text (a `draw:text-box` has no `text:h` reading path and no page concept at all). Speaker notes write as `presentation:notes`, one `text:p` per line. `flattenTree(readOdp(writeOdp(document)))` reproduces `document` up to the normalisation `normaliseOdpContent` states explicitly — including the one fact ODF forces rather than this writer choosing it: an image's own `widthPt`/`heightPt` become its enclosing shape's own frame size, since a `draw:image` has no size of its own at all inside a `draw:frame`. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception `writeOdt` makes. `.odg`/`.sxi` are not covered — see [Status](#status). +A presentation is a sequence of slides, each a positioned bag of shapes rather than flowed blocks — `writeOdp` writes one `style:master-page`/`style:page-layout` pair per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level `p:sldSz`) and one `draw:page` per slide, its shapes written by `typed/draw/write-shapes.ts`'s `writeDrawShapes` — the shape writer this package factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, so a future `.odg` writer reuses it rather than reimplementing shape geometry, insets, and text/table/image content from scratch. A shape's own `frame`/`rotationDeg` write as plain `svg:x`/`svg:y`/`svg:width`/`svg:height` when unrotated, or `svg:width`/`svg:height` plus a `draw:transform="rotate(...) translate(...)"` when rotated — the exact algebraic inverse of the reader's own `resolveOdfShapeGeometry`, exact up to ordinary floating-point rounding on a real round trip. A shape's own text (formatted runs, alignment, spacing, indentation, bullet/ordered lists nested per level) writes as a `draw:text-box`; a shape whose sole block is a table or an image writes that content directly as the frame's own `table:table`/`draw:image`, since a real `draw:frame` can hold exactly one of the three, never a mix — a combination ODF has no spelling for is refused **by name**, the same fidelity-construct stance `writeOdt` takes, and so is a heading or a page break inside a shape's own text (a `draw:text-box` has no `text:h` reading path and no page concept at all). A shape's own `paintOrder` writes as `draw:z-index`, the one spelling ODF has for a stacking order independent of document position, and the one the reader already resolves; a `paintOrder` ODF's own `xsd:nonNegativeInteger` cannot spell (a negative or fractional one) writes no attribute rather than a rounded approximation that would reorder it past a sibling. Speaker notes write as `presentation:notes`, one `text:p` per line. `flattenTree(readOdp(writeOdp(document)))` reproduces `document` up to the normalisation `normaliseOdpContent` states explicitly — including the one fact ODF forces rather than this writer choosing it: an image's own `widthPt`/`heightPt` become its enclosing shape's own frame size, since a `draw:image` has no size of its own at all inside a `draw:frame`. A shape's `fontScale`/`lineSpacingReduction` are dropped and say so: they are DrawingML's own `a:normAutofit` percentages — the shrink factor PowerPoint _computed_ and stored — and ODF's own autofit vocabulary is a mode flag with no computed factor anywhere, so a pptx → odp conversion loses autofit shrink state rather than having it approximated into something the format never said. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception `writeOdt` makes. `.odg`/`.sxi` are not covered — see [Status](#status). #### LibreOffice verification (`writeOdp`) -Round-tripping through this package's own reader proves internal consistency, not that a real, independent ODF implementation accepts the result — so a sample `.odp` covering multiple slides (one widescreen, one A4-portrait, exercising per-slide page geometry), a shape with mixed bold/italic/plain runs and centred alignment, a rotated shape (`draw:transform`), a nested bullet list, a shape carrying a table (including a merged cell) as its sole content, a shape carrying an image as its sole content, and multi-line speaker notes was built with `writeOdp` and checked against LibreOffice 26.2.5.2 directly (`soffice --headless`), matching this package family's own established verification bar (see `doc-codec`'s README and this package's own `.sxw`/`.ods`/`.sxc` writer PRs): +Round-tripping through this package's own reader proves internal consistency, not that a real, independent ODF implementation accepts the result — so a sample `.odp` covering multiple slides (one widescreen, one A4-portrait, exercising per-slide page geometry), a shape with mixed bold/italic/plain runs and centred alignment, rotated shapes (`draw:transform`) both away from and at the page origin, shapes whose `paintOrder` disagrees with their document order, a shape whose `draw:name` carries XML special characters, a nested bullet list, a shape carrying a table (including a merged cell) as its sole content, a shape carrying an image as its sole content, and multi-line speaker notes was built with `writeOdp` and checked against LibreOffice 26.2.5.2 directly (`soffice --headless`), matching this package family's own established verification bar (see `doc-codec`'s README and this package's own `.sxw`/`.ods`/`.sxc` writer PRs): ```sh soffice --headless --convert-to fodp sample.odp # flat XML, for text-content inspection soffice --headless --convert-to pdf sample.odp # rendered pages, for visual inspection ``` -Both commands exit `0` with no error. The flat-XML conversion carries every piece of real content byte-for-byte (all three `draw:page`s, the `table:table`, the `draw:transform`, both `text:list`s, and every string of authored text — titles, bullets, table cells, the rotated shape's own text — found verbatim in the re-serialised output), and the rendered PDF (3 pages, matching the 3 slides) visually confirms the bold/italic mixed formatting, the centred title, the nested bullet list, the shape rotated clockwise by the requested angle, the table with its merged cell, and the A4-portrait slide's own different page geometry, all laid out correctly with no visible loss. +Both commands exit `0` with no error. What the flat-XML conversion establishes is that every authored string appears **verbatim** in LibreOffice's re-serialised output — all three `draw:page`s, the `table:table`, the `draw:transform`, both `text:list`s, and every string of authored text: titles, bullets, table cells, the rotated shape's own text. It is not a byte-identity check, and could not be: `--convert-to fodp` re-serialises the whole document through LibreOffice's own writer, which renames styles, reorders and reformats attributes, and adds defaults of its own, so its bytes differ from `writeOdp`'s by construction. The rendered PDF (3 pages, matching the 3 slides) visually confirms the bold/italic mixed formatting, the centred title, the nested bullet list, the shape rotated clockwise by the requested angle, the table with its merged cell, and the A4-portrait slide's own different page geometry, all laid out correctly with no visible loss. **One gap found and fixed during this verification**: an earlier version of this writer's `presentation:notes` carried no `style:page-layout-name` attribute at all. Real LibreOffice output always states one (a notes page is sized for printing, independent of whatever on-screen size its slide's own page-layout states), and every real producer's own notes page references it directly — `writeOdp` now mints one page-layout for the whole presentation's own notes pages, lazily, the first time any slide actually has notes to write. @@ -194,6 +194,10 @@ Both commands exit `0` with no error. The flat-XML conversion carries every piec Nothing between a writer and the emitted bytes checks that a qualified name's prefix is actually bound — `src/xml/build.ts` writes whatever name an element carries — so this failure mode is silent by construction, and round-trips perfectly through this package's own (prefix-string-matching, namespace-unaware) reader. `src/package-io/namespace-declarations.test.ts` now audits every prefix each writer emits, across element and attribute names at any depth, against what that part's own root declares, so the next writer to reach for an undeclared prefix fails a test instead of shipping a document no XML parser will accept. +**`draw:z-index` is honoured by a real consumer**, confirmed on the same sample: its slide-1 shapes were written in an array order deliberately unlike their own `paintOrder` (`3, 1, 0, 2`), and LibreOffice re-emitted them in `paintOrder` order — dropping the attribute and physically reordering the elements instead, the mirror image of what `typed/draw/shapes.ts`'s own reading of `draw:z-index` already documents finding in LibreOffice-authored files. + +**One class of defect this verification cannot catch, worth stating so nobody reads more into a green `soffice` run than it proves**: LibreOffice's own length parser accepts values outside the OASIS `length` datatype. A `translate()` component written in JavaScript's exponent notation (`7.105427357601002e-15pt` — the ordinary result of the rotation inverse's terms cancelling for a frame at the page origin) converts through `--convert-to fodp` with no error and lands at the right place, so the file looked correct by every check above while being invalid ODF that this package's own (spec-conforming) reader silently discarded — dropping the whole `translate()` for a rotated frame, and the whole shape for an unrotated one whose `svg:x`/`svg:y` were the unparseable values. `formatOdfLength` now emits fixed-point decimal only, and the regression is pinned by unit tests rather than by a `soffice` run, since `soffice` would have passed either way. + ### The flat `ContentDocument` level Beneath each package-native reader sits the flat reader it is built on, unchanged in behaviour and exported under a `*Content` name. Reach for these when you work in `document-schema.js`'s flat codec-exchange form — as `documents.js`'s own conversion pipeline does — rather than in the tree: From 22d3f40830fb614fb072dcb2bdd3c5538f8c52c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 00:29:15 +0100 Subject: [PATCH 12/15] refactor(odf.js): keep the draw-shape write seam out of the published surface createDrawShapeWriteState, DrawShapeWriteState, and planShapeContent were exported from the package entry point for a consumer that does not exist: the state constructor takes a StyleRegistry plus the raw XmlElement container automatic styles get appended to, which is internal plumbing odp's own writer and a future odg writer hold between themselves, not a shape any external caller has a use for. ShapeContentPlan goes with them, since only planShapeContent produces one. They stay ordinary exports of their own module, which is all an in-package caller needs. Publishing them would freeze that plumbing into the package's public API ahead of any concrete requirement for it, and turn every later change to it into a breaking one. Nothing outside this package referenced any of the four, and none of them has ever been in a released version -- they were added alongside the odp writer on this same unreleased branch -- so this removes an API surface no consumer can be holding. --- packages/odf.js/src/index.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/odf.js/src/index.ts b/packages/odf.js/src/index.ts index 0d64bf35f..791e8ba28 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -243,16 +243,9 @@ export { 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. -export { - createDrawShapeWriteState, - planShapeContent, - writeDrawFrame, - writeDrawShapes, -} from "./typed/draw/write-shapes"; -export type { - DrawShapeWriteState, - ShapeContentPlan, -} from "./typed/draw/write-shapes"; +// +// 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 { From 1ae151cb8815d4ef69e31bc06045637637ee834a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 01:29:22 +0100 Subject: [PATCH 13/15] fix(odf.js): refuse a paintOrder beyond Number.isSafeInteger's own bound odfZIndexOf accepted any Number.isInteger paintOrder, but Number.isInteger(1e21) is true and String(1e21) is "1e+21" -- the exact exponent-notation defect this branch's own formatOdfLength fix already closed for lengths, reintroduced here for draw:z-index. A value beyond 2^53 now writes no attribute at all, matching how a negative or fractional paintOrder already degrades to the reader's own document-encounter order rather than a spec-invalid attribute. --- packages/odf.js/src/typed/draw/write-shapes.ts | 4 ++-- packages/odf.js/src/typed/odp/write.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/typed/draw/write-shapes.ts b/packages/odf.js/src/typed/draw/write-shapes.ts index 942703af9..531c3b42b 100644 --- a/packages/odf.js/src/typed/draw/write-shapes.ts +++ b/packages/odf.js/src/typed/draw/write-shapes.ts @@ -293,13 +293,13 @@ function writeShapeImage( // // draw:z-index is the ONE spelling ODF has for a shape's stacking order independent of its position in the document, and typed/draw/shapes.ts's own paintOrderKey already reads it back (see that module's own PAINT ORDER note for the schema citation -- xsd:nonNegativeInteger, valid on draw:frame -- and for the empirical finding that real LibreOffice output never emits it, relying on document order alone, which its own reader falls back to). Writing it is therefore not a new convention this module invents: typed/ods/write.ts's own anchored-drawing frames already carry one, and the odp/odg reader already resolves one. // -// A paintOrder ODF cannot spell -- negative, or fractional (ContentShapeSchema declares a plain z.number() deliberately, "to allow fractional insertion between two existing values later") -- is NOT approximated by rounding it to a neighbouring integer: that would silently reorder a shape past a sibling, changing what the document renders as. The attribute is omitted instead, and the reader's own document-encounter fallback then supplies this shape's position in its page's own shape order, which is exactly what an unspelled paint order means. typed/odp/write.ts's canonicalShape states that fallback as part of its canonical form, reading it back off THIS function so the two can never disagree. +// A paintOrder ODF cannot spell -- negative, fractional (ContentShapeSchema declares a plain z.number() deliberately, "to allow fractional insertion between two existing values later"), or too large to round-trip through JavaScript's own shortest-round-trip String() without switching to exponent notation (Number.isSafeInteger's own 2^53 bound, well under xsd:nonNegativeInteger's own unbounded range but the largest this codec's String(zIndex) call below can spell without repeating the exact "e" defect formatOdfLength's own expandExponential was written to close, see that function's own note) -- is NOT approximated by rounding it to a neighbouring integer: that would silently reorder a shape past a sibling, changing what the document renders as. The attribute is omitted instead, and the reader's own document-encounter fallback then supplies this shape's position in its page's own shape order, which is exactly what an unspelled paint order means. typed/odp/write.ts's canonicalShape states that fallback as part of its canonical form, reading it back off THIS function so the two can never disagree. export function odfZIndexOf( paintOrder: number | undefined, ): number | undefined { if ( paintOrder === undefined || - !Number.isInteger(paintOrder) || + !Number.isSafeInteger(paintOrder) || paintOrder < 0 ) { return undefined; diff --git a/packages/odf.js/src/typed/odp/write.test.ts b/packages/odf.js/src/typed/odp/write.test.ts index 747580f8d..dd4c65930 100644 --- a/packages/odf.js/src/typed/odp/write.test.ts +++ b/packages/odf.js/src/typed/odp/write.test.ts @@ -496,4 +496,13 @@ describe("writeOdpContent: shape paint order", () => { undefined, ]); }); + + // Number.isInteger(1e21) is true, and String(1e21) is "1e+21" -- an integer beyond Number.isSafeInteger's 2^53 bound reaches JavaScript's own exponent-notation threshold before it reaches any bound xsd:nonNegativeInteger itself states, the exact failure class formatOdfLength's own expandExponential exists to close for lengths. odfZIndexOf must refuse one rather than writing a draw:z-index no XML integer datatype can spell. + it("writes no draw:z-index for a paintOrder beyond Number.isSafeInteger's own bound, rather than emitting exponent notation", () => { + const pkg = writeOdpContent( + documentOf([slide([shape({ paintOrder: 1e21 })])]), + ); + const frame = childrenWithTag(pagesOf(pkg)[0]!, "draw:frame")[0]!; + expect(attrValue(frame, "draw:z-index")).toBeUndefined(); + }); }); From 6f9374fba9ff83096deb5d0f80f12f4813395919 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 01:29:34 +0100 Subject: [PATCH 14/15] test(odf.js): pin the odg custom-shape draw:name decode against a mutation readCustomShapeAsTextShape's own draw:name read was fixed to go through readDrawName rather than a bare attrValue, but nothing pinned it -- the whole suite passed with that call site mutated back to the unescaped form. draw:frame's own sibling call site was already covered. --- packages/odf.js/src/typed/draw/shapes.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/odf.js/src/typed/draw/shapes.test.ts b/packages/odf.js/src/typed/draw/shapes.test.ts index 2ab581cb2..be5cb573a 100644 --- a/packages/odf.js/src/typed/draw/shapes.test.ts +++ b/packages/odf.js/src/typed/draw/shapes.test.ts @@ -810,6 +810,29 @@ describe("readDrawPageContent: draw:custom-shape presets", () => { }); }); + // readCustomShapeAsTextShape's own draw:name read goes through readDrawName, not a bare attrValue -- a second call site of the same fix draw:frame's own readDrawFrame already had (S3, ExaDev/documents.js#900), pinned here since mutating this call site back to attrValue left the whole odf.js suite green. + it("decodes an unrecognised preset's own draw:name the same way draw:frame does", () => { + const shape = el( + "draw:custom-shape", + { + "draw:name": "Q&A <draft>", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el("text:p", {}, [txt("Hello")]), + el("draw:enhanced-geometry", { + "svg:viewBox": "0 0 21600 21600", + "draw:type": "smiley", + }), + ], + ); + const { shapes } = readDrawPageContent([shape], { parts: {} }); + expect(shapes[0]?.name).toBe("Q&A "); + }); + it("an unrecognised preset's whole draw:enhanced-geometry element quarantines in the salvaged text shape's residue, so the preset definition survives beside the approximation", () => { const shape = el( "draw:custom-shape", From 92e447109e69b05682fb891d09db3dce13894cd8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 01:29:43 +0100 Subject: [PATCH 15/15] docs(odf.js): name all five fields canonicalShape drops, not four of them The header claimed "THE THREE FIELDS THIS FUNCTION DROPS" while the bullets underneath named four (fontScale, lineSpacingReduction, sourcePath, source), and canonicalShape drops a fifth -- frames -- that went unmentioned entirely, in a comment whose whole point is that nothing is left silent. normaliseOdtContent already names all three of sourcePath/source/frames together for the identical reason; this states the same precedent rather than two of the three. --- packages/odf.js/src/typed/odp/write.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/typed/odp/write.ts b/packages/odf.js/src/typed/odp/write.ts index 482fff432..7cd8e19ca 100644 --- a/packages/odf.js/src/typed/odp/write.ts +++ b/packages/odf.js/src/typed/odp/write.ts @@ -88,9 +88,9 @@ function canonicalMetadata(metadata: LayoutMetadata): LayoutMetadata { // // PAINT ORDER is always present on the way back, never optional: readDrawFrame's own walker stamps every shape it reads (typed/draw/shapes.ts's paintOrderKey), so this canonical form states the same value. A paintOrder ODF can spell (a non-negative integer -- see typed/draw/write-shapes.ts's odfZIndexOf, which this reads the answer off rather than re-deriving) is written as draw:z-index and comes back exactly; anything else -- absent, negative, or fractional -- writes no attribute and comes back as the shape's own DOCUMENT-ENCOUNTER index, which for this writer's output is simply its position in its slide's own shapes array (this writer emits one top-level draw:frame per shape, in array order, and the reader's counter is per-slide and counts exactly those). // -// THE THREE FIELDS THIS FUNCTION DROPS, each named rather than left silent, matching typed/odt/write.ts's own normaliseOdtContent convention: +// THE FIVE FIELDS THIS FUNCTION DROPS, each named rather than left silent, matching typed/odt/write.ts's own normaliseOdtContent convention: // - fontScale / lineSpacingReduction are DrawingML's own a:normAutofit percentages -- the font-shrink factor PowerPoint COMPUTED to make overflowing text fit, stored in the file (ooxml.js's src/typed/pptx/read.ts reads both). ODF stores no such computed factor anywhere: its own autofit vocabulary (draw:fit-to-size on the shape's graphic properties) is a MODE flag, saying that a consumer should shrink text to fit, not by how much. Writing it would therefore invent a fact the input never stated (a mode, from a factor) while still losing the factor, and this package's own reader reads nothing back from it -- so the loss is stated here instead of approximated. A real pptx -> odp conversion drops autofit shrink state, and this is the line that says so. -// - `sourcePath` and `source` are dropped for the reasons odt's own writer already gives for the identical fields: sourcePath is a READER's own diagnostic path (the writer has no document to have read it from), and residue is quarantined, opaque text belonging to whichever format produced it -- re-emitting it into a different document would be actively wrong rather than merely incomplete. Not a gap this writer introduces; existing, consistent precedent. +// - `sourcePath`, `source`, and `frames` are dropped for the reasons odt's own writer already gives for the identical fields (normaliseOdtContent names all three too): sourcePath is a READER's own diagnostic path (the writer has no document to have read it from), residue is quarantined, opaque text belonging to whichever format produced it -- re-emitting it into a different document would be actively wrong rather than merely incomplete -- and frames is a LAYOUT pass's own rendered-position record, which a writer that runs before any layout pass has none of to carry. Not a gap this writer introduces; existing, consistent precedent. function canonicalShape( shape: ContentShape, documentIndex: number,