@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,241 @@
1
+ import { normalizeFileSource } from "@elabs-ai/components-ui";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import type { ResolvedHighlight } from "../../core/highlight";
6
+ import { SAMPLE_PPTX_BASE64 } from "../office-fixture";
7
+ import pptxModule, { type PptxDocument } from "./pptx-adapter";
8
+
9
+ const MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
10
+
11
+ function fixtureSource(name = "quarter.pptx") {
12
+ const bytes = Uint8Array.from(atob(SAMPLE_PPTX_BASE64), (char) => char.charCodeAt(0));
13
+ return normalizeFileSource({
14
+ kind: "buffer",
15
+ buffer: bytes.buffer as ArrayBuffer,
16
+ name,
17
+ mediaType: MEDIA_TYPE,
18
+ });
19
+ }
20
+
21
+ const load = () => pptxModule.create().load(fixtureSource(), {}) as Promise<PptxDocument>;
22
+
23
+ // jsdom implements no scrolling, so the programmatic scroll is the only
24
+ // observable half of "take me to slide N" here. What the reader then sees is
25
+ // browser behaviour, and the story is what verifies it.
26
+ const scrollTo = vi.fn();
27
+ beforeAll(() => {
28
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", {
29
+ configurable: true,
30
+ writable: true,
31
+ value: scrollTo,
32
+ });
33
+ });
34
+ beforeEach(() => {
35
+ scrollTo.mockClear();
36
+ });
37
+
38
+ /** Where the column was asked to scroll to, in CSS pixels. */
39
+ const scrolledTo = () => (scrollTo.mock.calls.at(-1)?.[0] as { top?: number } | undefined)?.top;
40
+
41
+ /** Where a mounted slide actually sits in the column — its own laid-out offset. */
42
+ function offsetOf(index: number): number {
43
+ const item = globalThis.document.querySelector(`[data-index="${index}"]`) as HTMLElement;
44
+ return Number(/translateY\((-?[\d.]+)px\)/.exec(item.style.transform)?.[1]);
45
+ }
46
+
47
+ describe("pptx adapter — a real deck through jszip", () => {
48
+ it("reads every slide in deck order, with its title", async () => {
49
+ const doc = await load();
50
+ expect(doc.pageCount).toBe(2);
51
+ expect(doc.slides.map((slide) => slide.title)).toEqual(["Quarterly review", "Regions"]);
52
+ });
53
+
54
+ it("keeps the outline depth PowerPoint gave each line", async () => {
55
+ const [first] = (await load()).slides;
56
+ expect(first?.lines).toEqual([
57
+ { text: "Revenue grew 18%", level: 0 },
58
+ { text: "EMEA beat plan", level: 1 },
59
+ { text: "APAC held flat", level: 1 },
60
+ ]);
61
+ });
62
+
63
+ it("reads a table on a slide", async () => {
64
+ const [, second] = (await load()).slides;
65
+ expect(second?.lines).toContainEqual({ text: "Region\tRevenue", level: 0 });
66
+ });
67
+
68
+ it("follows the slide's relationship to its speaker notes", async () => {
69
+ const doc = await load();
70
+ expect(doc.slides[0]?.notes).toBe("Open with the revenue number, then hand over to Sam.");
71
+ // Slide 2 has no notes part; that is a complete deck, not a failure.
72
+ expect(doc.slides[1]?.notes).toBeUndefined();
73
+ });
74
+
75
+ it("projects the deck to text, notes included", async () => {
76
+ const doc = await load();
77
+ expect(doc.text).toContain("Quarterly review");
78
+ expect(doc.text).toContain("hand over to Sam");
79
+ });
80
+
81
+ it("reports a file that is not a deck as parse-failed, not as a crash", async () => {
82
+ const source = normalizeFileSource({
83
+ kind: "text",
84
+ text: "not a zip",
85
+ name: "broken.pptx",
86
+ mediaType: MEDIA_TYPE,
87
+ });
88
+ await expect(pptxModule.create().load(source, {})).rejects.toMatchObject({
89
+ code: "parse-failed",
90
+ });
91
+ });
92
+ });
93
+
94
+ describe("pptx renderer", () => {
95
+ it("shows the first slide, in a named frame the keyboard can reach", async () => {
96
+ const doc = await load();
97
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} />);
98
+
99
+ expect(screen.getByRole("heading", { name: "Quarterly review" })).toBeInTheDocument();
100
+ // A slide that overflows its 16:9 frame scrolls, so the frame itself has to
101
+ // be a focus stop with a name (WCAG 2.1.1).
102
+ expect(screen.getByLabelText("Slide 1")).toHaveAttribute("tabindex", "0");
103
+ });
104
+
105
+ it("stacks the slides — a deck scrolls, it does not flip", async () => {
106
+ // The behaviour that prompted the change: slide 2 is one gesture below slide
107
+ // 1, not behind a "next" button that swaps the frame.
108
+ const doc = await load();
109
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} />);
110
+ expect(screen.getByRole("heading", { name: "Quarterly review" })).toBeInTheDocument();
111
+ expect(screen.getByRole("heading", { name: "Regions" })).toBeInTheDocument();
112
+ });
113
+
114
+ it("scrolls to the slide the shell asked for, including on the first frame", async () => {
115
+ const doc = await load();
116
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} pageNumber={2} />);
117
+ await waitFor(() => expect(scrolledTo()).toBe(offsetOf(1)));
118
+ });
119
+
120
+ it("clamps a slide number past the end of the deck", async () => {
121
+ const doc = await load();
122
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} pageNumber={9} />);
123
+ // The last slide, not an offset past the end of the column.
124
+ await waitFor(() => expect(scrolledTo()).toBe(offsetOf(1)));
125
+ });
126
+
127
+ it("draws no chrome of its own — the pager lives in the shell (ADR 0026)", async () => {
128
+ const doc = await load();
129
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} />);
130
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
131
+ expect(screen.queryByRole("button", { name: "Next slide" })).not.toBeInTheDocument();
132
+ });
133
+
134
+ it("shows the speaker notes as their own labelled region", async () => {
135
+ const doc = await load();
136
+ render(<pptxModule.Renderer document={doc} source={fixtureSource()} />);
137
+ expect(screen.getByRole("region", { name: "Speaker notes" })).toHaveTextContent(
138
+ "hand over to Sam",
139
+ );
140
+ });
141
+
142
+ it("says so when a slide has no text, instead of showing an empty frame", async () => {
143
+ const doc = await load();
144
+ const blank: PptxDocument = {
145
+ ...doc,
146
+ slides: [{ index: 1, lines: [] }],
147
+ pageCount: 1,
148
+ };
149
+ render(<pptxModule.Renderer document={blank} source={fixtureSource()} />);
150
+ expect(screen.getByText("This slide has no text")).toBeInTheDocument();
151
+ expect(screen.getByRole("heading", { name: "Untitled slide" })).toBeInTheDocument();
152
+ });
153
+ });
154
+
155
+ describe("pptx renderer — highlighting", () => {
156
+ const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
157
+ id,
158
+ source: "citation",
159
+ status: "resolved",
160
+ address: { kind: "range", start: range[0], end: range[1] },
161
+ active,
162
+ range,
163
+ });
164
+
165
+ /** Offsets of a passage in the deck's own projection. */
166
+ const rangeOf = (doc: PptxDocument, passage: string): [number, number] => {
167
+ const start = doc.text?.indexOf(passage) ?? -1;
168
+ expect(start).toBeGreaterThanOrEqual(0);
169
+ return [start, start + passage.length];
170
+ };
171
+
172
+ const renderWith = async (
173
+ pick: (doc: PptxDocument) => ResolvedHighlight[],
174
+ activeHighlightId?: string,
175
+ onPageChange?: (page: number) => void,
176
+ ) => {
177
+ const doc = await load();
178
+ return render(
179
+ <pptxModule.Renderer
180
+ document={doc}
181
+ source={fixtureSource()}
182
+ highlights={pick(doc)}
183
+ activeHighlightId={activeHighlightId}
184
+ onPageChange={onPageChange}
185
+ />,
186
+ );
187
+ };
188
+
189
+ it("declares the address kinds it can actually honour", () => {
190
+ expect(pptxModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
191
+ });
192
+
193
+ it("marks the bullet a citation points at, not the whole slide", async () => {
194
+ const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "EMEA beat plan"))]);
195
+ const marks = Array.from(container.querySelectorAll("mark"));
196
+ expect(marks).toHaveLength(1);
197
+ expect(marks[0]?.closest("li")?.textContent).toBe("EMEA beat plan");
198
+ });
199
+
200
+ it("marks the slide title", async () => {
201
+ const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "Quarterly"))]);
202
+ expect(container.querySelector("mark")?.closest("h2")?.textContent).toBe("Quarterly review");
203
+ });
204
+
205
+ it("marks the speaker notes, which are part of the projection", async () => {
206
+ const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "hand over to Sam"))]);
207
+ const mark = container.querySelector("mark");
208
+ expect(mark?.textContent).toBe("hand over to Sam");
209
+ expect(mark?.closest("section")?.getAttribute("aria-label")).toBe("Speaker notes");
210
+ });
211
+
212
+ it("pages to the cited slide instead of marking nothing", async () => {
213
+ // "Region\tRevenue" is a table on slide 2, which is not the slide that opens.
214
+ // The renderer no longer owns the pager, so this is a REQUEST it makes —
215
+ // asserted both as the call and as its effect.
216
+ const onPageChange = vi.fn();
217
+ const { container } = await renderWith(
218
+ (doc) => [cite("a", rangeOf(doc, "Region\tRevenue"), true)],
219
+ "a",
220
+ onPageChange,
221
+ );
222
+ expect(await screen.findByRole("heading", { name: "Regions" })).toBeInTheDocument();
223
+ expect(onPageChange).toHaveBeenCalledWith(2);
224
+ expect(container.querySelector("mark")?.textContent).toBe("Region\tRevenue");
225
+ });
226
+
227
+ it("flags the current passage for assistive tech, not by colour alone", async () => {
228
+ const { container } = await renderWith(
229
+ (doc) => [cite("a", rangeOf(doc, "Revenue grew 18%"), true)],
230
+ "a",
231
+ );
232
+ const active = container.querySelector('mark[data-active][data-slot="match-highlight-mark"]');
233
+ expect(active?.getAttribute("aria-current")).toBe("true");
234
+ expect(active?.textContent).toBe("Revenue grew 18%");
235
+ });
236
+
237
+ it("draws nothing when no highlight is passed", async () => {
238
+ const { container } = await renderWith(() => []);
239
+ expect(container.querySelectorAll("mark")).toHaveLength(0);
240
+ });
241
+ });
@@ -0,0 +1,341 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PowerPoint adapter — a deck becomes a slide outline you can page through.
5
+ *
6
+ * A `.pptx` is a zip of XML, so there is no PowerPoint library here: jszip opens
7
+ * the container and the platform's own `DOMParser` reads the parts. What is
8
+ * extracted, and why it is an outline rather than a reproduction of the slide
9
+ * canvas, is argued in `pptx-model.ts`.
10
+ *
11
+ * The deck is one continuous column of slides, virtualized over the shell's
12
+ * viewport — the same treatment the PDF pages get, for the same reason: reading
13
+ * a deck end to end is scrolling, not clicking "next" 60 times. The slide number
14
+ * follows the scroll, and the pager scrolls.
15
+ */
16
+
17
+ import type { ProseHeadingLevel, ResolvedFileSource } from "@elabs-ai/components-ui";
18
+ import { cn, ProseHeading, useLocale } from "@elabs-ai/components-ui";
19
+ import { useCallback, useEffect, useMemo, useRef } from "react";
20
+
21
+ import { MarkedText } from "../../components/marked-text";
22
+ import { ViewerError, toViewerError } from "../../core/errors";
23
+ import { toMarkRanges, type MarkRanges } from "../../core/highlight-marks";
24
+ import { spanAt, type TextIndex } from "../../core/text-index";
25
+ import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
26
+ import { usePageControl } from "../../core/use-page-control";
27
+ import { usePagedScroll } from "../../core/use-paged-scroll";
28
+ import { useViewportSize } from "../../core/use-viewport-size";
29
+ import type {
30
+ AdapterDocument,
31
+ AdapterLoadContext,
32
+ AdapterModule,
33
+ AdapterRendererProps,
34
+ FileAdapter,
35
+ } from "../../core/types";
36
+ import { pptxManifest } from "./pptx-manifest";
37
+ import {
38
+ notesTarget,
39
+ orderSlidePaths,
40
+ parseNotes,
41
+ parseSlide,
42
+ slidesToTextWithMap,
43
+ PPTX_NOTES_LINE,
44
+ PPTX_TITLE_LINE,
45
+ type PptxRef,
46
+ type PptxSlide,
47
+ } from "./pptx-model";
48
+
49
+ export interface PptxDocument extends AdapterDocument {
50
+ kind: "pptx";
51
+ slides: PptxSlide[];
52
+ pageCount: number;
53
+ /** Which slide and line each stretch of `text` came from. */
54
+ textIndex?: TextIndex<PptxRef>;
55
+ }
56
+
57
+ /** Indent per outline level. Four rungs is as deep as a readable slide ever goes. */
58
+ const LEVEL_INDENT = ["ps-0", "ps-4", "ps-8", "ps-12"] as const;
59
+
60
+ /** The slide column's own cap, matching the `max-w-3xl` on the frame. */
61
+ const SLIDE_MAX_WIDTH = 768;
62
+
63
+ /** Space between two slides in the column, in CSS pixels. */
64
+ const SLIDE_GAP = 16;
65
+
66
+ /** Room reserved for a slide's speaker notes before they are measured. */
67
+ const NOTES_ESTIMATE = 72;
68
+
69
+ class PptxAdapter implements FileAdapter {
70
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<PptxDocument> {
71
+ let buffer: ArrayBuffer;
72
+ try {
73
+ buffer = await source.bytes(context.signal);
74
+ } catch (error) {
75
+ throw toViewerError(error, "read-failed", { fileName: source.name });
76
+ }
77
+
78
+ // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
79
+ const JSZip = (await import("jszip")).default;
80
+
81
+ try {
82
+ const zip = await JSZip.loadAsync(buffer);
83
+ const parser = new DOMParser();
84
+ const parse = (xml: string) => parser.parseFromString(xml, "application/xml");
85
+
86
+ const slidePaths = orderSlidePaths(Object.keys(zip.files));
87
+ if (slidePaths.length === 0) {
88
+ throw new ViewerError("parse-failed", "The presentation contains no slides.", {
89
+ fileName: source.name,
90
+ });
91
+ }
92
+
93
+ const slides: PptxSlide[] = [];
94
+ for (const [position, path] of slidePaths.entries()) {
95
+ if (context.signal?.aborted) break;
96
+ const xml = await zip.file(path)?.async("string");
97
+ if (!xml) continue;
98
+ const slide = parseSlide(parse(xml), position + 1);
99
+
100
+ // Notes are best-effort: a deck with no notes part, or one whose
101
+ // relationships do not resolve, is a complete deck — not a failure.
102
+ const relsXml = await zip
103
+ .file(`${dirOf(path)}/_rels/${baseOf(path)}.rels`)
104
+ ?.async("string");
105
+ const notesPath = relsXml ? notesTarget(parse(relsXml), path) : undefined;
106
+ const notesXml = notesPath ? await zip.file(notesPath)?.async("string") : undefined;
107
+ if (notesXml) slide.notes = parseNotes(parse(notesXml));
108
+
109
+ slides.push(slide);
110
+ }
111
+
112
+ const textIndex = slidesToTextWithMap(slides);
113
+ return {
114
+ kind: "pptx",
115
+ slides,
116
+ pageCount: slides.length,
117
+ text: textIndex.text,
118
+ textIndex,
119
+ };
120
+ } catch (error) {
121
+ throw toViewerError(error, "parse-failed", { fileName: source.name });
122
+ }
123
+ }
124
+ }
125
+
126
+ function dirOf(path: string): string {
127
+ return path.slice(0, path.lastIndexOf("/"));
128
+ }
129
+
130
+ function baseOf(path: string): string {
131
+ return path.slice(path.lastIndexOf("/") + 1);
132
+ }
133
+
134
+ /* -------------------------------------------------------------------------- */
135
+ /* Renderer */
136
+ /* -------------------------------------------------------------------------- */
137
+
138
+ function PptxRenderer({
139
+ document: doc,
140
+ className,
141
+ baseHeadingLevel = 2,
142
+ highlights,
143
+ activeHighlightId,
144
+ pageNumber,
145
+ onPageChange,
146
+ }: AdapterRendererProps) {
147
+ const deck = doc as PptxDocument;
148
+ // One pager for every paginated format, so a deck's slides and a PDF's pages
149
+ // are driven by the same shell control (ADR 0026). 1-based on the wire; this
150
+ // renderer's array is 0-based, and the conversion stays local.
151
+ const [slideNumber, goToSlide] = usePageControl(pageNumber, onPageChange, deck.slides.length);
152
+ const listRef = useRef<HTMLDivElement>(null);
153
+ const viewport = useViewportSize(listRef);
154
+
155
+ const marks = useMemo(
156
+ () => toMarkRanges(highlights, deck.text?.length ?? 0),
157
+ [highlights, deck.text],
158
+ );
159
+
160
+ // A slide frame is 16:9 inside a capped column, so its height follows from the
161
+ // pane's width — no measurement of the slide itself is needed for the first
162
+ // frame. `measureElement` corrects a slide whose outline overflows the ratio,
163
+ // and whose notes are longer than the estimate.
164
+ const frameWidth = Math.min(SLIDE_MAX_WIDTH, (viewport?.width ?? SLIDE_MAX_WIDTH) - SLIDE_GAP);
165
+ const estimateSize = useCallback(
166
+ (index: number) =>
167
+ Math.round((frameWidth * 9) / 16) +
168
+ SLIDE_GAP +
169
+ (deck.slides[index]?.notes ? NOTES_ESTIMATE : 0),
170
+ [frameWidth, deck.slides],
171
+ );
172
+
173
+ const virtualizer = usePagedScroll({
174
+ count: deck.slides.length,
175
+ listRef,
176
+ pageNumber: slideNumber,
177
+ goToPage: goToSlide,
178
+ estimateSize,
179
+ sizeKey: frameWidth,
180
+ });
181
+
182
+ // Where each line of the deck begins in the projection, keyed by slide. Built
183
+ // once for the whole deck rather than per slide: several slides are on screen
184
+ // at a time now, and one pass over the spans is cheaper than one pass per
185
+ // visible slide on every scroll.
186
+ const starts = useMemo(() => {
187
+ const map = new Map<number, Map<number, number>>();
188
+ for (const span of deck.textIndex?.spans ?? []) {
189
+ const slide = map.get(span.ref.slide) ?? new Map<number, number>();
190
+ slide.set(span.ref.line, span.start);
191
+ map.set(span.ref.slide, slide);
192
+ }
193
+ return map;
194
+ }, [deck.textIndex]);
195
+
196
+ // Scrolling to the cited slide is this format's half of "take me there". Keyed
197
+ // on the slide NUMBER, so a reader who scrolls away while the same citation is
198
+ // still active is not dragged back (the same trade the PDF column makes).
199
+ const activeSlide = useMemo(() => {
200
+ const active = highlights?.find((highlight) => highlight.id === activeHighlightId);
201
+ if (!active || active.status !== "resolved" || !active.range || !deck.textIndex) {
202
+ return undefined;
203
+ }
204
+ return spanAt(deck.textIndex, active.range[0])?.ref.slide;
205
+ }, [highlights, activeHighlightId, deck.textIndex]);
206
+ useEffect(() => {
207
+ if (activeSlide === undefined) return;
208
+ const position = deck.slides.findIndex((candidate) => candidate.index === activeSlide);
209
+ if (position >= 0) goToSlide(position + 1);
210
+ }, [activeSlide, deck.slides, goToSlide]);
211
+
212
+ // The mark only exists once the cited slide is mounted, so the scroll waits
213
+ // for the slide as well as for the id.
214
+ useScrollActiveHighlightIntoView(listRef, activeHighlightId, slideNumber);
215
+
216
+ return (
217
+ // No viewport of its own: the slide chrome moved to the shell (ADR 0026), so
218
+ // there is no fixed control above a scrolling body and `FileViewerContent`
219
+ // is the one scroll boundary (`viewer-components.md`).
220
+ <div
221
+ ref={listRef}
222
+ data-slot="pptx-slides"
223
+ className={cn("relative w-full", className)}
224
+ style={{ height: virtualizer.getTotalSize() }}
225
+ >
226
+ {virtualizer.getVirtualItems().map((item) => {
227
+ const slide = deck.slides[item.index];
228
+ if (!slide) return null;
229
+ return (
230
+ <div
231
+ key={item.key}
232
+ data-index={item.index}
233
+ ref={virtualizer.measureElement}
234
+ className="absolute inset-x-0 top-0"
235
+ style={{
236
+ paddingBottom: SLIDE_GAP,
237
+ transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
238
+ }}
239
+ >
240
+ <PptxSlideView
241
+ slide={slide}
242
+ slideNumber={item.index + 1}
243
+ baseHeadingLevel={baseHeadingLevel}
244
+ marks={marks}
245
+ starts={starts.get(slide.index)}
246
+ />
247
+ </div>
248
+ );
249
+ })}
250
+ </div>
251
+ );
252
+ }
253
+
254
+ interface PptxSlideViewProps {
255
+ slide: PptxSlide;
256
+ /** 1-based position in the deck, which is what the reader is shown. */
257
+ slideNumber: number;
258
+ baseHeadingLevel: ProseHeadingLevel;
259
+ marks: MarkRanges;
260
+ /** Where each of this slide's lines begins in the projection. */
261
+ starts?: Map<number, number>;
262
+ }
263
+
264
+ /** One slide: the 16:9 frame, its outline, and any speaker notes beneath it. */
265
+ function PptxSlideView({
266
+ slide,
267
+ slideNumber,
268
+ baseHeadingLevel,
269
+ marks,
270
+ starts,
271
+ }: PptxSlideViewProps) {
272
+ const { t, formatNumber } = useLocale();
273
+ const isEmpty = !slide.title && slide.lines.length === 0;
274
+
275
+ return (
276
+ <>
277
+ {/* The slide surface: `bg-card` above the pane's ground, `aspect-video`
278
+ so a deck still reads as a deck — this is the shape of the thing,
279
+ which is the one part of the layout an outline can honestly keep. */}
280
+ {/* A slide that overflows its 16:9 frame scrolls, and a scrollable
281
+ region with no focusable content cannot be reached by keyboard
282
+ (WCAG 2.1.1). `tabIndex={0}` makes the slide itself the stop that
283
+ arrow keys and Page Up/Down drive. This is the one nested scroller the
284
+ rule allows: a fixed-ratio frame whose content may not fit it. */}
285
+ <section
286
+ tabIndex={0}
287
+ data-slot="pptx-slide"
288
+ data-page={slideNumber}
289
+ aria-label={t("viewer.pptx.slide", {
290
+ slide: formatNumber(slideNumber),
291
+ })}
292
+ className="border-border bg-card focus-visible:ring-ring mx-auto flex aspect-video w-full max-w-3xl flex-col gap-3 overflow-auto rounded-md border p-6 shadow-sm focus-visible:outline-none focus-visible:ring-2"
293
+ >
294
+ {/* A slide title is the deck's top rung, so it sits at the host's base.
295
+ The stand-in for an untitled slide is OUR text, not the deck's, so
296
+ it is not in the projection and cannot be cited. */}
297
+ <ProseHeading level={baseHeadingLevel}>
298
+ {slide.title === undefined ? (
299
+ t("viewer.pptx.untitled")
300
+ ) : (
301
+ <MarkedText text={slide.title} marks={marks} start={starts?.get(PPTX_TITLE_LINE)} />
302
+ )}
303
+ </ProseHeading>
304
+ {isEmpty ? (
305
+ <p className="text-muted-foreground text-body">{t("viewer.pptx.empty")}</p>
306
+ ) : (
307
+ <ul className="text-body space-y-1.5">
308
+ {slide.lines.map((line, lineIndex) => (
309
+ <li
310
+ key={lineIndex}
311
+ className={cn("whitespace-pre-wrap", LEVEL_INDENT[Math.min(line.level, 3)])}
312
+ >
313
+ <MarkedText text={line.text} marks={marks} start={starts?.get(lineIndex)} />
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ )}
318
+ </section>
319
+
320
+ {slide.notes && (
321
+ <section
322
+ aria-label={t("viewer.pptx.notes")}
323
+ className="mx-auto mt-3 w-full max-w-3xl space-y-1"
324
+ >
325
+ <h3 className="text-meta text-muted-foreground">{t("viewer.pptx.notes")}</h3>
326
+ <p className="text-body text-muted-foreground whitespace-pre-wrap">
327
+ <MarkedText text={slide.notes} marks={marks} start={starts?.get(PPTX_NOTES_LINE)} />
328
+ </p>
329
+ </section>
330
+ )}
331
+ </>
332
+ );
333
+ }
334
+
335
+ const adapterModule: AdapterModule = {
336
+ manifest: pptxManifest,
337
+ create: () => new PptxAdapter(),
338
+ Renderer: PptxRenderer,
339
+ };
340
+
341
+ export default adapterModule;
@@ -0,0 +1,19 @@
1
+ import { type AdapterManifest, PROTOCOL_VERSION } from "../../core/types";
2
+
3
+ /**
4
+ * PowerPoint decks. `.ppt` (the pre-2007 binary format) is not a zip at all, so
5
+ * it is deliberately absent rather than claimed and then failed on.
6
+ *
7
+ * `requires: ["jszip"]` — the deck is unzipped here and the XML is parsed with
8
+ * the platform's own `DOMParser`, so there is no PowerPoint library to install.
9
+ */
10
+ export const pptxManifest: AdapterManifest = {
11
+ id: "pptx",
12
+ protocol: PROTOCOL_VERSION,
13
+ extensions: ["pptx"],
14
+ mediaTypes: ["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
15
+ // No `rect`: the deck is read as an OUTLINE, not reproduced as a canvas, so
16
+ // there is no slide geometry a box could point into (`pptx-model.ts`).
17
+ capabilities: { pages: true, text: true, highlight: ["quote", "range"] },
18
+ requires: ["jszip"],
19
+ };