diff --git a/src/index.ts b/src/index.ts
index 3ca1214..733419f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -101,6 +101,9 @@ export { readOdfMetadata, META_PART } from './typed/shared/metadata';
export { readOdfParagraph } from './typed/shared/paragraph';
+export { mintOdfListNumId, readOdfListParagraphs, resolveOdfListKind } from './typed/shared/list';
+export type { OdfListIdState, OdfListParagraphReader } from './typed/shared/list';
+
export { readOdfTable } from './typed/shared/table';
export { parseOdfTransform, applyOdfTransform, netRotationDeg, resolveOdfShapeGeometry, composeOdfGroupTransform } from './typed/shared/transform';
diff --git a/src/typed/draw/shapes.test.ts b/src/typed/draw/shapes.test.ts
index 66928a6..e2c6c5a 100644
--- a/src/typed/draw/shapes.test.ts
+++ b/src/typed/draw/shapes.test.ts
@@ -73,11 +73,15 @@ describe('readDrawFrame: content dispatch', () => {
expect(shape?.blocks).toEqual([{ kind: 'paragraph', runs: [{ text: 'Hello', bold: undefined, italic: undefined, underline: undefined, strike: undefined, fontFamily: undefined, sizePt: undefined, color: undefined }], styleId: undefined, alignment: undefined, spacingBeforePt: undefined, spacingAfterPt: undefined, lineSpacing: undefined, indentLeftPt: undefined, indentFirstLinePt: undefined }]);
});
- it('flattens a text:list\'s own text:list-item > text:p paragraphs (list numbering membership is a documented, separate gap -- text is never dropped)', () => {
- const list = el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('item one')])]), el('text:list-item', {}, [el('text:p', {}, [txt('item two')])])]);
+ it('reads a text:list\'s own text:list-item > text:p paragraphs with list membership attached -- one minted numId across nesting, level read off the XML nesting depth (unstyled, so the numId carries no kind prefix)', () => {
+ const list = el('text:list', {}, [
+ el('text:list-item', {}, [el('text:p', {}, [txt('item one')])]),
+ el('text:list-item', {}, [el('text:p', {}, [txt('item two')]), el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('item two.1')])])])]),
+ ]);
const frame = el('draw:frame', box, [el('draw:text-box', {}, [list])]);
const shape = readDrawFrame(frame, [], { parts: {} });
- expect(shape?.blocks.map((b) => (b.kind === 'paragraph' ? b.runs.map((r) => r.text).join('') : undefined))).toEqual(['item one', 'item two']);
+ expect(shape?.blocks.map((b) => (b.kind === 'paragraph' ? b.runs.map((r) => r.text).join('') : undefined))).toEqual(['item one', 'item two', 'item two.1']);
+ expect(shape?.blocks.map((b) => (b.kind === 'paragraph' ? b.list : undefined))).toEqual([{ numId: 'list1', level: 0 }, { numId: 'list1', level: 0 }, { numId: 'list1', level: 1 }]);
});
it('reads a draw:image\'s referenced media part, sniffed and sized to the frame\'s own resolved box', () => {
diff --git a/src/typed/draw/shapes.ts b/src/typed/draw/shapes.ts
index 7891188..0b39499 100644
--- a/src/typed/draw/shapes.ts
+++ b/src/typed/draw/shapes.ts
@@ -8,6 +8,7 @@ import { resolveStyleElementChain } from '../shared/cascade';
import { parseOdfColor } from '../shared/color';
import { parseLinePoints } from '../shared/geometry';
import { decodeOdfText } from '../shared/text';
+import { mintOdfListNumId, readOdfListParagraphs, type OdfListIdState } from '../shared/list';
import { readOdfParagraph } from '../shared/paragraph';
import { readOdfTable } from '../shared/table';
import { parseOdfLength } from '../shared/units';
@@ -85,15 +86,29 @@ export function readDrawImageBlock(image: XmlElement, frame: XmlElement, frameBo
}
// A draw:frame's content is exactly one of table:table, draw:text-box, or draw:image (verified against real LibreOffice output) -- table:table is checked FIRST because a real saved presentation table frame also carries a sibling draw:image (an .svm fallback preview LibreOffice writes for consumers that can't render a real table), which must not be mistaken for the frame's own image content.
-function readDrawFrameContent(frame: XmlElement, frameBox: Box, pkg: Package): ContentBlock[] {
+//
+// ODP LIST MEMBERSHIP -- minted numId, not the numId-less { level } shape: document-schema.js 3.3.0 made ContentListMembership.numId optional precisely so a reader whose source carries NO list identity could emit the honest minimal { level } (ooxml.js's pptx reader, whose a:pPr/@lvl is a bare depth attribute on the paragraph with no list element behind it -- a fabricated numId there would be a lie in the data). A slide text box is not that case: draw:text-box's own content model is exactly (text:p | text:list)*, and its text:list elements are the IDENTICAL structural containers the odt reader walks in office:text -- a slide can carry two of them (two bullet bodies in one text box, or one list in each of two frames), and a consumer grouping list paragraphs apart (rendering separate
/ elements, nesting an outline per list) must be able to tell them apart. The deciding criterion is exactly that: whether the source carries genuine list identity a consumer needs for grouping separate lists apart. ODP's text:list elements pass it, so this reader mints a per-encounter numId through the SAME shared machinery (typed/shared/list.ts: mintOdfListNumId/readOdfListParagraphs, including the ordered:/bullet: kind prefix) the odt reader uses -- emitting { level } alone would discard a real, source-grounded fact, not avoid a fabrication.
+function readDrawFrameContent(frame: XmlElement, frameBox: Box, pkg: Package, listIdState: OdfListIdState): ContentBlock[] {
const table = childrenWithTag(frame, 'table:table')[0];
if (table !== undefined) {
return [readOdfTable(table, pkg)];
}
const textBox = childrenWithTag(frame, 'draw:text-box')[0];
if (textBox !== undefined) {
- // elementsWithTag (a DEEP search, not childrenWithTag's direct-children-only) so a text:p nested inside a text:list/text:list-item (a real, valid ODF bulleted/numbered text box) is still read as a paragraph -- its text is preserved, though list numbering membership (ContentParagraph.list) is not populated: that needs ODF list-style resolution (text:list-style -> numId/level), a genuinely separate feature this task does not build, and a documented, narrow gap rather than a silently dropped one.
- return elementsWithTag(textBox.children, 'text:p').map((p) => readOdfParagraph(p, pkg));
+ // A direct-children walk (not the deep elementsWithTag search this branch used before list membership existed) covers draw:text-box's whole (text:p | text:list)* content model: a text:p reads as a plain paragraph with NO list membership, and a text:list reads through the shared walker, which attaches numId/level membership to every paragraph it finds at its actual text:list-in-text:list-item nesting depth -- document order across both child kinds is preserved, matching the flattened order the old deep search produced.
+ const blocks: ContentBlock[] = [];
+ for (const child of textBox.children) {
+ if (child.type !== 'element') {
+ continue;
+ }
+ if (child.tag === 'text:p') {
+ blocks.push(readOdfParagraph(child, pkg));
+ } else if (child.tag === 'text:list') {
+ const numId = mintOdfListNumId(pkg, child, listIdState);
+ blocks.push(...readOdfListParagraphs(child, { numId, level: 0 }, (element) => readOdfParagraph(element, pkg)));
+ }
+ }
+ return blocks;
}
const image = childrenWithTag(frame, 'draw:image')[0];
if (image !== undefined) {
@@ -103,8 +118,8 @@ function readDrawFrameContent(frame: XmlElement, frameBox: Box, pkg: Package): C
return [];
}
-// Reads one draw:frame into a ContentShape, in the coordinate space `groupFunctions` maps FROM (its own immediate parent's local space) TO the page: composeOdfGroupTransform is the identity when groupFunctions is empty (the overwhelmingly common case -- a frame with no enclosing draw:g), so this is cheap for the non-grouped case. Returns undefined for a frame with no resolvable geometry of its own -- see transform.ts's resolveOdfShapeGeometry for the documented "inherited positioning" scope boundary this defers to.
-export function readDrawFrame(frame: XmlElement, groupFunctions: readonly OdfTransformFunction[], pkg: Package): ContentShape | undefined {
+// Reads one draw:frame into a ContentShape, in the coordinate space `groupFunctions` maps FROM (its own immediate parent's local space) TO the page: composeOdfGroupTransform is the identity when groupFunctions is empty (the overwhelmingly common case -- a frame with no enclosing draw:g), so this is cheap for the non-grouped case. Returns undefined for a frame with no resolvable geometry of its own -- see transform.ts's resolveOdfShapeGeometry for the documented "inherited positioning" scope boundary this defers to. `listIdState` mints a text-box list's numId identity (see readDrawFrameContent's own ODP LIST MEMBERSHIP note) and defaults to a fresh counter so every pre-existing call site (ods's anchored-drawing reader, this file's own tests) keeps working unchanged -- a caller walking a WHOLE presentation (odp) threads one document-wide state so identities stay unique across every slide.
+export function readDrawFrame(frame: XmlElement, groupFunctions: readonly OdfTransformFunction[], pkg: Package, listIdState: OdfListIdState = { next: 1 }): ContentShape | undefined {
const ownGeometry = resolveOdfShapeGeometry(frame);
if (ownGeometry === undefined) {
return undefined;
@@ -115,7 +130,7 @@ export function readDrawFrame(frame: XmlElement, groupFunctions: readonly OdfTra
frame: geometry.frame,
rotationDeg: geometry.rotationDeg,
...readFrameInsets(frame, pkg),
- blocks: readDrawFrameContent(frame, geometry.frame, pkg),
+ blocks: readDrawFrameContent(frame, geometry.frame, pkg, listIdState),
};
}
@@ -128,21 +143,23 @@ function readOwnTransformFunctions(element: XmlElement): OdfTransformFunction[]
// Walks a shape container's direct children (a draw:page, or a draw:g's own children) in document order, flattening any draw:g group into `out`'s own flat ContentShape list: `groupFunctions` accumulates each enclosing group's own draw:transform, INNERMOST first (a nested group's own functions are prepended ahead of whatever its own parent already accumulated), so composeOdfGroupTransform at the leaf applies them in the correct innermost-to-outermost order -- mirroring ooxml.js's own p:grpSp flattening (src/typed/pptx/read.ts's walkShapeTreeChildren/composeGroupTransform), adapted to ODF's own transform-function-list model instead of OOXML's chOff/chExt scaling. See this file's own top-of-file note on why a bare vector-primitive shape (not wrapped in a draw:frame) is silently skipped here.
//
// `indexState` reuses the EXACT SAME paintOrderKey/DocumentIndexState machinery walkDrawPageContent (odg, further down this file) uses -- ContentShapeSchema carries the identical optional `paintOrder` field ContentSlideSchema's own shapes already declare, so a presentation shape gets the same real, spec-aware (draw:z-index-honouring, falling back to document-encounter order) paint-order value an odg drawing's shapes get, even though odp's own output array is never reordered by it (matching this walker's own pre-existing document-order-only behaviour -- only the STAMPED VALUE is new, not a new sort). Defaults to a fresh counter so every existing external call site (a single top-level call per slide, with no indexState argument) keeps working unchanged; recursion into a nested draw:g threads the SAME state onward so the counter stays monotonic across the whole slide, matching walkDrawPageContent's own threading discipline exactly.
-export function walkDrawShapes(children: readonly XmlNode[], groupFunctions: readonly OdfTransformFunction[], pkg: Package, out: ContentShape[], indexState: DocumentIndexState = { next: 0 }): void {
+//
+// `listIdState` threads the text-box list numId counter (see readDrawFrameContent's own ODP LIST MEMBERSHIP note) through every frame of the walk, with the same fresh-counter default and the same recursive threading discipline as indexState -- odp passes one document-wide state (see readOdp) so a list's identity is unique across the whole presentation, never reset per slide or per group.
+export function walkDrawShapes(children: readonly XmlNode[], groupFunctions: readonly OdfTransformFunction[], pkg: Package, out: ContentShape[], indexState: DocumentIndexState = { next: 0 }, listIdState: OdfListIdState = { next: 1 }): void {
for (const node of children) {
if (node.type !== 'element') {
continue;
}
if (node.tag === 'draw:frame') {
const zIndex = paintOrderKey(node, indexState);
- const shape = readDrawFrame(node, groupFunctions, pkg);
+ const shape = readDrawFrame(node, groupFunctions, pkg, listIdState);
if (shape !== undefined) {
out.push({ ...shape, paintOrder: zIndex });
}
} else if (node.tag === 'draw:g') {
const ownFunctions = readOwnTransformFunctions(node);
const nested = ownFunctions.length === 0 ? groupFunctions : [...ownFunctions, ...groupFunctions];
- walkDrawShapes(node.children, nested, pkg, out, indexState);
+ walkDrawShapes(node.children, nested, pkg, out, indexState, listIdState);
}
}
}
@@ -305,7 +322,7 @@ function readCustomShapeVector(element: XmlElement, groupFunctions: readonly Odf
return { kind: type === 'ellipse' ? 'ellipse' : 'rect', frame: geometry.frame, rotationDeg: geometry.rotationDeg, fill, stroke };
}
-// The fallback for an UNRECOGNISED draw:custom-shape preset (or one with no draw:enhanced-geometry/draw:type at all): produce text-only content -- a plain ContentShape carrying whatever real text:p runs the shape has, read exactly like readDrawFrameContent's own draw:text-box case above -- rather than a vector primitive this reader cannot correctly derive without evaluating draw:enhanced-path's own formula language (see RECOGNIZED_CUSTOM_SHAPE_PRESETS' own note). A custom-shape's text:p children sit DIRECTLY under draw:custom-shape itself (confirmed against real LibreOffice output -- unlike draw:frame's own draw:text-box wrapper), so elementsWithTag is used the same deep-search way readDrawFrameContent already uses it for a listed text box. An unrecognised preset with NO real text content at all (every run empty, matching this reader's own hand-built fixtures, which never populate a placeholder shape's own text) has nothing worth preserving and is skipped entirely -- this IS the "diagnostic-worthy note" this task's brief asks for: this comment IS that note, since neither this reader nor readOdg below has a diagnostics sink to report it through (matching readOdp/readOdt's own established "no diagnostics channel" posture elsewhere in this package).
+// The fallback for an UNRECOGNISED draw:custom-shape preset (or one with no draw:enhanced-geometry/draw:type at all): produce text-only content -- a plain ContentShape carrying whatever real text:p runs the shape has, read through the same readOdfParagraph call readDrawFrameContent's own draw:text-box case uses (though without its list-membership walk -- an odg path, where a text:list's own text:p children are still FOUND by this deep search and read as plain paragraphs) -- rather than a vector primitive this reader cannot correctly derive without evaluating draw:enhanced-path's own formula language (see RECOGNIZED_CUSTOM_SHAPE_PRESETS' own note). A custom-shape's text:p children sit DIRECTLY under draw:custom-shape itself (confirmed against real LibreOffice output -- unlike draw:frame's own draw:text-box wrapper), so elementsWithTag is used here as a deep search that also finds a text:list's own text:p children should a custom shape carry one, reading them as plain paragraphs. An unrecognised preset with NO real text content at all (every run empty, matching this reader's own hand-built fixtures, which never populate a placeholder shape's own text) has nothing worth preserving and is skipped entirely -- this IS the "diagnostic-worthy note" this task's brief asks for: this comment IS that note, since neither this reader nor readOdg below has a diagnostics sink to report it through (matching readOdp/readOdt's own established "no diagnostics channel" posture elsewhere in this package).
function readCustomShapeAsTextShape(element: XmlElement, groupFunctions: readonly OdfTransformFunction[], pkg: Package): ContentShape | undefined {
const paragraphs = elementsWithTag(element.children, 'text:p').map((p) => readOdfParagraph(p, pkg));
const hasText = paragraphs.some((paragraph) => paragraph.runs.some((run) => run.text.length > 0));
diff --git a/src/typed/odp/read.test.ts b/src/typed/odp/read.test.ts
index 1901dc4..bf8ed8c 100644
--- a/src/typed/odp/read.test.ts
+++ b/src/typed/odp/read.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import type { ContentListMembership, ContentParagraph, ContentSlide } from 'document-schema.js';
import type { Package } from '../../model/package';
import { el, txt } from '../../xml/fragment';
import { bytesToBase64 } from '../../util/base64';
@@ -67,6 +68,86 @@ function buildFixturePackage(): Package {
};
}
+// A dedicated fixture for text:list content inside slide text frames (draw:frame > draw:text-box): one slide carrying a "Body" frame whose text box holds a plain paragraph, a styled (bullet) 2-level text:list, and a second unstyled sibling text:list, plus an "Aside" frame with a third text:list of its own -- so nesting depth, per-encounter identity, cross-frame identity, the ordered:/bullet: kind prefix, and the no-membership case are all exercisable against one real-shape package. The text:list-style lives in content.xml's office:automatic-styles, the placement real LibreOffice output uses.
+function buildListFixturePackage(): Package {
+ const styledList = el('text:list', { 'text:style-name': 'L1' }, [
+ el('text:list-item', {}, [el('text:p', {}, [txt('Alpha')])]),
+ el('text:list-item', {}, [
+ el('text:p', {}, [txt('Beta')]),
+ el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('Beta.1')])]), el('text:list-item', {}, [el('text:p', {}, [txt('Beta.2')])])]),
+ ]),
+ el('text:list-item', {}, [el('text:p', {}, [txt('Gamma')])]),
+ ]);
+ const siblingList = el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('Delta')])])]);
+ const bodyFrame = el('draw:frame', { 'draw:name': 'Body', 'svg:x': '40pt', 'svg:y': '80pt', 'svg:width': '400pt', 'svg:height': '300pt' }, [
+ el('draw:text-box', {}, [el('text:p', {}, [txt('Intro')]), styledList, siblingList]),
+ ]);
+ const asideFrame = el('draw:frame', { 'draw:name': 'Aside', 'svg:x': '40pt', 'svg:y': '400pt', 'svg:width': '400pt', 'svg:height': '80pt' }, [
+ el('draw:text-box', {}, [el('text:list', {}, [el('text:list-item', {}, [el('text:p', {}, [txt('Epsilon')])])])]),
+ ]);
+ const slide = el('draw:page', { 'draw:name': 'ListSlide', 'draw:master-page-name': 'Default' }, [bodyFrame, asideFrame]);
+
+ return {
+ parts: {
+ 'content.xml': {
+ kind: 'xml',
+ nodes: [
+ el('office:document-content', {}, [
+ el('office:automatic-styles', {}, [el('text:list-style', { 'style:name': 'L1' }, [el('text:list-level-style-bullet', { 'text:level': '1' })])]),
+ el('office:body', {}, [el('office:presentation', {}, [slide])]),
+ ]),
+ ],
+ },
+ 'styles.xml': stylesXml(),
+ },
+ };
+}
+
+function paragraphsWithText(slides: readonly ContentSlide[], shapeName: string, expected: readonly string[]): ContentParagraph[] {
+ const shape = slides[0]?.shapes.find((s) => s.name === shapeName);
+ if (shape === undefined) {
+ throw new Error(`expected a "${shapeName}" shape on slide 1`);
+ }
+ const paragraphs = shape.blocks.filter((block): block is ContentParagraph => block.kind === 'paragraph');
+ const texts = paragraphs.map((paragraph) => paragraph.runs.map((run) => run.text).join(''));
+ expect(texts).toEqual([...expected]);
+ return paragraphs;
+}
+
+describe('readOdp: text:list content inside slide text frames', () => {
+ it('reads a nested text:list as one numId across both depths, with level read off the actual text:list-in-text:list-item nesting and document order preserved across listed and unlisted paragraphs', () => {
+ const paragraphs = paragraphsWithText(readOdp(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']);
+ const [, alpha, beta, beta1, beta2, gamma] = paragraphs;
+ expect([alpha?.list?.level, beta?.list?.level, beta1?.list?.level, beta2?.list?.level, gamma?.list?.level]).toEqual([0, 0, 1, 1, 0]);
+ const numId = alpha?.list?.numId;
+ expect(numId).toBeDefined();
+ expect([beta?.list?.numId, beta1?.list?.numId, beta2?.list?.numId, gamma?.list?.numId]).toEqual([numId, numId, numId, numId]);
+ });
+
+ it('mints a distinct numId per top-level text:list encounter -- a sibling list in the same text box and a list in a different frame never share an identity', () => {
+ const { slides } = readOdp(buildListFixturePackage());
+ const body = paragraphsWithText(slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']);
+ const aside = paragraphsWithText(slides, 'Aside', ['Epsilon']);
+ const firstListId = body[1]?.list?.numId;
+ const siblingListId = body[6]?.list?.numId;
+ const asideListId = aside[0]?.list?.numId;
+ expect(new Set([firstListId, siblingListId, asideListId]).size).toBe(3);
+ });
+
+ it('leaves list undefined on paragraphs outside any text:list, including one sharing a text box with a list', () => {
+ const { slides } = readOdp(buildListFixturePackage());
+ expect(paragraphsWithText(slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta'])[0]?.list).toBeUndefined();
+ // The main fixture's title frame proves the same for a text box that never carried a list at all.
+ expect(readOdp(buildFixturePackage()).slides[0]?.shapes.find((s) => s.name === 'Title')?.blocks[0]).not.toHaveProperty('list');
+ });
+
+ it('resolves the ordered-vs-bullet kind prefix from the referenced text:list-style, and leaves an unstyled list unprefixed -- the same shared numId convention the odt reader mints', () => {
+ const paragraphs = paragraphsWithText(readOdp(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']);
+ expect(paragraphs[1]?.list).toEqual({ numId: 'bullet:list1', level: 0 } satisfies ContentListMembership);
+ expect(paragraphs[6]?.list).toEqual({ numId: 'list2', level: 0 } satisfies ContentListMembership);
+ });
+});
+
describe('readOdp', () => {
it('reads slides in native document order (draw:page order, no p:sldIdLst-style indirection to resolve)', () => {
const { slides } = readOdp(buildFixturePackage());
diff --git a/src/typed/odp/read.ts b/src/typed/odp/read.ts
index 7d19d57..38f677a 100644
--- a/src/typed/odp/read.ts
+++ b/src/typed/odp/read.ts
@@ -6,6 +6,7 @@ import { childrenWithTag, elementsWithTag, findChildElement, rootElement } from
import { decodeOdfText } from '../shared/text';
import { readOdfMetadata } from '../shared/metadata';
import { resolveDrawPageSize } from '../shared/masterpage';
+import type { OdfListIdState } from '../shared/list';
import { walkDrawShapes } from '../draw/shapes';
// Resolves a Package into { metadata, slides }: document order is native here -- a draw:page's own position among its office:presentation siblings IS slide order, with no pptx-style p:sldIdLst indirection to resolve at all (verified against real LibreOffice output: multiple draw:page elements sit directly, in order, under office:body/office:presentation).
@@ -26,9 +27,10 @@ function readSlideNotes(page: XmlElement): string {
return elementsWithTag(notes.children, 'text:p').map(decodeOdfText).join('\n');
}
-function readSlide(page: XmlElement, pkg: Package): ContentSlide {
+// `listIdState` mints the numId identity for every text:list found inside a slide text frame (draw:frame > draw:text-box), threaded by walkDrawShapes through the whole shape walk and owned by readOdp below at DOCUMENT scope -- one counter across every slide, so two lists on different slides get different identities exactly as two lists in different parts of one odt body do (see typed/shared/list.ts's own top-of-file note for the numId convention and typed/draw/shapes.ts's readDrawFrameContent for why odp mints rather than emitting the numId-less { level } shape).
+function readSlide(page: XmlElement, pkg: Package, listIdState: OdfListIdState): ContentSlide {
const shapes: ContentShape[] = [];
- walkDrawShapes(page.children, [], pkg, shapes);
+ walkDrawShapes(page.children, [], pkg, shapes, { next: 0 }, listIdState);
return { size: readSlideSize(page, pkg), shapes, notes: readSlideNotes(page) };
}
@@ -44,8 +46,9 @@ export function readOdp(pkg: Package): OdpDocument {
const presentation = body === undefined ? undefined : findChildElement(body.children, 'office:presentation');
const pages = presentation === undefined ? [] : childrenWithTag(presentation, 'draw:page');
+ const listIdState: OdfListIdState = { next: 1 };
return {
metadata: readOdfMetadata(pkg),
- slides: pages.map((page) => readSlide(page, pkg)),
+ slides: pages.map((page) => readSlide(page, pkg, listIdState)),
};
}
diff --git a/src/typed/odt/read.ts b/src/typed/odt/read.ts
index 676b67a..9ad2cb5 100644
--- a/src/typed/odt/read.ts
+++ b/src/typed/odt/read.ts
@@ -1,8 +1,9 @@
-import type { ContentBlock, ContentListMembership, ContentParagraph, ContentSection, LayoutMetadata, Margins, PageSize } from 'document-schema.js';
+import type { ContentBlock, ContentParagraph, ContentSection, LayoutMetadata, Margins, PageSize } from 'document-schema.js';
import { PAGE_SIZE_A4 } from 'document-schema.js';
import type { Package } from '../../model/package';
import type { XmlElement, XmlNode } from '../../model/node';
import { rootElement, findChildElement, childrenWithTag, attrValue } from '../../xml/query';
+import { mintOdfListNumId, readOdfListParagraphs, type OdfListIdState } from '../shared/list';
import { readOdfParagraph } from '../shared/paragraph';
import { readOdfTable } from '../shared/table';
import { readOdfMetadata } from '../shared/metadata';
@@ -11,18 +12,11 @@ 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 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.
+// 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), 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 .
+// 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 typed/shared/list.ts's resolveOdfListKind), since that is what downstream consumers need to render vs .
//
-// LIST numId DERIVATION: ODF has no docx-style shared numId at all -- a docx w:numId identifies one entry in numbering.xml that many, textually unrelated w:p elements can reference by attribute; an ODF text:list is instead a purely STRUCTURAL container (its own list items are its own XML children), so "which list does this paragraph belong to" is answered by tree position, not by an attribute lookup. To give downstream consumers (document-schema.js's ContentListMembership, mirroring docx's numId/level pair) an equivalent stable identity, this reader mints numId as a monotonically increasing counter ("list1", "list2", ...), ONE PER TOP-LEVEL text:list ELEMENT ENCOUNTERED IN DOCUMENT ORDER -- never per text:style-name. A text:list's own text:style-name was deliberately rejected as the numId source: it names a REUSABLE list-style DEFINITION (the marker/numbering format), and real documents routinely apply the identical list-style to two unrelated, non-adjacent text:list elements (e.g. two independent bullet lists both created from the same "List 1" paragraph style) -- collapsing those into one numId would violate the one hard requirement this reader must satisfy ("different text:list elements get different [numIds]"). A monotonic per-encounter counter satisfies that requirement unconditionally, is stable/deterministic for a given document (same input -> same output, useful for tests), and needs no cross-referencing at all. Nesting is layered on top of this identity, not a separate list: a NESTED text:list (one found while walking a text:list-item's own children, per the OASIS content model for list nesting) keeps its ENCLOSING list's numId unchanged and only increments level -- exactly mirroring how a docx numId spans every nesting depth of one multi-level list, with w:ilvl (level here) distinguishing depth. Nesting depth itself is never separately counted or inferred from indentation: it is read directly off the actual XML nesting depth of text:list inside text:list-item inside text:list ..., per this reader's own explicit design brief.
-//
-// LIST KIND PREFIX: the minted numId is prefixed with "ordered:" or "bullet:" when the text:list's text:style-name resolves to a text:list-style whose level-1 child is text:list-level-style-number (ordered) or text:list-level-style-bullet/-image (bullet) -- see resolveListKind below. This encodes the ordered-vs-bullet kind into the opaque numId string (the same convention markdown-codec and documents.js's router-side docx normalization already use), so downstream consumers (the web app's buildListForest/renderer) can render vs without needing a separate field on ContentListMembership. An unresolved style-name leaves the numId unprefixed, and the consumer renders with a neutral marker.
-
-// A monotonically increasing counter for minting fresh top-level list numIds, threaded by reference through the whole document walk -- see this module's own top-of-file note on why a per-encounter counter, not text:style-name, is the numId source.
-interface ListIdState {
- next: number;
-}
+// LIST HANDLING: the numId minting convention (a monotonically increasing per-encounter counter, never text:style-name), the ordered:/bullet: kind prefix, and the text:list/text:list-item structural nesting walk itself all live in typed/shared/list.ts -- read that module's own top-of-file notes in full for the derivation -- because the odp reader meets the IDENTICAL text:list construct inside slide text frames and shares every line of it. This reader's own remaining list responsibility is the one genuinely odt-specific part: threading a single document-wide OdfListIdState through its office:text walk, so list identities are unique across the whole body exactly as they are across a whole presentation's slides.
export interface OdtDocument {
metadata: LayoutMetadata;
@@ -43,8 +37,8 @@ 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 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 {
+// 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 membership is never set here: the shared walker (typed/shared/list.ts's readOdfListParagraphs) attaches it for paragraphs it reads inside a text:list, 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): ContentParagraph {
const paragraph = readOdfParagraph(element, pkg);
if (element.tag === 'text:h') {
const outlineLevel = readOutlineLevel(element);
@@ -52,69 +46,21 @@ function readParagraphOrHeading(element: XmlElement, pkg: Package, list: Content
// 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;
- }
return paragraph;
}
-// Walks a text:list's direct text:list-item children, pushing each item's own text:p/text:h content into `blocks` (in document order, alongside everything else at the CALLER's own level -- a list's items are not wrapped in any block of their own, matching ContentBlock's flat discriminated-union shape) and recursing into a nested text:list (found inside a text:list-item, per the OASIS nesting content model) at level + 1 under the SAME numId -- see this module's own top-of-file note on why nesting never mints a new numId.
-function readListItems(listElement: XmlElement, context: ContentListMembership, pkg: Package, blocks: ContentBlock[]): void {
- for (const item of listElement.children) {
- if (item.type !== 'element' || item.tag !== 'text:list-item') {
- continue;
- }
- for (const itemChild of item.children) {
- if (itemChild.type !== 'element') {
- continue;
- }
- if (itemChild.tag === 'text:p' || itemChild.tag === 'text:h') {
- blocks.push(readParagraphOrHeading(itemChild, pkg, context));
- } else if (itemChild.tag === 'text:list') {
- readListItems(itemChild, { numId: context.numId, level: context.level + 1 }, pkg, blocks);
- }
- // A list item containing a table or further block content beyond a nested list is legal ODF but vanishingly rare and outside this reader's scope, matching the task's own explicit list-membership-only mandate.
- }
- }
-}
-
// Walks block-level content (text:p, text:h, text:list, table:table) in document order, at ONE nesting level -- office:text's own top-level children. text:section (ODF's generic grouping/columns wrapper) is unwrapped transparently, flattening its content into the caller's own block sequence -- it carries no semantic meaning ContentBlock has any vocabulary for. Anything else (text:sequence-decls, a bookmark, a field, change-tracking markup, an anchored draw:frame, text:soft-page-break, ...) is silently outside this reader's scope, matching the OUT OF SCOPE note at the top of this file. Table CELL content is not walked here at all -- readOdfTable owns that entirely (see this file's own top-of-file note on the scope it inherits from doing so).
-// Resolves a text:list's text:style-name to its ordered-vs-bullet kind by finding the corresponding text:list-style definition and inspecting its level-1 child tag. Searches both content.xml and styles.xml, in both office:automatic-styles and office:styles -- mirroring findPageLayoutElement's and cascade.ts's own both-parts-both-containers pattern. Returns undefined when the style-name is absent or unresolvable, so the caller leaves the numId unprefixed and the downstream consumer falls back to a neutral marker.
-function resolveListKind(pkg: Package, styleName: string | undefined): 'ordered' | 'bullet' | undefined {
- if (styleName === undefined) return undefined;
- for (const partPath of AUTOMATIC_STYLE_PARTS) {
- const part = pkg.parts[partPath];
- if (part?.kind !== 'xml') continue;
- const root = rootElement(part.nodes);
- if (root === undefined) continue;
- for (const containerTag of ['office:automatic-styles', 'office:styles'] as const) {
- const container = findChildElement(root.children, containerTag);
- if (container === undefined) continue;
- const listStyle = childrenWithTag(container, 'text:list-style').find((el) => attrValue(el, 'style:name') === styleName);
- if (listStyle === undefined) continue;
- // Real list-styles are homogeneous across levels; checking level 1 is sufficient.
- if (childrenWithTag(listStyle, 'text:list-level-style-number').some((el) => attrValue(el, 'text:level') === '1')) return 'ordered';
- if (childrenWithTag(listStyle, 'text:list-level-style-bullet').some((el) => attrValue(el, 'text:level') === '1')) return 'bullet';
- if (childrenWithTag(listStyle, 'text:list-level-style-image').some((el) => attrValue(el, 'text:level') === '1')) return 'bullet';
- return undefined;
- }
- }
- return undefined;
-}
-
-function readBlocks(nodes: readonly XmlNode[], pkg: Package, listIdState: ListIdState): ContentBlock[] {
+function readBlocks(nodes: readonly XmlNode[], pkg: Package, listIdState: OdfListIdState): ContentBlock[] {
const blocks: ContentBlock[] = [];
for (const node of nodes) {
if (node.type !== 'element') {
continue;
}
if (node.tag === 'text:p' || node.tag === 'text:h') {
- blocks.push(readParagraphOrHeading(node, pkg, undefined));
+ blocks.push(readParagraphOrHeading(node, pkg));
} else if (node.tag === 'text:list') {
- const kind = resolveListKind(pkg, attrValue(node, 'text:style-name'));
- const numId = kind !== undefined ? `${kind}:list${listIdState.next}` : `list${listIdState.next}`;
- listIdState.next += 1;
- readListItems(node, { numId, level: 0 }, pkg, blocks);
+ const numId = mintOdfListNumId(pkg, node, listIdState);
+ blocks.push(...readOdfListParagraphs(node, { numId, level: 0 }, (element) => readParagraphOrHeading(element, pkg)));
} else if (node.tag === 'table:table') {
blocks.push(readOdfTable(node, pkg));
} else if (node.tag === 'text:section') {
@@ -194,7 +140,7 @@ export function readOdt(pkg: Package): OdtDocument {
const metadata = readOdfMetadata(pkg);
const { pageSize, margins } = readFirstMasterPageGeometry(pkg);
- const listIdState: ListIdState = { next: 1 };
+ const listIdState: OdfListIdState = { next: 1 };
const blocks = readBlocks(textElement.children, pkg, listIdState);
return { metadata, sections: [{ pageSize, margins, blocks }] };
diff --git a/src/typed/shared/list.ts b/src/typed/shared/list.ts
new file mode 100644
index 0000000..e7d1e88
--- /dev/null
+++ b/src/typed/shared/list.ts
@@ -0,0 +1,73 @@
+import type { ContentListMembership, ContentParagraph } from 'document-schema.js';
+import type { XmlElement } from '../../model/node';
+import type { Package } from '../../model/package';
+import { rootElement, findChildElement, childrenWithTag, attrValue } from '../../xml/query';
+
+// The text:list walker every reader that meets list-structured ODF text shares -- the odt reader (office:text body content) and the odp reader (draw:text-box content inside slide text frames). A text:list means the same thing in both homes: a purely STRUCTURAL container whose own items are its own XML children, never a reference into a shared numbering part. This module therefore owns the whole ContentParagraph.list mapping for both readers: the numId identity convention (below), the ordered-vs-bullet kind prefix resolved from the referenced text:list-style, and the structural item/nesting walk itself.
+//
+// LIST numId DERIVATION: ODF has no docx-style shared numId at all -- a docx w:numId identifies one entry in numbering.xml that many, textually unrelated w:p elements can reference by attribute; an ODF text:list is instead a purely STRUCTURAL container (its own list items are its own XML children), so "which list does this paragraph belong to" is answered by tree position, not by an attribute lookup. To give downstream consumers (document-schema.js's ContentListMembership, mirroring docx's numId/level pair) an equivalent stable identity, numId is minted as a monotonically increasing counter ("list1", "list2", ...), ONE PER TOP-LEVEL text:list ELEMENT ENCOUNTERED IN DOCUMENT ORDER -- never per text:style-name. A text:list's own text:style-name was deliberately rejected as the numId source: it names a REUSABLE list-style DEFINITION (the marker/numbering format), and real documents routinely apply the identical list-style to two unrelated, non-adjacent text:list elements (e.g. two independent bullet lists both created from the same "List 1" paragraph style) -- collapsing those into one numId would violate the one hard requirement this convention must satisfy ("different text:list elements get different [numIds]"). A monotonic per-encounter counter satisfies that requirement unconditionally, is stable/deterministic for a given document (same input -> same output, useful for tests), and needs no cross-referencing at all. Nesting is layered on top of this identity, not a separate list: a NESTED text:list (one found while walking a text:list-item's own children, per the OASIS content model for list nesting) keeps its ENCLOSING list's numId unchanged and only increments level -- exactly mirroring how a docx numId spans every nesting depth of one multi-level list, with w:ilvl (level here) distinguishing depth. Nesting depth itself is never separately counted or inferred from indentation: it is read directly off the actual XML nesting depth of text:list inside text:list-item inside text:list ..., never from any style property.
+//
+// LIST KIND PREFIX: the minted numId is prefixed with "ordered:" or "bullet:" when the text:list's text:style-name resolves to a text:list-style whose level-1 child is text:list-level-style-number (ordered) or text:list-level-style-bullet/-image (bullet) -- see resolveOdfListKind below. This encodes the ordered-vs-bullet kind into the opaque numId string (the same convention markdown-codec and documents.js's router-side docx normalization already use), so downstream consumers (the web app's buildListForest/renderer) can render vs without needing a separate field on ContentListMembership. An unresolved style-name leaves the numId unprefixed, and the consumer renders with a neutral marker.
+//
+// The counter state (OdfListIdState) is minted-per-document, threaded by reference through the caller's whole walk -- one state for the entire odt body or the entire odp presentation (every slide, every frame), so two lists on different slides of one presentation get different numIds exactly as two lists in different sections of one odt body do.
+
+// A monotonically increasing counter for minting fresh top-level list numIds, threaded by reference through a reader's whole document walk -- see this module's own top-of-file note on why a per-encounter counter, not text:style-name, is the numId source.
+export interface OdfListIdState {
+ next: number;
+}
+
+// Resolves a text:list's text:style-name to its ordered-vs-bullet kind by finding the corresponding text:list-style definition and inspecting its level-1 child tag. Searches both content.xml and styles.xml, in both office:automatic-styles and office:styles -- mirroring cascade.ts's own both-parts-both-containers pattern (style:name uniqueness is document-wide, so at most one text:list-style can match). Returns undefined when the style-name is absent or unresolvable, so the caller leaves the numId unprefixed and the downstream consumer falls back to a neutral marker.
+export function resolveOdfListKind(pkg: Package, styleName: string | undefined): 'ordered' | 'bullet' | undefined {
+ if (styleName === undefined) return undefined;
+ for (const partPath of ['content.xml', 'styles.xml'] as const) {
+ const part = pkg.parts[partPath];
+ if (part?.kind !== 'xml') continue;
+ const root = rootElement(part.nodes);
+ if (root === undefined) continue;
+ for (const containerTag of ['office:automatic-styles', 'office:styles'] as const) {
+ const container = findChildElement(root.children, containerTag);
+ if (container === undefined) continue;
+ const listStyle = childrenWithTag(container, 'text:list-style').find((el) => attrValue(el, 'style:name') === styleName);
+ if (listStyle === undefined) continue;
+ // Real list-styles are homogeneous across levels; checking level 1 is sufficient.
+ if (childrenWithTag(listStyle, 'text:list-level-style-number').some((el) => attrValue(el, 'text:level') === '1')) return 'ordered';
+ if (childrenWithTag(listStyle, 'text:list-level-style-bullet').some((el) => attrValue(el, 'text:level') === '1')) return 'bullet';
+ if (childrenWithTag(listStyle, 'text:list-level-style-image').some((el) => attrValue(el, 'text:level') === '1')) return 'bullet';
+ return undefined;
+ }
+ }
+ return undefined;
+}
+
+// Mints one top-level text:list element's numId from the shared counter, encoding the ordered-vs-bullet kind as a prefix when the element's own text:style-name resolves (see LIST KIND PREFIX above) and advancing the counter exactly once per encounter -- see LIST numId DERIVATION above for why the counter, never the style-name, is the identity.
+export function mintOdfListNumId(pkg: Package, listElement: XmlElement, state: OdfListIdState): string {
+ const kind = resolveOdfListKind(pkg, attrValue(listElement, 'text:style-name'));
+ const numId = kind !== undefined ? `${kind}:list${state.next}` : `list${state.next}`;
+ state.next += 1;
+ return numId;
+}
+
+// Reads one text:list element's own paragraph content into a flat, document-ordered ContentParagraph list: each text:list-item's own text:p/text:h children (read through the caller-supplied `readParagraph` callback, which owns every reader-specific concern -- odt's text:h heading-identity override, odp's plain readOdfParagraph) carry the given `membership` attached by THIS walker, never by the callback, since ODF list membership is purely structural (which text:list/text:list-item the paragraph is nested inside), never an attribute on the paragraph element itself the way docx's w:numPr is. A nested text:list (found inside a text:list-item, per the OASIS nesting content model) recurses at level + 1 under the SAME numId -- see this module's own top-of-file note on why nesting never mints a new numId. Items are not wrapped in any block of their own, matching ContentBlock's flat discriminated-union shape. A list item containing a table or further block content beyond a nested list is legal ODF but vanishingly rare and outside this walker's scope, matching the list-membership-only mandate both callers were built against.
+export type OdfListParagraphReader = (element: XmlElement) => ContentParagraph;
+
+export function readOdfListParagraphs(listElement: XmlElement, membership: ContentListMembership, readParagraph: OdfListParagraphReader): ContentParagraph[] {
+ const paragraphs: ContentParagraph[] = [];
+ for (const item of listElement.children) {
+ if (item.type !== 'element' || item.tag !== 'text:list-item') {
+ continue;
+ }
+ for (const itemChild of item.children) {
+ if (itemChild.type !== 'element') {
+ continue;
+ }
+ if (itemChild.tag === 'text:p' || itemChild.tag === 'text:h') {
+ const paragraph = readParagraph(itemChild);
+ paragraph.list = membership;
+ paragraphs.push(paragraph);
+ } else if (itemChild.tag === 'text:list') {
+ paragraphs.push(...readOdfListParagraphs(itemChild, { numId: membership.numId, level: membership.level + 1 }, readParagraph));
+ }
+ }
+ }
+ return paragraphs;
+}