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