@elabs-ai/components-viewer 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
notesTarget,
|
|
5
|
+
orderSlidePaths,
|
|
6
|
+
parseNotes,
|
|
7
|
+
parseSlide,
|
|
8
|
+
resolveRelative,
|
|
9
|
+
slideNumber,
|
|
10
|
+
slidesToText,
|
|
11
|
+
slidesToTextWithMap,
|
|
12
|
+
PPTX_NOTES_LINE,
|
|
13
|
+
PPTX_TITLE_LINE,
|
|
14
|
+
} from "./pptx-model";
|
|
15
|
+
|
|
16
|
+
const parse = (xml: string) => new DOMParser().parseFromString(xml, "application/xml");
|
|
17
|
+
|
|
18
|
+
const P = "http://schemas.openxmlformats.org/presentationml/2006/main";
|
|
19
|
+
const A = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
20
|
+
|
|
21
|
+
/** A slide document, written the way PowerPoint writes one. */
|
|
22
|
+
const slideXml = (body: string) => `<p:sld xmlns:p="${P}" xmlns:a="${A}">
|
|
23
|
+
<p:cSld><p:spTree>${body}</p:spTree></p:cSld></p:sld>`;
|
|
24
|
+
|
|
25
|
+
const shape = (placeholder: string | undefined, paragraphs: string) => `<p:sp>
|
|
26
|
+
<p:nvSpPr><p:nvPr>${placeholder ? `<p:ph type="${placeholder}"/>` : ""}</p:nvPr></p:nvSpPr>
|
|
27
|
+
<p:txBody>${paragraphs}</p:txBody></p:sp>`;
|
|
28
|
+
|
|
29
|
+
const paragraph = (text: string, level?: number) =>
|
|
30
|
+
`<a:p>${level ? `<a:pPr lvl="${String(level)}"/>` : ""}<a:r><a:t>${text}</a:t></a:r></a:p>`;
|
|
31
|
+
|
|
32
|
+
describe("orderSlidePaths", () => {
|
|
33
|
+
it("orders slides numerically, not the way a zip lists them", () => {
|
|
34
|
+
// "slide10" sorts before "slide2" as a string — the deck would read wrong.
|
|
35
|
+
expect(
|
|
36
|
+
orderSlidePaths(["ppt/slides/slide10.xml", "ppt/slides/slide2.xml", "ppt/slides/slide1.xml"]),
|
|
37
|
+
).toEqual(["ppt/slides/slide1.xml", "ppt/slides/slide2.xml", "ppt/slides/slide10.xml"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores every other part of the package", () => {
|
|
41
|
+
expect(
|
|
42
|
+
orderSlidePaths([
|
|
43
|
+
"ppt/slides/slide1.xml",
|
|
44
|
+
"ppt/slides/_rels/slide1.xml.rels",
|
|
45
|
+
"ppt/slideLayouts/slideLayout1.xml",
|
|
46
|
+
"ppt/notesSlides/notesSlide1.xml",
|
|
47
|
+
"[Content_Types].xml",
|
|
48
|
+
]),
|
|
49
|
+
).toEqual(["ppt/slides/slide1.xml"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reads the slide number out of the part name", () => {
|
|
53
|
+
expect(slideNumber("ppt/slides/slide7.xml")).toBe(7);
|
|
54
|
+
expect(slideNumber("ppt/presentation.xml")).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("resolveRelative", () => {
|
|
59
|
+
it("walks `..` the way the package expects", () => {
|
|
60
|
+
expect(resolveRelative("ppt/slides/slide1.xml", "../notesSlides/notesSlide1.xml")).toBe(
|
|
61
|
+
"ppt/notesSlides/notesSlide1.xml",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("resolves a sibling part and an absolute one", () => {
|
|
66
|
+
expect(resolveRelative("ppt/slides/slide1.xml", "slide2.xml")).toBe("ppt/slides/slide2.xml");
|
|
67
|
+
expect(resolveRelative("ppt/slides/slide1.xml", "/ppt/presentation.xml")).toBe(
|
|
68
|
+
"ppt/presentation.xml",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("notesTarget", () => {
|
|
74
|
+
const rels = (body: string) =>
|
|
75
|
+
parse(
|
|
76
|
+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${body}</Relationships>`,
|
|
77
|
+
);
|
|
78
|
+
const type = (name: string) =>
|
|
79
|
+
`http://schemas.openxmlformats.org/officeDocument/2006/relationships/${name}`;
|
|
80
|
+
|
|
81
|
+
it("follows the slide's own relationship rather than matching slide numbers", () => {
|
|
82
|
+
// Slide 1 pointing at notesSlide4 is what a deck looks like after slides
|
|
83
|
+
// have been deleted — matching by number would attach the wrong notes.
|
|
84
|
+
const document = rels(
|
|
85
|
+
`<Relationship Id="rId1" Type="${type("slideLayout")}" Target="../slideLayouts/slideLayout1.xml"/>
|
|
86
|
+
<Relationship Id="rId2" Type="${type("notesSlide")}" Target="../notesSlides/notesSlide4.xml"/>`,
|
|
87
|
+
);
|
|
88
|
+
expect(notesTarget(document, "ppt/slides/slide1.xml")).toBe("ppt/notesSlides/notesSlide4.xml");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns nothing for a slide with no notes", () => {
|
|
92
|
+
const document = rels(
|
|
93
|
+
`<Relationship Id="rId1" Type="${type("slideLayout")}" Target="../slideLayouts/slideLayout1.xml"/>`,
|
|
94
|
+
);
|
|
95
|
+
expect(notesTarget(document, "ppt/slides/slide1.xml")).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("parseSlide", () => {
|
|
100
|
+
it("separates the title placeholder from the body text", () => {
|
|
101
|
+
const slide = parseSlide(
|
|
102
|
+
parse(
|
|
103
|
+
slideXml(
|
|
104
|
+
shape("title", paragraph("Quarterly review")) +
|
|
105
|
+
shape("body", paragraph("Revenue grew 18%") + paragraph("EMEA beat plan", 1)),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
1,
|
|
109
|
+
);
|
|
110
|
+
expect(slide.title).toBe("Quarterly review");
|
|
111
|
+
expect(slide.lines).toEqual([
|
|
112
|
+
{ text: "Revenue grew 18%", level: 0 },
|
|
113
|
+
{ text: "EMEA beat plan", level: 1 },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("treats a centred title as a title", () => {
|
|
118
|
+
const slide = parseSlide(parse(slideXml(shape("ctrTitle", paragraph("Cover")))), 1);
|
|
119
|
+
expect(slide.title).toBe("Cover");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps a shape with no placeholder as body text", () => {
|
|
123
|
+
const slide = parseSlide(parse(slideXml(shape(undefined, paragraph("A loose text box")))), 1);
|
|
124
|
+
expect(slide.title).toBeUndefined();
|
|
125
|
+
expect(slide.lines).toEqual([{ text: "A loose text box", level: 0 }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("reads a table on the slide, one row per line", () => {
|
|
129
|
+
const table = `<p:graphicFrame><a:graphic><a:graphicData><a:tbl>
|
|
130
|
+
<a:tr><a:tc><a:txBody><a:p><a:r><a:t>Region</a:t></a:r></a:p></a:txBody></a:tc>
|
|
131
|
+
<a:tc><a:txBody><a:p><a:r><a:t>Revenue</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
|
|
132
|
+
</a:tbl></a:graphicData></a:graphic></p:graphicFrame>`;
|
|
133
|
+
const slide = parseSlide(parse(slideXml(table)), 2);
|
|
134
|
+
expect(slide.lines).toEqual([{ text: "Region\tRevenue", level: 0 }]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("drops empty paragraphs — a blank placeholder is not a bullet", () => {
|
|
138
|
+
const slide = parseSlide(parse(slideXml(shape("body", "<a:p/>" + paragraph("Real")))), 1);
|
|
139
|
+
expect(slide.lines).toEqual([{ text: "Real", level: 0 }]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns an empty slide rather than throwing when there is no shape tree", () => {
|
|
143
|
+
expect(parseSlide(parse(`<p:sld xmlns:p="${P}"/>`), 3)).toEqual({ index: 3, lines: [] });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("parseNotes", () => {
|
|
148
|
+
it("reads only the notes placeholder, not the copy of the slide beside it", () => {
|
|
149
|
+
const notes = parseNotes(
|
|
150
|
+
parse(
|
|
151
|
+
`<p:notes xmlns:p="${P}" xmlns:a="${A}"><p:cSld><p:spTree>` +
|
|
152
|
+
shape("sldImg", paragraph("Quarterly review")) +
|
|
153
|
+
shape("body", paragraph("Open with the revenue number.")) +
|
|
154
|
+
`</p:spTree></p:cSld></p:notes>`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
expect(notes).toBe("Open with the revenue number.");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns nothing when the notes part holds no notes", () => {
|
|
161
|
+
expect(parseNotes(parse(`<p:notes xmlns:p="${P}"/>`))).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("slidesToText", () => {
|
|
166
|
+
it("projects titles, lines and notes into one searchable string", () => {
|
|
167
|
+
expect(
|
|
168
|
+
slidesToText([
|
|
169
|
+
{ index: 1, title: "Quarterly review", lines: [{ text: "Revenue grew 18%", level: 0 }] },
|
|
170
|
+
{ index: 2, title: "Regions", lines: [], notes: "Hand over to Sam." },
|
|
171
|
+
]),
|
|
172
|
+
).toBe("Quarterly review\nRevenue grew 18%\n\nRegions\nHand over to Sam.");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("slidesToTextWithMap", () => {
|
|
177
|
+
it("maps every chunk back to the slide and line it came from", () => {
|
|
178
|
+
const { text, spans } = slidesToTextWithMap([
|
|
179
|
+
{
|
|
180
|
+
index: 1,
|
|
181
|
+
title: "Quarterly review",
|
|
182
|
+
lines: [
|
|
183
|
+
{ text: "Revenue grew 18%", level: 0 },
|
|
184
|
+
{ text: "EMEA beat plan", level: 1 },
|
|
185
|
+
],
|
|
186
|
+
notes: "Hand over to Sam.",
|
|
187
|
+
},
|
|
188
|
+
{ index: 2, title: "Regions", lines: [] },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
expect(spans.map((span) => [text.slice(span.start, span.end), span.ref])).toEqual([
|
|
192
|
+
["Quarterly review", { slide: 1, line: PPTX_TITLE_LINE }],
|
|
193
|
+
["Revenue grew 18%", { slide: 1, line: 0 }],
|
|
194
|
+
["EMEA beat plan", { slide: 1, line: 1 }],
|
|
195
|
+
["Hand over to Sam.", { slide: 1, line: PPTX_NOTES_LINE }],
|
|
196
|
+
["Regions", { slide: 2, line: PPTX_TITLE_LINE }],
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("refs the slide's own number, not its position, so a gap cannot misattribute", () => {
|
|
201
|
+
const { spans } = slidesToTextWithMap([{ index: 7, lines: [{ text: "Only line", level: 0 }] }]);
|
|
202
|
+
expect(spans[0]?.ref).toEqual({ slide: 7, line: 0 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("is the one definition of the projection", () => {
|
|
206
|
+
const slides = [{ index: 1, title: "A", lines: [], notes: "B" }];
|
|
207
|
+
expect(slidesToText(slides)).toBe(slidesToTextWithMap(slides).text);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("contributes nothing for a slide with no text at all", () => {
|
|
211
|
+
const { text, spans } = slidesToTextWithMap([
|
|
212
|
+
{ index: 1, title: "A", lines: [] },
|
|
213
|
+
{ index: 2, lines: [] },
|
|
214
|
+
{ index: 3, title: "C", lines: [] },
|
|
215
|
+
]);
|
|
216
|
+
expect(text).toBe("A\n\nC");
|
|
217
|
+
expect(spans.map((span) => span.ref.slide)).toEqual([1, 3]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerPoint XML → a slide outline.
|
|
3
|
+
*
|
|
4
|
+
* ## What a deck preview honestly is
|
|
5
|
+
*
|
|
6
|
+
* A `.pptx` slide is absolutely-positioned drawing: shapes at EMU coordinates,
|
|
7
|
+
* theme-driven fills, transitions, embedded media. Reproducing that faithfully
|
|
8
|
+
* is a rendering engine, not a preview — and a half-faithful reproduction is
|
|
9
|
+
* worse than none, because it looks like the deck while quietly lying about it.
|
|
10
|
+
*
|
|
11
|
+
* So this reads the deck as an OUTLINE: per slide, the title, the text in
|
|
12
|
+
* document order (with its indent level), and the speaker notes. That is the
|
|
13
|
+
* content a reader is looking for when they open a deck in a file browser, it
|
|
14
|
+
* renders in this system's typography, and it is honest about what it is.
|
|
15
|
+
* Anything positional — layout, images, charts, transitions — is deliberately
|
|
16
|
+
* absent; the toolbar's download is the answer for the real thing.
|
|
17
|
+
*
|
|
18
|
+
* Parsing goes through namespace URIs rather than `a:t` / `p:sp` tag names,
|
|
19
|
+
* because the prefix is only a convention: a generator is free to bind the same
|
|
20
|
+
* namespace to a different prefix, and tag-name matching would silently return
|
|
21
|
+
* an empty deck.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createTextIndexBuilder, type TextIndex } from "../../core/text-index";
|
|
25
|
+
|
|
26
|
+
/** The DrawingML namespace — text, paragraphs, tables. */
|
|
27
|
+
export const DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
28
|
+
/** The PresentationML namespace — shapes, placeholders, the shape tree. */
|
|
29
|
+
export const PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main";
|
|
30
|
+
|
|
31
|
+
/** One line of slide text, with the indent level PowerPoint gave it. */
|
|
32
|
+
export interface PptxLine {
|
|
33
|
+
text: string;
|
|
34
|
+
/** Outline depth, `0` for a top-level bullet. */
|
|
35
|
+
level: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PptxSlide {
|
|
39
|
+
/** 1-based position in the deck. */
|
|
40
|
+
index: number;
|
|
41
|
+
/** The title placeholder's text, when the slide has one. */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Every other line, in document order. */
|
|
44
|
+
lines: PptxLine[];
|
|
45
|
+
/** The slide's speaker notes, when it has any. */
|
|
46
|
+
notes?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Order slide parts the way the deck does.
|
|
51
|
+
*
|
|
52
|
+
* A zip lists its entries in whatever order they were written, and
|
|
53
|
+
* `slide10.xml` sorts before `slide2.xml` as a string — so the number is pulled
|
|
54
|
+
* out and compared as a number.
|
|
55
|
+
*/
|
|
56
|
+
export function orderSlidePaths(paths: string[]): string[] {
|
|
57
|
+
return paths
|
|
58
|
+
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
|
|
59
|
+
.sort((left, right) => slideNumber(left) - slideNumber(right));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `N` in `…/slideN.xml`, or `0` when there is none. */
|
|
63
|
+
export function slideNumber(path: string): number {
|
|
64
|
+
const match = /(\d+)\.xml$/.exec(path);
|
|
65
|
+
return match?.[1] ? Number(match[1]) : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** All text under a node, in document order, as one string. */
|
|
69
|
+
function textOf(node: Element): string {
|
|
70
|
+
const runs = node.getElementsByTagNameNS(DRAWING_NS, "t");
|
|
71
|
+
let text = "";
|
|
72
|
+
for (const run of Array.from(runs)) text += run.textContent ?? "";
|
|
73
|
+
return text.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The paragraphs directly under a shape, as lines. Empty paragraphs are dropped. */
|
|
77
|
+
function linesOf(shape: Element): PptxLine[] {
|
|
78
|
+
const lines: PptxLine[] = [];
|
|
79
|
+
for (const paragraph of Array.from(shape.getElementsByTagNameNS(DRAWING_NS, "p"))) {
|
|
80
|
+
const text = textOf(paragraph);
|
|
81
|
+
if (!text) continue;
|
|
82
|
+
// `lvl` is absent for a top-level bullet, which is the common case.
|
|
83
|
+
const properties = paragraph.getElementsByTagNameNS(DRAWING_NS, "pPr")[0];
|
|
84
|
+
const level = Number(properties?.getAttribute("lvl") ?? 0);
|
|
85
|
+
lines.push({ text, level: Number.isFinite(level) ? level : 0 });
|
|
86
|
+
}
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The placeholder type a shape declares (`title`, `ctrTitle`, `body`, …), if any. */
|
|
91
|
+
function placeholderType(shape: Element): string | undefined {
|
|
92
|
+
const placeholder = shape.getElementsByTagNameNS(PRESENTATION_NS, "ph")[0];
|
|
93
|
+
return placeholder?.getAttribute("type") ?? undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Table text, row by row, tab-separated — a table on a slide is usually the point of it. */
|
|
97
|
+
function tableLines(frame: Element): PptxLine[] {
|
|
98
|
+
const lines: PptxLine[] = [];
|
|
99
|
+
for (const row of Array.from(frame.getElementsByTagNameNS(DRAWING_NS, "tr"))) {
|
|
100
|
+
const cells = Array.from(row.getElementsByTagNameNS(DRAWING_NS, "tc")).map(textOf);
|
|
101
|
+
const text = cells.join("\t").trim();
|
|
102
|
+
if (text) lines.push({ text, level: 0 });
|
|
103
|
+
}
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse one `ppt/slides/slideN.xml` document into a slide.
|
|
109
|
+
*
|
|
110
|
+
* The shape tree is walked in document order, so the outline reads in the order
|
|
111
|
+
* the shapes were authored rather than in the order the file happens to store
|
|
112
|
+
* them. A shape kind this does not name contributes nothing — the same
|
|
113
|
+
* allowlist-by-parse the Word adapter uses.
|
|
114
|
+
*/
|
|
115
|
+
export function parseSlide(document: Document, index: number): PptxSlide {
|
|
116
|
+
const tree = document.getElementsByTagNameNS(PRESENTATION_NS, "spTree")[0];
|
|
117
|
+
const slide: PptxSlide = { index, lines: [] };
|
|
118
|
+
if (!tree) return slide;
|
|
119
|
+
|
|
120
|
+
for (const node of Array.from(tree.children)) {
|
|
121
|
+
if (node.namespaceURI !== PRESENTATION_NS) continue;
|
|
122
|
+
if (node.localName === "sp") {
|
|
123
|
+
const type = placeholderType(node);
|
|
124
|
+
const lines = linesOf(node);
|
|
125
|
+
if ((type === "title" || type === "ctrTitle") && !slide.title) {
|
|
126
|
+
// A title placeholder can hold several paragraphs; they are one heading.
|
|
127
|
+
const title = lines.map((line) => line.text).join(" ");
|
|
128
|
+
if (title) slide.title = title;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
slide.lines.push(...lines);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (node.localName === "graphicFrame") slide.lines.push(...tableLines(node));
|
|
135
|
+
}
|
|
136
|
+
return slide;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Parse a `ppt/notesSlides/notesSlideN.xml` document into its notes text. */
|
|
140
|
+
export function parseNotes(document: Document): string | undefined {
|
|
141
|
+
const tree = document.getElementsByTagNameNS(PRESENTATION_NS, "spTree")[0];
|
|
142
|
+
if (!tree) return undefined;
|
|
143
|
+
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
for (const shape of Array.from(tree.getElementsByTagNameNS(PRESENTATION_NS, "sp"))) {
|
|
146
|
+
// The notes part repeats the slide's own body as a non-editable copy; only
|
|
147
|
+
// the notes placeholder holds what the presenter actually wrote.
|
|
148
|
+
if (placeholderType(shape) !== "body") continue;
|
|
149
|
+
for (const line of linesOf(shape)) lines.push(line.text);
|
|
150
|
+
}
|
|
151
|
+
const notes = lines.join("\n").trim();
|
|
152
|
+
return notes || undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The OPC relationships namespace — how a part points at another part. */
|
|
156
|
+
export const RELATIONSHIP_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a relationship target (`../notesSlides/notesSlide1.xml`) against the
|
|
160
|
+
* part that declared it (`ppt/slides/slide1.xml`).
|
|
161
|
+
*
|
|
162
|
+
* Zip entry names are plain strings, not URLs, so `..` has to be walked by hand.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveRelative(fromPart: string, target: string): string {
|
|
165
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
166
|
+
const segments = fromPart.split("/").slice(0, -1);
|
|
167
|
+
for (const segment of target.split("/")) {
|
|
168
|
+
if (segment === "." || segment === "") continue;
|
|
169
|
+
if (segment === "..") segments.pop();
|
|
170
|
+
else segments.push(segment);
|
|
171
|
+
}
|
|
172
|
+
return segments.join("/");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The notes part a slide points at, if any.
|
|
177
|
+
*
|
|
178
|
+
* Read from the slide's own `_rels`, not by matching `slideN` to `notesSlideN`:
|
|
179
|
+
* the numbers agree in decks PowerPoint wrote from scratch and drift in decks
|
|
180
|
+
* that have had slides deleted, which would attach the wrong presenter notes to
|
|
181
|
+
* the wrong slide — a quiet, plausible-looking error.
|
|
182
|
+
*/
|
|
183
|
+
export function notesTarget(rels: Document, slidePath: string): string | undefined {
|
|
184
|
+
for (const relationship of Array.from(
|
|
185
|
+
rels.getElementsByTagNameNS(RELATIONSHIP_NS, "Relationship"),
|
|
186
|
+
)) {
|
|
187
|
+
if (!relationship.getAttribute("Type")?.endsWith("/notesSlide")) continue;
|
|
188
|
+
const target = relationship.getAttribute("Target");
|
|
189
|
+
if (target) return resolveRelative(slidePath, target);
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Between two lines of the same slide. */
|
|
195
|
+
export const PPTX_LINE_SEPARATOR = "\n";
|
|
196
|
+
/** Between two slides. */
|
|
197
|
+
export const PPTX_SLIDE_SEPARATOR = "\n\n";
|
|
198
|
+
|
|
199
|
+
/** The `line` of a slide's title. */
|
|
200
|
+
export const PPTX_TITLE_LINE = -1;
|
|
201
|
+
/** The `line` of a slide's speaker notes. */
|
|
202
|
+
export const PPTX_NOTES_LINE = -2;
|
|
203
|
+
|
|
204
|
+
/** Where a stretch of the projection came from in the outline. */
|
|
205
|
+
export interface PptxRef {
|
|
206
|
+
/** 1-based slide position, the same number {@link PptxSlide.index} carries. */
|
|
207
|
+
slide: number;
|
|
208
|
+
/** Body-line index, or {@link PPTX_TITLE_LINE} / {@link PPTX_NOTES_LINE}. */
|
|
209
|
+
line: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Plain-text projection of a deck, plus the map back into the outline.
|
|
214
|
+
*
|
|
215
|
+
* A slide's title, its lines and its notes are each their own chunk, so a
|
|
216
|
+
* citation resolves to the exact line the renderer draws rather than to "slide
|
|
217
|
+
* 4". The blank line between slides is written before whichever chunk turns out
|
|
218
|
+
* to be the next slide's first — a slide with no text at all contributes
|
|
219
|
+
* nothing, instead of a run of empty lines that would shift every offset after
|
|
220
|
+
* it.
|
|
221
|
+
*/
|
|
222
|
+
export function slidesToTextWithMap(slides: PptxSlide[]): TextIndex<PptxRef> {
|
|
223
|
+
const builder = createTextIndexBuilder<PptxRef>({ separator: PPTX_LINE_SEPARATOR });
|
|
224
|
+
slides.forEach((slide, index) => {
|
|
225
|
+
let separator = index === 0 ? undefined : PPTX_SLIDE_SEPARATOR;
|
|
226
|
+
const push = (chunk: string | undefined, line: number) => {
|
|
227
|
+
if (!chunk) return;
|
|
228
|
+
builder.push(chunk, { slide: slide.index, line }, separator);
|
|
229
|
+
separator = undefined;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
push(slide.title, PPTX_TITLE_LINE);
|
|
233
|
+
slide.lines.forEach((line, lineIndex) => {
|
|
234
|
+
push(line.text, lineIndex);
|
|
235
|
+
});
|
|
236
|
+
push(slide.notes, PPTX_NOTES_LINE);
|
|
237
|
+
});
|
|
238
|
+
return builder.build();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Plain-text projection of a deck — powers search, copy and the raw view. A thin
|
|
243
|
+
* wrapper so the projection has exactly one definition: it is the index's own
|
|
244
|
+
* text, never a second assembly that could drift from it.
|
|
245
|
+
*/
|
|
246
|
+
export function slidesToText(slides: PptxSlide[]): string {
|
|
247
|
+
return slidesToTextWithMap(slides).text;
|
|
248
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { normalizeFileSource } from "@elabs-ai/components-ui";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { ResolvedHighlight } from "../../core/highlight";
|
|
6
|
+
import textModule, { TEXT_CHARACTER_LIMIT, type TextDocument } from "./text-adapter";
|
|
7
|
+
|
|
8
|
+
const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
|
|
9
|
+
id,
|
|
10
|
+
source: "citation",
|
|
11
|
+
status: "resolved",
|
|
12
|
+
address: { kind: "range", start: range[0], end: range[1] },
|
|
13
|
+
active,
|
|
14
|
+
range,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const load = (text: string, name = "notes.txt") =>
|
|
18
|
+
textModule
|
|
19
|
+
.create()
|
|
20
|
+
.load(normalizeFileSource({ kind: "text", text, name }), {}) as Promise<TextDocument>;
|
|
21
|
+
|
|
22
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "notes.txt" });
|
|
23
|
+
|
|
24
|
+
describe("text adapter — loading", () => {
|
|
25
|
+
it("keeps a small file whole and claims no truncation", async () => {
|
|
26
|
+
const doc = await load("line one\nline two");
|
|
27
|
+
expect(doc.text).toBe("line one\nline two");
|
|
28
|
+
expect(doc.totalCharacters).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("truncates at load, not at render — a huge file must not reach the DOM", async () => {
|
|
32
|
+
const doc = await load("x".repeat(TEXT_CHARACTER_LIMIT + 500));
|
|
33
|
+
expect(doc.text).toHaveLength(TEXT_CHARACTER_LIMIT);
|
|
34
|
+
expect(doc.totalCharacters).toBe(TEXT_CHARACTER_LIMIT + 500);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not truncate a file exactly at the limit", async () => {
|
|
38
|
+
const doc = await load("x".repeat(TEXT_CHARACTER_LIMIT));
|
|
39
|
+
expect(doc.totalCharacters).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("text adapter — rendering", () => {
|
|
44
|
+
it("preserves whitespace so indentation survives", async () => {
|
|
45
|
+
const doc = await load(" indented\n\n again");
|
|
46
|
+
const { container } = render(<textModule.Renderer document={doc} source={source} />);
|
|
47
|
+
expect(container.querySelector("pre")?.textContent).toBe(" indented\n\n again");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("announces truncation as a status, never an error", async () => {
|
|
51
|
+
const doc = await load("x".repeat(TEXT_CHARACTER_LIMIT + 1));
|
|
52
|
+
render(<textModule.Renderer document={doc} source={source} />);
|
|
53
|
+
expect(screen.getByRole("status")).toHaveTextContent(/Showing the first/);
|
|
54
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("says nothing when the whole file is shown", async () => {
|
|
58
|
+
const doc = await load("short");
|
|
59
|
+
render(<textModule.Renderer document={doc} source={source} />);
|
|
60
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("text adapter — highlighting", () => {
|
|
65
|
+
it("declares the address kinds it can actually honour, and no others", () => {
|
|
66
|
+
// `rect` is missing on purpose: plain text has no geometry.
|
|
67
|
+
expect(textModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reports a capped file as truncated, so a miss can say WHY", async () => {
|
|
71
|
+
expect((await load("x".repeat(TEXT_CHARACTER_LIMIT + 1))).textTruncated).toBe(true);
|
|
72
|
+
expect((await load("short")).textTruncated).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("marks a located range and leaves the rest of the file intact", async () => {
|
|
76
|
+
const doc = await load("the delay was escalated");
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<textModule.Renderer document={doc} source={source} highlights={[cite("a", [4, 9])]} />,
|
|
79
|
+
);
|
|
80
|
+
const marks = container.querySelectorAll("mark");
|
|
81
|
+
expect(marks).toHaveLength(1);
|
|
82
|
+
expect(marks[0]).toHaveTextContent("delay");
|
|
83
|
+
// Nothing is dropped — a screen reader still reads the file continuously.
|
|
84
|
+
expect(container.querySelector("pre")?.textContent).toBe("the delay was escalated");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("distinguishes the current highlight from the rest", async () => {
|
|
88
|
+
const doc = await load("delay delay delay");
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<textModule.Renderer
|
|
91
|
+
document={doc}
|
|
92
|
+
source={source}
|
|
93
|
+
highlights={[cite("a", [0, 5]), cite("b", [6, 11], true), cite("c", [12, 17])]}
|
|
94
|
+
activeHighlightId="b"
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(container.querySelectorAll("mark")).toHaveLength(3);
|
|
98
|
+
const active = container.querySelectorAll("mark[data-active]");
|
|
99
|
+
expect(active).toHaveLength(1);
|
|
100
|
+
expect(active[0]?.getAttribute("aria-current")).toBe("true");
|
|
101
|
+
expect(container.querySelectorAll("mark")[1]).toBe(active[0]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders the plain string when nothing located — no empty mark layer", async () => {
|
|
105
|
+
const doc = await load("nothing to point at");
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<textModule.Renderer
|
|
108
|
+
document={doc}
|
|
109
|
+
source={source}
|
|
110
|
+
highlights={[
|
|
111
|
+
{
|
|
112
|
+
id: "a",
|
|
113
|
+
source: "citation",
|
|
114
|
+
status: "not-found",
|
|
115
|
+
reason: "absent",
|
|
116
|
+
address: { kind: "quote", text: "missing" },
|
|
117
|
+
active: false,
|
|
118
|
+
},
|
|
119
|
+
]}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
expect(container.querySelectorAll("mark")).toHaveLength(0);
|
|
123
|
+
expect(container.querySelector("pre")?.textContent).toBe("nothing to point at");
|
|
124
|
+
});
|
|
125
|
+
});
|