@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,169 @@
|
|
|
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 csvModule, { CSV_ROW_LIMIT, type CsvDocument } from "./csv-adapter";
|
|
7
|
+
|
|
8
|
+
const parse = (text: string, name = "rows.csv") =>
|
|
9
|
+
csvModule
|
|
10
|
+
.create()
|
|
11
|
+
.load(normalizeFileSource({ kind: "text", text, name }), {}) as Promise<CsvDocument>;
|
|
12
|
+
|
|
13
|
+
describe("csv adapter — parsing", () => {
|
|
14
|
+
it("keeps a quoted comma in one cell (the bug the split-on-comma parser had)", async () => {
|
|
15
|
+
const doc = await parse('name,address\nAda,"12 High St, London"\n');
|
|
16
|
+
expect(doc.columns).toEqual(["name", "address"]);
|
|
17
|
+
expect(doc.rows).toEqual([["Ada", "12 High St, London"]]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("handles escaped quotes and embedded newlines", async () => {
|
|
21
|
+
const doc = await parse('quote,note\n"She said ""hi""","line one\nline two"\n');
|
|
22
|
+
expect(doc.rows[0]).toEqual(['She said "hi"', "line one\nline two"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("detects a semicolon delimiter rather than trusting the extension", async () => {
|
|
26
|
+
const doc = await parse("a;b\n1;2\n");
|
|
27
|
+
expect(doc.columns).toEqual(["a", "b"]);
|
|
28
|
+
expect(doc.rows).toEqual([["1", "2"]]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads a tab-separated file", async () => {
|
|
32
|
+
const doc = await parse("a\tb\n1\t2\n", "rows.tsv");
|
|
33
|
+
expect(doc.columns).toEqual(["a", "b"]);
|
|
34
|
+
expect(doc.rows).toEqual([["1", "2"]]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reports truncation instead of silently dropping rows", async () => {
|
|
38
|
+
const rows = Array.from({ length: CSV_ROW_LIMIT + 10 }, (_, i) => `${String(i)},x`).join("\n");
|
|
39
|
+
const doc = await parse(`a,b\n${rows}\n`);
|
|
40
|
+
expect(doc.rows).toHaveLength(CSV_ROW_LIMIT);
|
|
41
|
+
expect(doc.totalRows).toBe(CSV_ROW_LIMIT + 10);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not claim truncation when the file fits", async () => {
|
|
45
|
+
const doc = await parse("a,b\n1,2\n");
|
|
46
|
+
expect(doc.totalRows).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("projects the PARSED grid as its text, not the raw file", async () => {
|
|
50
|
+
// Deliberate change: a citation has to address what the reader sees. The
|
|
51
|
+
// raw bytes carry quoting, escapes and whatever delimiter the exporter
|
|
52
|
+
// chose, so an offset into them lands nowhere in the rendered table.
|
|
53
|
+
const doc = await parse('name,address\nAda,"12 High St, London"\n');
|
|
54
|
+
expect(doc.text).toBe("name\taddress\nAda\t12 High St, London");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("maps each row back to its place in the projection", async () => {
|
|
58
|
+
const doc = await parse("a,b\n1,2\n3,4\n");
|
|
59
|
+
const spans = doc.textIndex?.spans ?? [];
|
|
60
|
+
expect(spans.map((span) => [doc.text?.slice(span.start, span.end), span.ref])).toEqual([
|
|
61
|
+
["a\tb", { sheet: 0, row: -1 }],
|
|
62
|
+
["1\t2", { sheet: 0, row: 0 }],
|
|
63
|
+
["3\t4", { sheet: 0, row: 1 }],
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("csv adapter — rendering", () => {
|
|
69
|
+
it("renders a real table with the file's own headers", async () => {
|
|
70
|
+
const doc = await parse('name,address\nAda,"12 High St, London"\n');
|
|
71
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "rows.csv" });
|
|
72
|
+
render(<csvModule.Renderer document={doc} source={source} />);
|
|
73
|
+
|
|
74
|
+
expect(screen.getByRole("columnheader", { name: "name" })).toBeInTheDocument();
|
|
75
|
+
// One cell, not two — proof the quoted comma survived to the DOM.
|
|
76
|
+
expect(screen.getByRole("cell", { name: "12 High St, London" })).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("gives the grid an accessible summary", async () => {
|
|
80
|
+
const doc = await parse("a,b\n1,2\n");
|
|
81
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "rows.csv" });
|
|
82
|
+
render(<csvModule.Renderer document={doc} source={source} />);
|
|
83
|
+
expect(screen.getByRole("table", { name: /1 rows, 2 columns/ })).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("announces truncation as a status, not an error", async () => {
|
|
87
|
+
const rows = Array.from({ length: CSV_ROW_LIMIT + 1 }, () => "x,y").join("\n");
|
|
88
|
+
const doc = await parse(`a,b\n${rows}\n`);
|
|
89
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "rows.csv" });
|
|
90
|
+
render(<csvModule.Renderer document={doc} source={source} />);
|
|
91
|
+
|
|
92
|
+
expect(screen.getByRole("status")).toHaveTextContent(/Showing the first/);
|
|
93
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
94
|
+
// Parsing and rendering CSV_ROW_LIMIT+1 rows is genuinely slow on a
|
|
95
|
+
// 2-core CI runner — 7232ms observed against the 5000ms default. The cost
|
|
96
|
+
// is the fixture, not a hang, so raise the margin rather than shrink the
|
|
97
|
+
// fixture (the limit is what this test exists to cross).
|
|
98
|
+
}, 15_000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("csv renderer — highlighting", () => {
|
|
102
|
+
const FILE = "region,revenue\nEMEA,4.2M\nAPAC,3.1M\n";
|
|
103
|
+
|
|
104
|
+
const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
|
|
105
|
+
id,
|
|
106
|
+
source: "citation",
|
|
107
|
+
status: "resolved",
|
|
108
|
+
address: { kind: "range", start: range[0], end: range[1] },
|
|
109
|
+
active,
|
|
110
|
+
range,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Offsets of a passage in the document's own projection. */
|
|
114
|
+
const rangeOf = (doc: CsvDocument, passage: string): [number, number] => {
|
|
115
|
+
const start = doc.text?.indexOf(passage) ?? -1;
|
|
116
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
117
|
+
return [start, start + passage.length];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const renderWith = async (
|
|
121
|
+
pick: (doc: CsvDocument) => ResolvedHighlight[],
|
|
122
|
+
activeHighlightId?: string,
|
|
123
|
+
) => {
|
|
124
|
+
const doc = await parse(FILE);
|
|
125
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "rows.csv" });
|
|
126
|
+
return render(
|
|
127
|
+
<csvModule.Renderer
|
|
128
|
+
document={doc}
|
|
129
|
+
source={source}
|
|
130
|
+
highlights={pick(doc)}
|
|
131
|
+
activeHighlightId={activeHighlightId}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
it("declares the address kinds it can actually honour", () => {
|
|
137
|
+
expect(csvModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("marks one cell, not the whole row it shares a span with", async () => {
|
|
141
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "4.2M"))]);
|
|
142
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
143
|
+
expect(marks).toHaveLength(1);
|
|
144
|
+
expect(marks[0]?.closest("td")?.textContent).toBe("4.2M");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("marks a header cell too — the header is part of the projection", async () => {
|
|
148
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "revenue"))]);
|
|
149
|
+
expect(container.querySelector("mark")?.closest("th")?.textContent).toBe("revenue");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("marks in both cells when a citation spans the separator", async () => {
|
|
153
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "EMEA\t4.2M"))]);
|
|
154
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
155
|
+
expect(marks.map((mark) => mark.textContent)).toEqual(["EMEA", "4.2M"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("flags the current passage for assistive tech, not by colour alone", async () => {
|
|
159
|
+
const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "3.1M"), true)], "a");
|
|
160
|
+
const active = container.querySelector('mark[data-active][data-slot="match-highlight-mark"]');
|
|
161
|
+
expect(active?.getAttribute("aria-current")).toBe("true");
|
|
162
|
+
expect(active?.textContent).toBe("3.1M");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("draws nothing when no highlight is passed", async () => {
|
|
166
|
+
const { container } = await renderWith(() => []);
|
|
167
|
+
expect(container.querySelectorAll("mark")).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CSV / TSV adapter — a real parser and a real table.
|
|
5
|
+
*
|
|
6
|
+
* Two things this fixes at once:
|
|
7
|
+
*
|
|
8
|
+
* - **Correctness.** The repo's previous CSV "parser" was `line.split(",")`
|
|
9
|
+
* (`packages/ai/src/asset-preview.tsx:69`), which mangles every quoted comma —
|
|
10
|
+
* i.e. every address, every price list, every free-text column. Papa Parse
|
|
11
|
+
* handles quoting, escapes, embedded newlines and delimiter detection.
|
|
12
|
+
* - **Theming.** anyview's CSV adapter emits an HTML string with `#ddd` and
|
|
13
|
+
* `#f5f5f5` baked in (`CsvAdapter.ts:184`). This one emits ROWS and hands them
|
|
14
|
+
* to `@elabs-ai/components-ui`'s `Table`, so the grid is tokened and
|
|
15
|
+
* re-themes with everything else.
|
|
16
|
+
*
|
|
17
|
+
* `papaparse` is an OPTIONAL peer, reached only through the dynamic import
|
|
18
|
+
* below. A consumer who never opens a spreadsheet never installs it, and one who
|
|
19
|
+
* forgot gets a "install papaparse" message rather than a resolution stack trace
|
|
20
|
+
* (`isModuleNotFound` in `core/errors.ts`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
24
|
+
import { useMemo } from "react";
|
|
25
|
+
|
|
26
|
+
import { gridToText, type GridRef } from "../../components/grid-text";
|
|
27
|
+
import { SheetTable } from "../../components/sheet-table";
|
|
28
|
+
|
|
29
|
+
import { ViewerError, toViewerError } from "../../core/errors";
|
|
30
|
+
import { toMarkRanges } from "../../core/highlight-marks";
|
|
31
|
+
import type { TextIndex } from "../../core/text-index";
|
|
32
|
+
import type {
|
|
33
|
+
AdapterDocument,
|
|
34
|
+
AdapterLoadContext,
|
|
35
|
+
AdapterModule,
|
|
36
|
+
AdapterRendererProps,
|
|
37
|
+
FileAdapter,
|
|
38
|
+
} from "../../core/types";
|
|
39
|
+
import { csvManifest } from "./csv-manifest";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rows rendered before truncation. A preview pane is for looking, not for
|
|
43
|
+
* analysis — past this, reach for `@elabs-ai/components-data`'s
|
|
44
|
+
* `DataTable`, which virtualizes.
|
|
45
|
+
*/
|
|
46
|
+
export const CSV_ROW_LIMIT = 5_000;
|
|
47
|
+
|
|
48
|
+
export interface CsvDocument extends AdapterDocument {
|
|
49
|
+
kind: "csv";
|
|
50
|
+
/** Header cells. Empty when the file had no usable first row. */
|
|
51
|
+
columns: string[];
|
|
52
|
+
/** Body rows, already capped at {@link CSV_ROW_LIMIT}. */
|
|
53
|
+
rows: string[][];
|
|
54
|
+
/** Total body rows in the file, when more than what is shown. */
|
|
55
|
+
totalRows?: number;
|
|
56
|
+
/** Which row each stretch of `text` came from. */
|
|
57
|
+
textIndex?: TextIndex<GridRef>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class CsvAdapter implements FileAdapter {
|
|
61
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<CsvDocument> {
|
|
62
|
+
let text: string;
|
|
63
|
+
try {
|
|
64
|
+
text = await source.text(context.signal);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dynamic: this is the ONLY edge to the optional peer. A static import here
|
|
70
|
+
// would break every consumer that did not install it (heavy-deps:check).
|
|
71
|
+
const Papa = await import("papaparse");
|
|
72
|
+
const parsed = Papa.parse<string[]>(text, {
|
|
73
|
+
skipEmptyLines: "greedy",
|
|
74
|
+
// Let Papa detect `,` vs `\t` vs `;` rather than trusting the extension —
|
|
75
|
+
// a .csv exported by a European locale is very often semicolon-delimited.
|
|
76
|
+
delimiter: "",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Papa reports recoverable problems as errors while still returning data;
|
|
80
|
+
// only an empty result with errors is genuinely unreadable.
|
|
81
|
+
if (parsed.data.length === 0 && parsed.errors.length > 0) {
|
|
82
|
+
throw new ViewerError(
|
|
83
|
+
"parse-failed",
|
|
84
|
+
parsed.errors[0]?.message ?? "Could not parse the file.",
|
|
85
|
+
{
|
|
86
|
+
fileName: source.name,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const [header = [], ...body] = parsed.data;
|
|
92
|
+
const rows = body.length <= CSV_ROW_LIMIT ? body : body.slice(0, CSV_ROW_LIMIT);
|
|
93
|
+
// `text` is the PARSED grid, not the raw file (changed in this release).
|
|
94
|
+
// A citation's offsets have to address what the reader can see: the raw
|
|
95
|
+
// bytes carry quoting, escapes and a delimiter that may not be a comma, so
|
|
96
|
+
// an offset into them lands nowhere in particular in the rendered table.
|
|
97
|
+
const textIndex = gridToText([{ columns: header, rows }]);
|
|
98
|
+
return {
|
|
99
|
+
kind: "csv",
|
|
100
|
+
columns: header,
|
|
101
|
+
rows,
|
|
102
|
+
...(body.length > CSV_ROW_LIMIT ? { totalRows: body.length } : {}),
|
|
103
|
+
text: textIndex.text,
|
|
104
|
+
textIndex,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function CsvRenderer({
|
|
110
|
+
document: doc,
|
|
111
|
+
className,
|
|
112
|
+
highlights,
|
|
113
|
+
activeHighlightId,
|
|
114
|
+
}: AdapterRendererProps) {
|
|
115
|
+
const csv = doc as CsvDocument;
|
|
116
|
+
const marks = useMemo(
|
|
117
|
+
() => toMarkRanges(highlights, csv.text?.length ?? 0),
|
|
118
|
+
[highlights, csv.text],
|
|
119
|
+
);
|
|
120
|
+
// One sheet, so a row's start is simply the span whose ref names that row.
|
|
121
|
+
const rowStart = useMemo(() => {
|
|
122
|
+
const starts = new Map<number, number>();
|
|
123
|
+
for (const span of csv.textIndex?.spans ?? []) starts.set(span.ref.row, span.start);
|
|
124
|
+
return (row: number) => starts.get(row);
|
|
125
|
+
}, [csv.textIndex]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<SheetTable
|
|
129
|
+
columns={csv.columns}
|
|
130
|
+
rows={csv.rows}
|
|
131
|
+
totalRows={csv.totalRows}
|
|
132
|
+
className={className}
|
|
133
|
+
marks={marks}
|
|
134
|
+
rowStart={rowStart}
|
|
135
|
+
activeHighlightId={activeHighlightId}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const adapterModule: AdapterModule = {
|
|
141
|
+
manifest: csvManifest,
|
|
142
|
+
create: () => new CsvAdapter(),
|
|
143
|
+
Renderer: CsvRenderer,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export default adapterModule;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AdapterManifest } from "../../core/types";
|
|
2
|
+
import { PROTOCOL_VERSION } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Eager, data-only (see `image-manifest.ts`).
|
|
6
|
+
*
|
|
7
|
+
* `requires` names the OPTIONAL peer this adapter dynamically imports. It is
|
|
8
|
+
* what the "install this to open that" message names when the import cannot
|
|
9
|
+
* resolve — the registry never installs anything itself.
|
|
10
|
+
*/
|
|
11
|
+
export const csvManifest: AdapterManifest = {
|
|
12
|
+
id: "csv",
|
|
13
|
+
protocol: PROTOCOL_VERSION,
|
|
14
|
+
extensions: ["csv", "tsv"],
|
|
15
|
+
mediaTypes: ["text/csv", "text/tab-separated-values"],
|
|
16
|
+
// Addressed against the PARSED grid (`gridToText`), not the raw bytes — see
|
|
17
|
+
// the adapter. No `rect`: a table cell has no page geometry to point at.
|
|
18
|
+
capabilities: { text: true, search: true, highlight: ["quote", "range"] },
|
|
19
|
+
requires: ["papaparse"],
|
|
20
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
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 { SAMPLE_DOCX_BASE64 } from "../office-fixture";
|
|
7
|
+
import docxModule, { type DocxDocument } from "./docx-adapter";
|
|
8
|
+
|
|
9
|
+
const MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
10
|
+
|
|
11
|
+
function fixtureSource(name = "review.docx") {
|
|
12
|
+
const bytes = Uint8Array.from(atob(SAMPLE_DOCX_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 = () => docxModule.create().load(fixtureSource(), {}) as Promise<DocxDocument>;
|
|
22
|
+
|
|
23
|
+
describe("docx adapter — a real Word file through mammoth", () => {
|
|
24
|
+
it("reads the document's structure, not a wall of text", async () => {
|
|
25
|
+
const doc = await load();
|
|
26
|
+
expect(doc.blocks).toContainEqual({
|
|
27
|
+
type: "heading",
|
|
28
|
+
level: 1,
|
|
29
|
+
runs: [{ text: "Quarterly review" }],
|
|
30
|
+
});
|
|
31
|
+
expect(doc.blocks).toContainEqual({
|
|
32
|
+
type: "list",
|
|
33
|
+
ordered: false,
|
|
34
|
+
items: [[{ text: "EMEA beat plan" }], [{ text: "APAC held flat" }]],
|
|
35
|
+
});
|
|
36
|
+
expect(doc.blocks.some((block) => block.type === "table")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps Word's emphasis as run styling", async () => {
|
|
40
|
+
const doc = await load();
|
|
41
|
+
const paragraph = doc.blocks.find((block) => block.type === "paragraph");
|
|
42
|
+
expect(paragraph).toEqual({
|
|
43
|
+
type: "paragraph",
|
|
44
|
+
runs: [
|
|
45
|
+
{ text: "Revenue grew " },
|
|
46
|
+
{ text: "18%", bold: true },
|
|
47
|
+
{ text: " against a " },
|
|
48
|
+
{ text: "flat", italic: true },
|
|
49
|
+
{ text: " market." },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("exposes a plain-text projection for search and copy", async () => {
|
|
55
|
+
const doc = await load();
|
|
56
|
+
expect(doc.text).toContain("Quarterly review");
|
|
57
|
+
expect(doc.text).toContain("• EMEA beat plan");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("reports a file that is not a Word document as parse-failed, not as a crash", async () => {
|
|
61
|
+
const source = normalizeFileSource({
|
|
62
|
+
kind: "text",
|
|
63
|
+
text: "not a zip",
|
|
64
|
+
name: "broken.docx",
|
|
65
|
+
mediaType: MEDIA_TYPE,
|
|
66
|
+
});
|
|
67
|
+
await expect(docxModule.create().load(source, {})).rejects.toMatchObject({
|
|
68
|
+
code: "parse-failed",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("docx renderer", () => {
|
|
74
|
+
it("draws the document with real headings, lists and links", async () => {
|
|
75
|
+
const doc = await load();
|
|
76
|
+
render(<docxModule.Renderer document={doc} source={fixtureSource()} />);
|
|
77
|
+
|
|
78
|
+
// Offset by the default base: Word's "Heading 1" is the top of THAT
|
|
79
|
+
// document, so it renders at h2 and leaves the host page's h1 alone.
|
|
80
|
+
expect(screen.getByRole("heading", { level: 2, name: "Quarterly review" })).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByRole("heading", { level: 3, name: "Regions" })).toBeInTheDocument();
|
|
82
|
+
expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual([
|
|
83
|
+
"EMEA beat plan",
|
|
84
|
+
"APAC held flat",
|
|
85
|
+
]);
|
|
86
|
+
// A real anchor, so it is reachable by keyboard and by a links list.
|
|
87
|
+
expect(screen.getByRole("link", { name: "Full report" })).toHaveAttribute(
|
|
88
|
+
"href",
|
|
89
|
+
"https://example.com/report",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("puts the document's own top heading where the host asks", async () => {
|
|
94
|
+
const doc = await load();
|
|
95
|
+
render(<docxModule.Renderer document={doc} source={fixtureSource()} baseHeadingLevel={1} />);
|
|
96
|
+
expect(screen.getByRole("heading", { level: 1, name: "Quarterly review" })).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders a Word table as a real table", async () => {
|
|
100
|
+
const doc = await load();
|
|
101
|
+
render(<docxModule.Renderer document={doc} source={fixtureSource()} />);
|
|
102
|
+
expect(screen.getByRole("table")).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByRole("cell", { name: "4.2M" })).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("says so when a document has no text, instead of rendering an empty pane", () => {
|
|
107
|
+
const empty: DocxDocument = { kind: "docx", blocks: [], warnings: [], text: "" };
|
|
108
|
+
render(<docxModule.Renderer document={empty} source={fixtureSource()} />);
|
|
109
|
+
expect(screen.getByText("This document has no text")).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("docx renderer — highlighting", () => {
|
|
114
|
+
const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
|
|
115
|
+
id,
|
|
116
|
+
source: "citation",
|
|
117
|
+
status: "resolved",
|
|
118
|
+
address: { kind: "range", start: range[0], end: range[1] },
|
|
119
|
+
active,
|
|
120
|
+
range,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Offsets of a passage in the document's own projection. */
|
|
124
|
+
const rangeOf = (doc: DocxDocument, passage: string): [number, number] => {
|
|
125
|
+
const start = doc.text?.indexOf(passage) ?? -1;
|
|
126
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
127
|
+
return [start, start + passage.length];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const renderWith = async (highlights: ResolvedHighlight[], activeHighlightId?: string) => {
|
|
131
|
+
const doc = await load();
|
|
132
|
+
const result = render(
|
|
133
|
+
<docxModule.Renderer
|
|
134
|
+
document={doc}
|
|
135
|
+
source={fixtureSource()}
|
|
136
|
+
highlights={highlights}
|
|
137
|
+
activeHighlightId={activeHighlightId}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
return { doc, ...result };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
it("declares the address kinds it can actually honour", () => {
|
|
144
|
+
expect(docxModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("marks a passage inside a paragraph, in the block it came from", async () => {
|
|
148
|
+
const doc = await load();
|
|
149
|
+
const { container } = await renderWith([cite("a", rangeOf(doc, "against a"))]);
|
|
150
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
151
|
+
expect(marks.map((mark) => mark.textContent).join("")).toBe("against a");
|
|
152
|
+
// The paragraph still reads whole — marking wraps, it does not replace.
|
|
153
|
+
expect(container.textContent).toContain("Revenue grew 18% against a flat market.");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("marks across run styling without flattening it", async () => {
|
|
157
|
+
const doc = await load();
|
|
158
|
+
const { container } = await renderWith([cite("a", rangeOf(doc, "grew 18% against"))]);
|
|
159
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
160
|
+
expect(marks.map((mark) => mark.textContent).join("")).toBe("grew 18% against");
|
|
161
|
+
// The bold run is still bold underneath the mark.
|
|
162
|
+
expect(container.querySelector("strong")?.textContent).toBe("18%");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("marks a list item past its bullet, which is drawn by the list, not the text", async () => {
|
|
166
|
+
const doc = await load();
|
|
167
|
+
const { container } = await renderWith([cite("a", rangeOf(doc, "EMEA beat"))]);
|
|
168
|
+
const mark = container.querySelector("mark");
|
|
169
|
+
expect(mark?.textContent).toBe("EMEA beat");
|
|
170
|
+
expect(mark?.closest("li")?.textContent).toBe("EMEA beat plan");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("marks one table cell, not the whole row it shares a span with", async () => {
|
|
174
|
+
const doc = await load();
|
|
175
|
+
const { container } = await renderWith([cite("a", rangeOf(doc, "4.2M"))]);
|
|
176
|
+
const marks = Array.from(container.querySelectorAll("mark"));
|
|
177
|
+
expect(marks).toHaveLength(1);
|
|
178
|
+
expect(marks[0]?.closest("td")?.textContent).toBe("4.2M");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("flags the current passage for assistive tech, not by colour alone", async () => {
|
|
182
|
+
const doc = await load();
|
|
183
|
+
const { container } = await renderWith([cite("a", rangeOf(doc, "flat"), true)], "a");
|
|
184
|
+
const active = container.querySelector('mark[data-active][data-slot="match-highlight-mark"]');
|
|
185
|
+
expect(active?.getAttribute("aria-current")).toBe("true");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("draws nothing when no highlight is passed", async () => {
|
|
189
|
+
const { container } = await renderWith([]);
|
|
190
|
+
expect(container.querySelectorAll("mark")).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
});
|