Skip to content

Commit a11f95a

Browse files
committed
feat: add readOdp and the shared odp/odg shape vocabulary
Adds the presentation-content read path on top of the existing lossless core, style interning, and shared typed primitives: - typed/shared/transform.ts: parses draw:transform's rotate()/ translate() function list and resolves a shape's own geometry (svg:x/y/width/height, or -- when draw:transform is present -- svg:width/height plus the transform's own rotation/position) into a center-pivoting Box + clockwise rotationDeg, matching how ContentShape.rotationDeg is already used by ooxml.js's pptx reader. The exact composition rule (left-to-right function application, not SVG's own rightmost-first convention) was reverse-engineered empirically against a real LibreOffice render, not assumed from the spec text -- see the module's own top-of-file note. Also composes an enclosing draw:g's own transform onto a child shape, mirroring ooxml.js's p:grpSp group-flattening. - typed/shared/paragraph.ts: reads text:p into ContentParagraph/ ContentRun, dispatching on the same text/text:s/text:tab/ text:line-break/text:span node shapes text.ts's own decodeOdfText already establishes, and merging a text:span's own resolved "text"-family properties over its enclosing paragraph's own "paragraph"-family base. - typed/shared/table.ts: reads table:table/table:table-row/ table:table-cell/table:covered-table-cell into ContentTable, including column/row repeat counts and column-spanned/covered cells -- the same table markup ODF uses identically across odt/ods/odp, so this lives in typed/shared rather than typed/draw/typed/odp for reuse by a future odt/ods reader. - typed/draw/shapes.ts: the shape vocabulary shared between odp (this change) and a later odg task, which will extend this same file additively with vector-primitive kinds (rect/ellipse/line/ path). This change builds draw:frame (dispatching its content to a table, a draw:text-box's paragraphs, or a draw:image sniffed from its referenced media part) and draw:g (flattened into the parent's own flat shape list, since ContentShape has no nested- group representation of its own). - typed/odp/read.ts: readOdp walks office:presentation's draw:page elements in native document order (no pptx-style sldIdLst indirection), resolving each slide's own size via its draw:master-page-name -> style:master-page -> style:page-layout chain and extracting presentation:notes into ContentSlide.notes. A frame that relies on master-page/layout-level positioning inheritance rather than carrying its own explicit svg:x/y/width/ height reads with no geometry at all (readDrawFrame returns undefined for it) -- a documented, narrow scope boundary, not inheritance resolution this change builds.
1 parent d94cf2f commit a11f95a

11 files changed

Lines changed: 1313 additions & 0 deletions

File tree

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,15 @@ export { resolveStyle, resolveStyleElementChain, findStyleElement } from './type
100100
export type { CascadeDiagnostic, StyleCascadeResult, StyleElementChainResult } from './typed/shared/cascade';
101101

102102
export { readOdfMetadata, META_PART } from './typed/shared/metadata';
103+
104+
export { readOdfParagraph } from './typed/shared/paragraph';
105+
106+
export { readOdfTable } from './typed/shared/table';
107+
108+
export { parseOdfTransform, applyOdfTransform, netRotationDeg, resolveOdfShapeGeometry, composeOdfGroupTransform } from './typed/shared/transform';
109+
export type { OdfTransformFunction, OdfPoint, OdfShapeGeometry } from './typed/shared/transform';
110+
111+
export { readDrawFrame, walkDrawShapes } from './typed/draw/shapes';
112+
113+
export { readOdp } from './typed/odp/read';
114+
export type { OdpDocument } from './typed/odp/read';

src/typed/draw/shapes.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { ContentShape } from 'document-content-model';
3+
import type { Package } from '../../model/package';
4+
import type { XmlElement } from '../../model/node';
5+
import { el, txt } from '../../xml/fragment';
6+
import { bytesToBase64 } from '../../util/base64';
7+
import { readDrawFrame, walkDrawShapes } from './shapes';
8+
9+
function contentPackage(automaticStyleChildren: XmlElement[] = []): Package['parts'][string] {
10+
return { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:automatic-styles', {}, automaticStyleChildren)])] };
11+
}
12+
13+
function graphicStyle(name: string, attrs: Record<string, string>, extra: Record<string, string> = {}): XmlElement {
14+
return el('style:style', { 'style:name': name, 'style:family': 'graphic', ...extra }, [el('style:graphic-properties', attrs)]);
15+
}
16+
17+
// Only the PNG magic-byte signature matters to sniffImageFormat -- the rest is arbitrary filler, not a real encoded image, matching ooxml.js's own read.test.ts convention.
18+
function tinyPngBase64(): string {
19+
return bytesToBase64(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]));
20+
}
21+
22+
describe('readDrawFrame: geometry', () => {
23+
it('reads a plain, unrotated frame\'s own svg:x/svg:y/svg:width/svg:height with no group transform', () => {
24+
const frame = el('draw:frame', { 'svg:x': '10pt', 'svg:y': '20pt', 'svg:width': '100pt', 'svg:height': '50pt' });
25+
const shape = readDrawFrame(frame, [], { parts: {} });
26+
expect(shape?.frame).toEqual({ xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 });
27+
expect(shape?.rotationDeg).toBeUndefined();
28+
});
29+
30+
it('returns undefined for a frame with no resolvable geometry at all (the documented inherited-positioning scope boundary)', () => {
31+
expect(readDrawFrame(el('draw:frame'), [], { parts: {} })).toBeUndefined();
32+
});
33+
34+
it('reads the frame\'s own draw:name', () => {
35+
const frame = el('draw:frame', { 'draw:name': 'My Shape', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
36+
expect(readDrawFrame(frame, [], { parts: {} })?.name).toBe('My Shape');
37+
});
38+
});
39+
40+
describe('readDrawFrame: insets from the graphic-family style cascade', () => {
41+
it('reads fo:padding-* from the frame\'s own draw:style-name -> graphic family style', () => {
42+
const gr1 = graphicStyle('gr1', { 'fo:padding-left': '0.25cm', 'fo:padding-top': '0.125cm', 'fo:padding-right': '0.25cm', 'fo:padding-bottom': '0.125cm' });
43+
const pkg: Package = { parts: { 'content.xml': contentPackage([gr1]) } };
44+
const frame = el('draw:frame', { 'draw:style-name': 'gr1', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
45+
const shape = readDrawFrame(frame, [], pkg);
46+
expect(shape?.insetLeftPt).toBeCloseTo(0.25 * (72 / 2.54), 6);
47+
expect(shape?.insetTopPt).toBeCloseTo(0.125 * (72 / 2.54), 6);
48+
});
49+
50+
it('inherits padding via style:parent-style-name -- the real LibreOffice pattern where a shape\'s own automatic style rarely repeats "standard"\'s own padding declaration', () => {
51+
const standard = graphicStyle('standard', { 'fo:padding-left': '0.25cm', 'fo:padding-top': '0.125cm', 'fo:padding-right': '0.25cm', 'fo:padding-bottom': '0.125cm' });
52+
const gr1 = graphicStyle('gr1', { 'fo:min-height': '1.867cm' }, { 'style:parent-style-name': 'standard' });
53+
const pkg: Package = { parts: { 'content.xml': contentPackage([standard, gr1]) } };
54+
const frame = el('draw:frame', { 'draw:style-name': 'gr1', 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
55+
const shape = readDrawFrame(frame, [], pkg);
56+
expect(shape?.insetLeftPt).toBeCloseTo(0.25 * (72 / 2.54), 6);
57+
expect(shape?.insetBottomPt).toBeCloseTo(0.125 * (72 / 2.54), 6);
58+
});
59+
60+
it('defaults every inset to 0 when the frame has no draw:style-name at all', () => {
61+
const frame = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
62+
const shape = readDrawFrame(frame, [], { parts: {} });
63+
expect(shape).toMatchObject({ insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0 });
64+
});
65+
});
66+
67+
describe('readDrawFrame: content dispatch', () => {
68+
const box = { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '100pt', 'svg:height': '50pt' };
69+
70+
it('reads a draw:text-box\'s own text:p children as paragraph blocks', () => {
71+
const frame = el('draw:frame', box, [el('draw:text-box', {}, [el('text:p', {}, [txt('Hello')])])]);
72+
const shape = readDrawFrame(frame, [], { parts: {} });
73+
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 }]);
74+
});
75+
76+
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)', () => {
77+
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')])])]);
78+
const frame = el('draw:frame', box, [el('draw:text-box', {}, [list])]);
79+
const shape = readDrawFrame(frame, [], { parts: {} });
80+
expect(shape?.blocks.map((b) => (b.kind === 'paragraph' ? b.runs.map((r) => r.text).join('') : undefined))).toEqual(['item one', 'item two']);
81+
});
82+
83+
it('reads a draw:image\'s referenced media part, sniffed and sized to the frame\'s own resolved box', () => {
84+
const pkg: Package = { parts: { 'Pictures/img1.png': { kind: 'binary', base64: tinyPngBase64() } } };
85+
const frame = el('draw:frame', box, [el('draw:image', { 'xlink:href': 'Pictures/img1.png' })]);
86+
const shape = readDrawFrame(frame, [], pkg);
87+
expect(shape?.blocks).toEqual([{ kind: 'image', format: 'png', base64: tinyPngBase64(), widthPt: 100, heightPt: 50 }]);
88+
});
89+
90+
it('returns no blocks (not a thrown error) for a draw:image whose referenced part is missing', () => {
91+
const frame = el('draw:frame', box, [el('draw:image', { 'xlink:href': 'Pictures/missing.png' })]);
92+
expect(readDrawFrame(frame, [], { parts: {} })?.blocks).toEqual([]);
93+
});
94+
95+
it('reads a table:table child as a single ContentTable block', () => {
96+
const table = el('table:table', {}, [el('table:table-row', {}, [el('table:table-cell', {}, [el('text:p', {}, [txt('cell')])])])]);
97+
const frame = el('draw:frame', box, [table]);
98+
const shape = readDrawFrame(frame, [], { parts: {} });
99+
expect(shape?.blocks).toHaveLength(1);
100+
expect(shape?.blocks[0]?.kind).toBe('table');
101+
});
102+
103+
it('prefers table:table over a sibling draw:image fallback preview -- the real LibreOffice-generated shape both a table frame and its own .svm preview image share', () => {
104+
const table = el('table:table', {}, [el('table:table-row', {}, [el('table:table-cell', {}, [el('text:p', {}, [txt('cell')])])])]);
105+
const preview = el('draw:image', { 'xlink:href': 'Pictures/TablePreview1.svm' });
106+
const frame = el('draw:frame', box, [table, preview]);
107+
const shape = readDrawFrame(frame, [], { parts: {} });
108+
expect(shape?.blocks).toHaveLength(1);
109+
expect(shape?.blocks[0]?.kind).toBe('table');
110+
});
111+
112+
it('reads an empty frame (no text-box/image/table child at all) as an empty blocks array', () => {
113+
const frame = el('draw:frame', box);
114+
expect(readDrawFrame(frame, [], { parts: {} })?.blocks).toEqual([]);
115+
});
116+
});
117+
118+
describe('readDrawFrame: rotation via draw:transform', () => {
119+
it('composes into a center-pivoting frame + rotationDeg -- see transform.test.ts for the pixel-verified geometry this delegates to', () => {
120+
const frame = el('draw:frame', { 'svg:width': '200pt', 'svg:height': '60pt', 'draw:transform': 'rotate(1.5707963267948966) translate(100pt 100pt)' });
121+
const shape = readDrawFrame(frame, [], { parts: {} });
122+
expect(shape?.frame.xPt).toBeCloseTo(30, 6);
123+
expect(shape?.frame.yPt).toBeCloseTo(-30, 6);
124+
expect(shape?.rotationDeg).toBeCloseTo(-90, 6);
125+
});
126+
});
127+
128+
describe('walkDrawShapes: flat, non-grouped content', () => {
129+
it('collects every draw:frame at the top level, in document order', () => {
130+
const frame1 = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
131+
const frame2 = el('draw:frame', { 'svg:x': '20pt', 'svg:y': '20pt', 'svg:width': '10pt', 'svg:height': '10pt' });
132+
const out: ContentShape[] = [];
133+
walkDrawShapes([frame1, frame2], [], { parts: {} }, out);
134+
expect(out.map((s) => s.frame.xPt)).toEqual([0, 20]);
135+
});
136+
137+
it('skips a top-level element that is neither draw:frame nor draw:g (a bare vector-primitive shape, out of this task\'s documented scope)', () => {
138+
const out: ContentShape[] = [];
139+
walkDrawShapes([el('draw:rect', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' })], [], { parts: {} }, out);
140+
expect(out).toEqual([]);
141+
});
142+
143+
it('drops a draw:frame with no resolvable geometry rather than pushing a fabricated shape', () => {
144+
const out: ContentShape[] = [];
145+
walkDrawShapes([el('draw:frame')], [], { parts: {} }, out);
146+
expect(out).toEqual([]);
147+
});
148+
});
149+
150+
describe('walkDrawShapes: draw:g group flattening', () => {
151+
it('flattens a group\'s children into the parent\'s own flat shape list -- a real LibreOffice-generated group carries NO draw:transform of its own, so children keep their own literal, already-page-space coordinates unchanged', () => {
152+
const shapeA = el('draw:frame', { 'draw:name': 'A', 'svg:x': '50pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' });
153+
const shapeB = el('draw:frame', { 'draw:name': 'B', 'svg:x': '150pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' });
154+
const group = el('draw:g', {}, [shapeA, shapeB]);
155+
const out: ContentShape[] = [];
156+
walkDrawShapes([group], [], { parts: {} }, out);
157+
expect(out).toHaveLength(2);
158+
expect(out[0]).toMatchObject({ name: 'A', frame: { xPt: 50, yPt: 50, widthPt: 80, heightPt: 40 } });
159+
expect(out[1]).toMatchObject({ name: 'B', frame: { xPt: 150, yPt: 50, widthPt: 80, heightPt: 40 } });
160+
});
161+
162+
it('composes a group\'s own draw:transform onto each child -- a concrete before/after example: child center (90,70) rotated+translated by the group becomes center (170,10)', () => {
163+
// Before: child A's own box is x:50 y:50 w:80 h:40 -> local center (90, 70).
164+
const shapeA = el('draw:frame', { 'svg:x': '50pt', 'svg:y': '50pt', 'svg:width': '80pt', 'svg:height': '40pt' });
165+
// Group transform: rotate(pi/2) translate(100pt 100pt) -- verified against a real render in transform.test.ts.
166+
const group = el('draw:g', { 'draw:transform': 'rotate(1.5707963267948966) translate(100pt 100pt)' }, [shapeA]);
167+
const out: ContentShape[] = [];
168+
walkDrawShapes([group], [], { parts: {} }, out);
169+
expect(out).toHaveLength(1);
170+
// After: applyOdfTransform(groupFunctions, {90,70}) -> rotate: (70,-90) -> translate: (170,10) -> frame top-left = center - halfSize = (170-40, 10-20) = (130,-10).
171+
expect(out[0]?.frame.xPt).toBeCloseTo(130, 6);
172+
expect(out[0]?.frame.yPt).toBeCloseTo(-10, 6);
173+
expect(out[0]?.frame.widthPt).toBeCloseTo(80, 6); // unchanged -- no scale in ODF's own group model
174+
expect(out[0]?.frame.heightPt).toBeCloseTo(40, 6);
175+
expect(out[0]?.rotationDeg).toBeCloseTo(-90, 6);
176+
});
177+
178+
it('composes NESTED groups innermost-first: an inner group\'s own transform applies to the child before the outer group\'s own transform applies to the result', () => {
179+
const shape = el('draw:frame', { 'svg:x': '0pt', 'svg:y': '0pt', 'svg:width': '10pt', 'svg:height': '10pt' });
180+
const inner = el('draw:g', { 'draw:transform': 'translate(10pt 0pt)' }, [shape]);
181+
const outer = el('draw:g', { 'draw:transform': 'translate(0pt 10pt)' }, [inner]);
182+
const out: ContentShape[] = [];
183+
walkDrawShapes([outer], [], { parts: {} }, out);
184+
// Child local center (5,5) -> inner translate (10,0) -> (15,5) -> outer translate (0,10) -> (15,15) -> top-left (10,10).
185+
expect(out[0]?.frame.xPt).toBeCloseTo(10, 6);
186+
expect(out[0]?.frame.yPt).toBeCloseTo(10, 6);
187+
});
188+
189+
it('flattens an EMPTY group (no children) into nothing, without error', () => {
190+
const out: ContentShape[] = [];
191+
walkDrawShapes([el('draw:g')], [], { parts: {} }, out);
192+
expect(out).toEqual([]);
193+
});
194+
});

0 commit comments

Comments
 (0)