@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,328 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Word adapter — a `.docx` becomes blocks, and blocks become brand-ui prose.
5
+ *
6
+ * mammoth does the hard part (unzipping OOXML, resolving styles, numbering,
7
+ * images); `docx-model.ts` then parses its HTML into a model and discards the
8
+ * markup, so nothing here writes `innerHTML`. That single decision is what makes
9
+ * a Word document themeable, Trusted-Types-safe and free of a sanitizer
10
+ * dependency — the reasoning lives in `docx-model.ts`.
11
+ *
12
+ * What a reader gets is the document's STRUCTURE in this system's typography:
13
+ * real headings, real lists, a real `Table`, images with their own alt text. It
14
+ * is not a pixel reproduction of Word's page layout — no page breaks, no columns,
15
+ * no margins — and it does not pretend to be. For a byte-faithful rendering the
16
+ * honest answer is to download the file, which the toolbar already offers.
17
+ */
18
+
19
+ import type { ProseHeadingLevel, ResolvedFileSource } from "@elabs-ai/components-ui";
20
+ import {
21
+ cn,
22
+ ProseHeading,
23
+ ProseLink,
24
+ ProseList,
25
+ ProseListItem,
26
+ ProseText,
27
+ StatePanel,
28
+ Table,
29
+ TableBody,
30
+ TableCell,
31
+ TableHead,
32
+ TableHeader,
33
+ TableRow,
34
+ useLocale,
35
+ } from "@elabs-ai/components-ui";
36
+
37
+ import { useMemo, useRef } from "react";
38
+
39
+ import { MarkedText } from "../../components/marked-text";
40
+ import { toViewerError } from "../../core/errors";
41
+ import { toMarkRanges, type MarkRanges } from "../../core/highlight-marks";
42
+ import type { TextIndex, TextSpan } from "../../core/text-index";
43
+ import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
44
+ import type {
45
+ AdapterDocument,
46
+ AdapterLoadContext,
47
+ AdapterModule,
48
+ AdapterRendererProps,
49
+ FileAdapter,
50
+ } from "../../core/types";
51
+ import {
52
+ blocksToTextWithMap,
53
+ DOCX_CELL_SEPARATOR,
54
+ DOCX_HEAD_ROW,
55
+ DOCX_LIST_BULLET,
56
+ htmlToBlocks,
57
+ type DocxBlock,
58
+ type DocxRef,
59
+ type DocxRun,
60
+ } from "./docx-model";
61
+ import { docxManifest } from "./docx-manifest";
62
+
63
+ export interface DocxDocument extends AdapterDocument {
64
+ kind: "docx";
65
+ blocks: DocxBlock[];
66
+ /** Non-fatal conversion notes mammoth reported (an unsupported style, a dropped field). */
67
+ warnings: string[];
68
+ /** Which block, item or row each stretch of `text` came from. */
69
+ textIndex?: TextIndex<DocxRef>;
70
+ }
71
+
72
+ /**
73
+ * Parse markup with the DOM the runtime already has.
74
+ *
75
+ * `DOMParser` is the browser's own parser and jsdom's in tests — it does NOT
76
+ * execute anything it parses, which is why building the model from it is safe
77
+ * even though the input is third-party markup.
78
+ */
79
+ function parseMarkup(markup: string): Document {
80
+ return new DOMParser().parseFromString(markup, "text/html");
81
+ }
82
+
83
+ class DocxAdapter implements FileAdapter {
84
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<DocxDocument> {
85
+ let buffer: ArrayBuffer;
86
+ try {
87
+ buffer = await source.bytes(context.signal);
88
+ } catch (error) {
89
+ throw toViewerError(error, "read-failed", { fileName: source.name });
90
+ }
91
+
92
+ // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
93
+ const mammoth = await import("mammoth");
94
+
95
+ try {
96
+ const result = await mammoth.convertToHtml({ arrayBuffer: buffer });
97
+ const blocks = htmlToBlocks(result.value, parseMarkup);
98
+ const textIndex = blocksToTextWithMap(blocks);
99
+ return {
100
+ kind: "docx",
101
+ blocks,
102
+ // mammoth's messages are about the CONVERSION, not about the file being
103
+ // broken — a document that lost one custom style still reads fine, so
104
+ // they are carried as notes rather than raised as a failure.
105
+ warnings: result.messages.map((message) => message.message),
106
+ text: textIndex.text,
107
+ textIndex,
108
+ };
109
+ } catch (error) {
110
+ throw toViewerError(error, "parse-failed", { fileName: source.name });
111
+ }
112
+ }
113
+ }
114
+
115
+ /* -------------------------------------------------------------------------- */
116
+ /* Renderer */
117
+ /* -------------------------------------------------------------------------- */
118
+
119
+ function Runs({ runs, marks, start }: { runs: DocxRun[]; marks: MarkRanges; start?: number }) {
120
+ let offset = start;
121
+ return (
122
+ <>
123
+ {runs.map((run, index) => {
124
+ const from = offset;
125
+ if (offset !== undefined) offset += run.text.length;
126
+ // Runs are joined with nothing in the projection, so a run's offset is
127
+ // simply the sum of the ones before it — the same accumulation the code
128
+ // adapter does over syntax tokens.
129
+ const content = <MarkedText text={run.text} marks={marks} start={from} />;
130
+ const bolded = run.bold ? <strong>{content}</strong> : content;
131
+ const styled = run.italic ? <em>{bolded}</em> : bolded;
132
+ if (run.href) {
133
+ return (
134
+ <ProseLink key={index} href={run.href}>
135
+ {styled}
136
+ </ProseLink>
137
+ );
138
+ }
139
+ return <span key={index}>{styled}</span>;
140
+ })}
141
+ </>
142
+ );
143
+ }
144
+
145
+ /** Shared empty list, so a block with no spans does not remount on every render. */
146
+ const EMPTY_SPANS: readonly TextSpan<DocxRef>[] = [];
147
+
148
+ /** The projection offset of one row's `index`-th cell, given the row's own start. */
149
+ function cellStart(row: readonly string[], index: number, rowStart?: number): number | undefined {
150
+ if (rowStart === undefined) return undefined;
151
+ let offset = rowStart;
152
+ for (let i = 0; i < index; i += 1) {
153
+ offset += (row[i]?.length ?? 0) + DOCX_CELL_SEPARATOR.length;
154
+ }
155
+ return offset;
156
+ }
157
+
158
+ function Block({
159
+ block,
160
+ baseHeadingLevel,
161
+ marks,
162
+ spans,
163
+ }: {
164
+ block: DocxBlock;
165
+ baseHeadingLevel: number;
166
+ marks: MarkRanges;
167
+ /** The projection spans belonging to THIS block, in document order. */
168
+ spans: readonly TextSpan<DocxRef>[];
169
+ }) {
170
+ const startOf = (match: (ref: DocxRef) => boolean) =>
171
+ spans.find((span) => match(span.ref))?.start;
172
+
173
+ if (block.type === "heading") {
174
+ return (
175
+ // Offset, not absolute: Word's "Heading 1" is the top of THAT document,
176
+ // not of the page showing it. Never past h6 — an h7 is not an element.
177
+ <ProseHeading
178
+ level={Math.min(6, Math.max(1, block.level + baseHeadingLevel - 1)) as ProseHeadingLevel}
179
+ >
180
+ <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />
181
+ </ProseHeading>
182
+ );
183
+ }
184
+ if (block.type === "paragraph") {
185
+ return (
186
+ <ProseText className="whitespace-pre-wrap">
187
+ <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />
188
+ </ProseText>
189
+ );
190
+ }
191
+ if (block.type === "list") {
192
+ return (
193
+ <ProseList ordered={block.ordered}>
194
+ {block.items.map((item, index) => {
195
+ // The bullet is in the projection but not in the DOM — the list
196
+ // element draws it — so an item's own text starts after it.
197
+ const start = startOf((ref) => ref.item === index);
198
+ return (
199
+ <ProseListItem key={index}>
200
+ <Runs
201
+ runs={item}
202
+ marks={marks}
203
+ start={start === undefined ? undefined : start + DOCX_LIST_BULLET.length}
204
+ />
205
+ </ProseListItem>
206
+ );
207
+ })}
208
+ </ProseList>
209
+ );
210
+ }
211
+ if (block.type === "image") {
212
+ return (
213
+ <img
214
+ src={block.src}
215
+ // The document's own alt text when the author wrote one. An image with
216
+ // none is decoration as far as the reader can tell, and a filename would
217
+ // be noise, not a description.
218
+ alt={block.alt ?? ""}
219
+ {...(block.alt ? {} : { "aria-hidden": true })}
220
+ className="my-2 block h-auto max-w-full rounded-md"
221
+ />
222
+ );
223
+ }
224
+ const headStart = startOf((ref) => ref.row === DOCX_HEAD_ROW);
225
+ return (
226
+ <Table className="my-2">
227
+ {block.head && (
228
+ <TableHeader>
229
+ <TableRow>
230
+ {block.head.map((cell, index) => (
231
+ <TableHead key={index} scope="col">
232
+ <MarkedText
233
+ text={cell}
234
+ marks={marks}
235
+ start={cellStart(block.head ?? [], index, headStart)}
236
+ />
237
+ </TableHead>
238
+ ))}
239
+ </TableRow>
240
+ </TableHeader>
241
+ )}
242
+ <TableBody>
243
+ {block.rows.map((row, rowIndex) => {
244
+ const rowStart = startOf((ref) => ref.row === rowIndex);
245
+ return (
246
+ <TableRow key={rowIndex}>
247
+ {row.map((cell, cellIndex) => (
248
+ <TableCell key={cellIndex} className="whitespace-pre-wrap align-top">
249
+ <MarkedText
250
+ text={cell}
251
+ marks={marks}
252
+ start={cellStart(row, cellIndex, rowStart)}
253
+ />
254
+ </TableCell>
255
+ ))}
256
+ </TableRow>
257
+ );
258
+ })}
259
+ </TableBody>
260
+ </Table>
261
+ );
262
+ }
263
+
264
+ function DocxRenderer({
265
+ document: doc,
266
+ className,
267
+ baseHeadingLevel = 2,
268
+ highlights,
269
+ activeHighlightId,
270
+ }: AdapterRendererProps) {
271
+ const docx = doc as DocxDocument;
272
+ const { t } = useLocale();
273
+ const container = useRef<HTMLElement>(null);
274
+
275
+ const marks = useMemo(
276
+ () => toMarkRanges(highlights, docx.text?.length ?? 0),
277
+ [highlights, docx.text],
278
+ );
279
+ // Grouped once per document rather than searched per block: a long report is
280
+ // thousands of blocks, and a linear scan inside the render loop would make it
281
+ // quadratic on every keystroke of a find-as-you-type.
282
+ const spansByBlock = useMemo(() => {
283
+ const map = new Map<number, TextSpan<DocxRef>[]>();
284
+ for (const span of docx.textIndex?.spans ?? []) {
285
+ const list = map.get(span.ref.block);
286
+ if (list) list.push(span);
287
+ else map.set(span.ref.block, [span]);
288
+ }
289
+ return map;
290
+ }, [docx.textIndex]);
291
+
292
+ useScrollActiveHighlightIntoView(container, activeHighlightId);
293
+
294
+ if (docx.blocks.length === 0) {
295
+ return (
296
+ <div className={cn("flex min-h-full flex-col justify-center p-4", className)}>
297
+ <StatePanel kind="empty" title={t("viewer.docx.empty")} />
298
+ </div>
299
+ );
300
+ }
301
+
302
+ return (
303
+ // `max-w-prose` because this is genuine multi-sentence prose in a pane that
304
+ // can be very wide — the one case styling-and-tokens.md says to cap the
305
+ // measure. Centred so the column does not hug one edge. No `overflow-auto`:
306
+ // `FileViewerContent` is the scroll boundary, and a second one clips the
307
+ // last paragraph above the outer pane's padding.
308
+ <article ref={container} className={cn("mx-auto max-w-prose space-y-2", className)}>
309
+ {docx.blocks.map((block, index) => (
310
+ <Block
311
+ key={index}
312
+ block={block}
313
+ baseHeadingLevel={baseHeadingLevel}
314
+ marks={marks}
315
+ spans={spansByBlock.get(index) ?? EMPTY_SPANS}
316
+ />
317
+ ))}
318
+ </article>
319
+ );
320
+ }
321
+
322
+ const adapterModule: AdapterModule = {
323
+ manifest: docxManifest,
324
+ create: () => new DocxAdapter(),
325
+ Renderer: DocxRenderer,
326
+ };
327
+
328
+ export default adapterModule;
@@ -0,0 +1,18 @@
1
+ import { type AdapterManifest, PROTOCOL_VERSION } from "../../core/types";
2
+
3
+ /**
4
+ * Word documents. `.doc` (the pre-2007 binary format) is deliberately absent:
5
+ * mammoth reads OOXML only, and claiming an extension we cannot open would trade
6
+ * an honest "can't preview this file type" panel for a parse error.
7
+ */
8
+ export const docxManifest: AdapterManifest = {
9
+ id: "docx",
10
+ protocol: PROTOCOL_VERSION,
11
+ extensions: ["docx"],
12
+ mediaTypes: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
13
+ // No `rect`: a Word document has no page geometry here at all — mammoth
14
+ // discards Word's layout, and the renderer reflows the blocks into this
15
+ // system's prose, so a box on "page 3" would point at nothing.
16
+ capabilities: { text: true, highlight: ["quote", "range"] },
17
+ requires: ["mammoth"],
18
+ };
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ blocksToText,
5
+ blocksToTextWithMap,
6
+ DOCX_HEAD_ROW,
7
+ htmlToBlocks,
8
+ safeHref,
9
+ type DocxBlock,
10
+ } from "./docx-model";
11
+
12
+ const parse = (markup: string) => new DOMParser().parseFromString(markup, "text/html");
13
+ const blocks = (html: string) => htmlToBlocks(html, parse);
14
+
15
+ describe("safeHref", () => {
16
+ it("keeps the schemes a browser should follow", () => {
17
+ expect(safeHref("https://example.com/a")).toBe("https://example.com/a");
18
+ expect(safeHref("mailto:ada@example.com")).toBe("mailto:ada@example.com");
19
+ expect(safeHref("tel:+441234567890")).toBe("tel:+441234567890");
20
+ });
21
+
22
+ it("drops a javascript: URL — the reason links are filtered at all", () => {
23
+ expect(safeHref("javascript:alert(1)")).toBeUndefined();
24
+ expect(safeHref("JavaScript:alert(1)")).toBeUndefined();
25
+ });
26
+
27
+ it("drops an internal bookmark, which points at nothing on the host page", () => {
28
+ expect(safeHref("#_Toc12345")).toBeUndefined();
29
+ });
30
+
31
+ it("drops an empty or missing href", () => {
32
+ expect(safeHref(null)).toBeUndefined();
33
+ expect(safeHref("")).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe("htmlToBlocks", () => {
38
+ it("reads headings at their own level", () => {
39
+ expect(blocks("<h1>Title</h1><h3>Sub</h3>")).toEqual<DocxBlock[]>([
40
+ { type: "heading", level: 1, runs: [{ text: "Title" }] },
41
+ { type: "heading", level: 3, runs: [{ text: "Sub" }] },
42
+ ]);
43
+ });
44
+
45
+ it("keeps bold and italic as run styling, not as markup", () => {
46
+ const [paragraph] = blocks(
47
+ "<p>Revenue grew <strong>18%</strong> in a <em>flat</em> market.</p>",
48
+ );
49
+ expect(paragraph).toEqual<DocxBlock>({
50
+ type: "paragraph",
51
+ runs: [
52
+ { text: "Revenue grew " },
53
+ { text: "18%", bold: true },
54
+ { text: " in a " },
55
+ { text: "flat", italic: true },
56
+ { text: " market." },
57
+ ],
58
+ });
59
+ });
60
+
61
+ it("merges adjacent runs that look the same", () => {
62
+ const [paragraph] = blocks("<p><span>one </span><span>two</span></p>");
63
+ expect(paragraph).toEqual<DocxBlock>({ type: "paragraph", runs: [{ text: "one two" }] });
64
+ });
65
+
66
+ it("carries a safe link on the run and leaves an unsafe one as plain text", () => {
67
+ const [safe] = blocks('<p><a href="https://example.com">Report</a></p>');
68
+ expect(safe).toEqual<DocxBlock>({
69
+ type: "paragraph",
70
+ runs: [{ text: "Report", href: "https://example.com/" }],
71
+ });
72
+
73
+ const [unsafe] = blocks('<p><a href="javascript:alert(1)">Click</a></p>');
74
+ expect(unsafe).toEqual<DocxBlock>({ type: "paragraph", runs: [{ text: "Click" }] });
75
+ });
76
+
77
+ it("reads both list kinds and flattens a nested list into its own items", () => {
78
+ expect(blocks("<ol><li>One<ul><li>Deeper</li></ul></li><li>Two</li></ol>")).toEqual<
79
+ DocxBlock[]
80
+ >([
81
+ {
82
+ type: "list",
83
+ ordered: true,
84
+ items: [[{ text: "OneDeeper" }], [{ text: "Deeper" }], [{ text: "Two" }]],
85
+ },
86
+ ]);
87
+ });
88
+
89
+ it("promotes the first row to a header only when the author marked one", () => {
90
+ expect(blocks("<table><tr><th>Region</th></tr><tr><td>EMEA</td></tr></table>")).toEqual<
91
+ DocxBlock[]
92
+ >([{ type: "table", head: ["Region"], rows: [["EMEA"]] }]);
93
+
94
+ // Word emits `<th>` only for a real header row; inventing one would claim
95
+ // structure the document does not have.
96
+ expect(blocks("<table><tr><td>Region</td></tr><tr><td>EMEA</td></tr></table>")).toEqual<
97
+ DocxBlock[]
98
+ >([{ type: "table", rows: [["Region"], ["EMEA"]] }]);
99
+ });
100
+
101
+ it("reads an image, and a paragraph that holds only an image IS the image", () => {
102
+ expect(blocks('<p><img src="data:image/png;base64,AAA" alt="A chart" /></p>')).toEqual<
103
+ DocxBlock[]
104
+ >([{ type: "image", src: "data:image/png;base64,AAA", alt: "A chart" }]);
105
+ });
106
+
107
+ it("contributes only the TEXT of a tag it does not name — the parse is the allowlist", () => {
108
+ // No `script` block, no markup: the element is not in the walk, so nothing
109
+ // it holds can reach the DOM as anything but characters.
110
+ expect(blocks("<div><p>Kept</p></div>")).toEqual<DocxBlock[]>([
111
+ { type: "paragraph", runs: [{ text: "Kept" }] },
112
+ ]);
113
+ expect(blocks("<script>alert(1)</script>")).toEqual<DocxBlock[]>([]);
114
+ });
115
+
116
+ it("drops an empty paragraph rather than rendering a blank line", () => {
117
+ expect(blocks("<p></p><p> </p>")).toEqual<DocxBlock[]>([]);
118
+ });
119
+ });
120
+
121
+ describe("blocksToText", () => {
122
+ it("projects every block kind into searchable, copyable text", () => {
123
+ const text = blocksToText(
124
+ blocks(
125
+ "<h1>Title</h1><p>Body</p><ul><li>One</li></ul>" +
126
+ "<table><tr><th>Region</th></tr><tr><td>EMEA</td></tr></table>",
127
+ ),
128
+ );
129
+ expect(text).toBe("Title\nBody\n• One\nRegion\nEMEA");
130
+ });
131
+ });
132
+
133
+ describe("blocksToTextWithMap", () => {
134
+ const HTML =
135
+ "<h1>Title</h1><p>Body</p><ul><li>One</li><li>Two</li></ul>" +
136
+ "<table><tr><th>Region</th><th>Revenue</th></tr><tr><td>EMEA</td><td>4.2M</td></tr></table>";
137
+
138
+ it("produces the same text `blocksToText` always did", () => {
139
+ // The wrapper is the projection, so a consumer's stored offsets keep
140
+ // meaning what they meant before the index existed.
141
+ const parsed = blocks(HTML);
142
+ expect(blocksToTextWithMap(parsed).text).toBe(blocksToText(parsed));
143
+ });
144
+
145
+ it("names the block every stretch of the projection came from", () => {
146
+ const { text, spans } = blocksToTextWithMap(blocks(HTML));
147
+ expect(spans.map((span) => [text.slice(span.start, span.end), span.ref])).toEqual([
148
+ ["Title", { block: 0 }],
149
+ ["Body", { block: 1 }],
150
+ ["• One", { block: 2, item: 0 }],
151
+ ["• Two", { block: 2, item: 1 }],
152
+ ["Region\tRevenue", { block: 3, row: DOCX_HEAD_ROW }],
153
+ ["EMEA\t4.2M", { block: 3, row: 0 }],
154
+ ]);
155
+ });
156
+
157
+ it("leaves the separators owned by nobody, so a mark cannot span a joint", () => {
158
+ const { text, spans } = blocksToTextWithMap(blocks(HTML));
159
+ const covered = new Set<number>();
160
+ for (const span of spans) {
161
+ for (let i = span.start; i < span.end; i += 1) covered.add(i);
162
+ }
163
+ const uncovered = [...text].map((char, i) => (covered.has(i) ? "" : char)).join("");
164
+ expect(uncovered).toBe("\n".repeat(spans.length - 1));
165
+ });
166
+
167
+ it("skips a table with no rows rather than emitting an empty span", () => {
168
+ const { spans } = blocksToTextWithMap([{ type: "table", rows: [] }]);
169
+ expect(spans).toEqual([]);
170
+ });
171
+
172
+ it("contributes nothing for an image, which has no text to address", () => {
173
+ const { text, spans } = blocksToTextWithMap([
174
+ { type: "paragraph", runs: [{ text: "Before" }] },
175
+ { type: "image", src: "data:image/png;base64,AA==", alt: "Chart" },
176
+ { type: "paragraph", runs: [{ text: "After" }] },
177
+ ]);
178
+ expect(text).toBe("Before\nAfter");
179
+ expect(spans.map((span) => span.ref)).toEqual([{ block: 0 }, { block: 2 }]);
180
+ });
181
+ });