From e25f717ac6845695233d40ba6efc47e8227588fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 17 Aug 2026 14:33:08 +0100 Subject: [PATCH 1/2] build!: bump document-schema.js to ^3.0.0 The migration is dependency-only for odf.js: nothing here constructs a DocumentPackage or touches the frames surface, so no code moves with the bump. The one place that tracked the old content format by literal -- formula/read.test.ts asserting formatVersion toBe(2) -- now asserts the imported CONTENT_FORMAT_VERSION constant, matching the production readers that already stamp that constant on every emitted document, so the test tracks whatever the installed schema defines instead of a number that silently rots on the next format bump. BREAKING CHANGE: odf.js's emitted ContentDocuments now carry formatVersion 3, from document-schema.js 3.0.0's CONTENT_FORMAT_VERSION; consumers still validating odf.js output against document-schema.js 2 will reject the new documents. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/typed/formula/read.test.ts | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 20a4964..a00e1c1 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "license": "MIT", "packageManager": "pnpm@11.6.0", "dependencies": { - "document-schema.js": "^2.7.17", + "document-schema.js": "^3.0.0", "fast-xml-parser": "^5.10.1", "fflate": "^0.8.3", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20b4985..9c97bb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: .: dependencies: document-schema.js: - specifier: ^2.7.17 - version: 2.7.17 + specifier: ^3.0.0 + version: 3.0.0 fast-xml-parser: specifier: ^5.10.1 version: 5.10.1 @@ -1608,8 +1608,8 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - document-schema.js@2.7.17: - resolution: {integrity: sha512-z+1XNFToTrERlxIgoeyFb4Rw5b2nP+EgFOMH/CQfIjo0V9+To0LG6jJe2JUbTUfaggvbBRAEfDAHDMPVcBPROQ==} + document-schema.js@3.0.0: + resolution: {integrity: sha512-19ARydD8C5GjTlEq3UjYgi4imX77Wg56N8cjgEGhRsS1PF1PLqblY9sLjG/b+eQTXzN1SKvDLQSOKvUwWFUa4g==} engines: {node: '>=20'} dot-prop@5.3.0: @@ -4540,7 +4540,7 @@ snapshots: dependencies: path-type: 4.0.0 - document-schema.js@2.7.17: + document-schema.js@3.0.0: dependencies: zod: 4.4.3 diff --git a/src/typed/formula/read.test.ts b/src/typed/formula/read.test.ts index a395b78..f3811d1 100644 --- a/src/typed/formula/read.test.ts +++ b/src/typed/formula/read.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Package } from '../../model/package'; import type { XmlElement } from '../../model/node'; +import { CONTENT_FORMAT_VERSION } from 'document-schema.js'; import { el, txt } from '../../xml/fragment'; import { readOdfFormula, readOdfFormulaDocument } from './read'; @@ -139,7 +140,7 @@ describe('readOdfFormulaDocument', () => { if (document.kind !== 'formula') { throw new Error('expected a formula-kind ContentDocument'); } - expect(document.formatVersion).toBe(2); + expect(document.formatVersion).toBe(CONTENT_FORMAT_VERSION); expect(document.metadata.title).toBe('Pythagoras'); expect(document.formula.starMath).toBe('f(x) = {x^2} over {2} + sqrt {x}'); expect(document.formula.mathml).toEqual(readOdfFormula(realFormulaPackage()).mathml); From 7ebdafed706c6fb7fc2520420de8a681ee53e579 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 17 Aug 2026 14:33:13 +0100 Subject: [PATCH 2/2] feat: read odt heading outline levels as headingLevel alongside styleId readParagraphOrHeading parsed text:outline-level only to re-encode it into the docx-equivalent 'Heading' styleId string, then discarded the number. The parsed value now also populates ContentParagraph.headingLevel, document-schema.js's canonical numeric heading field, so consumers that need the level as a number no longer have to parse it back out of styleId. styleId keeps its existing encoding unchanged; both derive from the same single parse and always agree. --- src/typed/odt/read.test.ts | 9 +++++++-- src/typed/odt/read.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/typed/odt/read.test.ts b/src/typed/odt/read.test.ts index efd1a06..ba337b2 100644 --- a/src/typed/odt/read.test.ts +++ b/src/typed/odt/read.test.ts @@ -78,15 +78,17 @@ describe('readOdt: kitchen-sink.odt (real LibreOffice output)', () => { expect(section.margins.rightPt).toBeCloseTo(knownLength('1.499cm'), 5); }); - it('maps a level-1 heading (text:h, text:outline-level="1") onto styleId "Heading1"', () => { + it('maps a level-1 heading (text:h, text:outline-level="1") onto styleId "Heading1" and headingLevel 1', () => { const chapterOne = asParagraph(blocks[0]); expect(chapterOne.styleId).toBe('Heading1'); + expect(chapterOne.headingLevel).toBe(1); expect(chapterOne.runs.map((r) => r.text).join('')).toBe('Chapter One'); }); - it('maps a level-2 heading onto styleId "Heading2"', () => { + it('maps a level-2 heading onto styleId "Heading2" and headingLevel 2', () => { const sectionHeading = blocks.find((b) => b.kind === 'paragraph' && b.runs[0]?.text === 'Section One Point One'); expect(asParagraph(sectionHeading).styleId).toBe('Heading2'); + expect(asParagraph(sectionHeading).headingLevel).toBe(2); }); it('reads plain multi-paragraph body text in document order', () => { @@ -163,6 +165,7 @@ describe('readOdt: kitchen-sink.odt (real LibreOffice output)', () => { const tableSection = asParagraph(blocks.find((b) => b.kind === 'paragraph' && b.runs[0]?.text === 'Table Section')); expect(tableSection.list).toBeUndefined(); expect(tableSection.styleId).toBe('Heading1'); + expect(tableSection.headingLevel).toBe(1); }); it('reads a table with a genuinely merged cell: colSpan on the anchor cell, an empty placeholder cell for the covered cell (mirroring ooxml.js\'s own vMerge-continuation convention), and the third cell unaffected', () => { @@ -192,6 +195,7 @@ describe('readOdt: kitchen-sink.odt (real LibreOffice output)', () => { expect(chapterTwoIndex).toBeGreaterThan(-1); const chapterTwo = asParagraph(blocks[chapterTwoIndex]); expect(chapterTwo.styleId).toBe('Heading1'); + expect(chapterTwo.headingLevel).toBe(1); const closing = asParagraph(blocks[chapterTwoIndex + 1]); expect(closing.runs.map((r) => r.text).join('')).toContain("second chapter's opening paragraph"); }); @@ -221,6 +225,7 @@ describe('readOdt: minimal.odt (real LibreOffice output, default/unmodified page expect(section.blocks).toHaveLength(2); const heading = asParagraph(section.blocks[0]); expect(heading.styleId).toBe('Heading1'); + expect(heading.headingLevel).toBe(1); expect(heading.runs.map((r) => r.text).join('')).toBe('Minimal Document'); const body = asParagraph(section.blocks[1]); expect(body.list).toBeUndefined(); diff --git a/src/typed/odt/read.ts b/src/typed/odt/read.ts index a44b246..676b67a 100644 --- a/src/typed/odt/read.ts +++ b/src/typed/odt/read.ts @@ -11,7 +11,7 @@ import { parseOdfLength } from '../shared/units'; // Package -> OdtDocument: the first end-to-end ODF content reader, producing GENUINE ContentSection[] values (document-schema.js's own pivot type, the one documents.js's docx flow/pagination engine already consumes) from a real .odt package. This is the concrete proof of the whole odf.js architectural bet -- that odt and docx can share one pivot and one layout algorithm despite being completely unrelated XML formats -- so every mapping below is deliberately expressed in terms document-schema.js already defines, never a lookalike shape of its own. // -// This reader is deliberately thin: paragraph/run reading (readOdfParagraph) and table reading (readOdfTable) already live in typed/shared/ -- built for reuse across odt/ods/odp/odg, not odt-specific -- so this module's own job is the odt-SPECIFIC structure those shared readers have no opinion on: walking office:text's actual block sequence (paragraphs interleaved with lists and tables, in document order), turning a text:list's purely structural nesting into ContentParagraph.list (numId/level), mapping text:h's own text:outline-level onto a docx-equivalent styleId, and resolving the document's own page geometry from its first master page. readOdfParagraph is tag-agnostic (it never inspects which tag its own caller found it at) and reads text:h exactly as it reads text:p, so this reader calls straight through to it for both, then overrides ONLY the resulting styleId for a heading -- see readParagraphOrHeading below. +// This reader is deliberately thin: paragraph/run reading (readOdfParagraph) and table reading (readOdfTable) already live in typed/shared/ -- built for reuse across odt/ods/odp/odg, not odt-specific -- so this module's own job is the odt-SPECIFIC structure those shared readers have no opinion on: walking office:text's actual block sequence (paragraphs interleaved with lists and tables, in document order), turning a text:list's purely structural nesting into ContentParagraph.list (numId/level), mapping text:h's own text:outline-level onto a docx-equivalent styleId alongside document-schema.js's own headingLevel field, and resolving the document's own page geometry from its first master page. readOdfParagraph is tag-agnostic (it never inspects which tag its own caller found it at) and reads text:h exactly as it reads text:p, so this reader calls straight through to it for both, then overrides ONLY the resulting heading identity (styleId plus headingLevel) for a heading -- see readParagraphOrHeading below. // // SCOPE, matching ooxml.js's own readDocx's already-established, deliberately narrower gaps (see that module's own top-of-file note for the identical reasoning applied to OOXML): footnotes/endnotes, annotations/comments, header/footer content, inline frames/images (draw:frame inside text flow -- odp/odg's job, not odt's), fields beyond their cached/last-computed text value, change tracking (text:change-*), cell borders, explicit page breaks (fo:break-before/fo:break-after -- not modelled by styles/properties.ts's StyleProperties, so the cascade this reader relies on can't surface it; a genuinely separate, bounded follow-on), and documents with more than one master page (only the first is read, in document order -- see readFirstMasterPageGeometry below). A text:h or a nested text:list/text:table inside a table cell is also out of scope here, inherited directly from readOdfTable's own cell reading (table:table-cell content there is read as text:p only) -- not a gap introduced by this module. src/typed/formula/read.ts does not exist yet at the time this reader was written, so there is no formula-embedding recursion to account for either. List marker GLYPHS (the exact bullet character or number format string) remain unread -- only the ordered-vs-bullet KIND is resolved (see resolveListKind below), since that is what downstream consumers need to render
    vs
      . // @@ -43,11 +43,14 @@ function readOutlineLevel(headingElement: XmlElement): number { return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; } -// text:h/text:p -> ContentParagraph, via readOdfParagraph (typed/shared/paragraph.ts) -- tag-agnostic itself, so calling it on a text:h reads its style/run content exactly as it would a text:p. The one thing it can't know is odt's own heading convention: a heading's real @text:style-name (e.g. "Heading_20_1") is a producer-chosen ODF string with no cross-format meaning, so this function overrides ONLY styleId for a text:h, synthesising the same "Heading1"/"Heading2" shape docx's own real w:pStyle values already use for its built-in heading styles -- giving downstream consumers (documents.js's layout engine, or anything else keying off styleId) one consistent heading convention across both formats. `list` is threaded in by the caller (readBlocks/readListItems) rather than derived here, since ODF list membership is purely structural (which text:list/text:list-item this element is nested inside), never an attribute on the paragraph element itself the way docx's w:numPr is. +// text:h/text:p -> ContentParagraph, via readOdfParagraph (typed/shared/paragraph.ts) -- tag-agnostic itself, so calling it on a text:h reads its style/run content exactly as it would a text:p. The one thing it can't know is odt's own heading convention: a heading's real @text:style-name (e.g. "Heading_20_1") is a producer-chosen ODF string with no cross-format meaning, so this function overrides ONLY the heading identity for a text:h, synthesising the same "Heading1"/"Heading2" shape docx's own real w:pStyle values already use for its built-in heading styles -- giving downstream consumers (documents.js's layout engine, or anything else keying off styleId) one consistent heading convention across both formats -- while the parsed text:outline-level number itself is kept as headingLevel, document-schema.js's canonical numeric heading field, so numeric consumers never have to parse it back out of the styleId string. `list` is threaded in by the caller (readBlocks/readListItems) rather than derived here, since ODF list membership is purely structural (which text:list/text:list-item this element is nested inside), never an attribute on the paragraph element itself the way docx's w:numPr is. function readParagraphOrHeading(element: XmlElement, pkg: Package, list: ContentListMembership | undefined): ContentParagraph { const paragraph = readOdfParagraph(element, pkg); if (element.tag === 'text:h') { - paragraph.styleId = `Heading${readOutlineLevel(element)}`; + const outlineLevel = readOutlineLevel(element); + paragraph.styleId = `Heading${outlineLevel}`; + // The parsed text:outline-level number itself is the schema's canonical headingLevel (schema #13): styleId encodes it for styleId-keyed consumers, headingLevel carries it verbatim for numeric consumers, and both always agree because they derive from this one parse. + paragraph.headingLevel = outlineLevel; } if (list !== undefined) { paragraph.list = list;