@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,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
|
+
});
|