Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/typed/formula/read.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions src/typed/odt/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 6 additions & 3 deletions src/typed/odt/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ol> vs <ul>.
//
Expand Down Expand Up @@ -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;
Expand Down
Loading