@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,117 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Plain-text adapter — the fallback for anything readable as UTF-8.
5
+ *
6
+ * Deliberately does NOT do SYNTAX highlighting — that is the separate `code`
7
+ * adapter, registered at a higher priority for code extensions. Keeping them
8
+ * apart means a 40 MB log file never drags a tokenizer into the bundle, and a
9
+ * consumer can drop the tokenizer without losing plain text. (Citation and
10
+ * find-in-document marks are a different thing entirely and DO render here.)
11
+ *
12
+ * Large files are truncated at load, not at render: putting ten million
13
+ * characters into one `<pre>` locks the main thread with no way back. The cut is
14
+ * reported so the UI can say so rather than silently lying about the content.
15
+ */
16
+
17
+ import { useMemo, useRef } from "react";
18
+
19
+ import { MatchHighlight, cn, useLocale } from "@elabs-ai/components-ui";
20
+ import type { ResolvedFileSource } from "@elabs-ai/components-ui";
21
+
22
+ import { toViewerError } from "../../core/errors";
23
+ import { toMarkRanges } from "../../core/highlight-marks";
24
+ import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
25
+ import type {
26
+ AdapterDocument,
27
+ AdapterLoadContext,
28
+ AdapterModule,
29
+ AdapterRendererProps,
30
+ FileAdapter,
31
+ } from "../../core/types";
32
+ import { textManifest } from "./text-manifest";
33
+
34
+ /**
35
+ * Characters kept before truncation. ~2M is comfortably past any file a person
36
+ * reads in a preview pane and still renders in one frame.
37
+ */
38
+ export const TEXT_CHARACTER_LIMIT = 2_000_000;
39
+
40
+ export interface TextDocument extends AdapterDocument {
41
+ kind: "text";
42
+ text: string;
43
+ /** Total characters in the file, when more than what is shown. */
44
+ totalCharacters?: number;
45
+ }
46
+
47
+ class TextAdapter implements FileAdapter {
48
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<TextDocument> {
49
+ let raw: string;
50
+ try {
51
+ raw = await source.text(context.signal);
52
+ } catch (error) {
53
+ throw toViewerError(error, "read-failed", { fileName: source.name });
54
+ }
55
+
56
+ if (raw.length <= TEXT_CHARACTER_LIMIT) return { kind: "text", text: raw };
57
+ return {
58
+ kind: "text",
59
+ text: raw.slice(0, TEXT_CHARACTER_LIMIT),
60
+ totalCharacters: raw.length,
61
+ // So a passage the shell cannot find is reported as "past the part we
62
+ // previewed" rather than "not in this document" — different news.
63
+ textTruncated: true,
64
+ };
65
+ }
66
+ }
67
+
68
+ function TextRenderer({
69
+ document: doc,
70
+ className,
71
+ highlights,
72
+ activeHighlightId,
73
+ }: AdapterRendererProps) {
74
+ const text = doc as TextDocument;
75
+ const { t, formatNumber } = useLocale();
76
+ const container = useRef<HTMLDivElement>(null);
77
+ const { ranges, activeIndex } = useMemo(
78
+ () => toMarkRanges(highlights, text.text.length),
79
+ [highlights, text.text.length],
80
+ );
81
+ useScrollActiveHighlightIntoView(container, activeHighlightId);
82
+
83
+ return (
84
+ // No `overflow-auto` here: `FileViewerContent` is the scroll boundary, and
85
+ // nesting a second one clips the text above the outer pane's own padding.
86
+ <div ref={container} className={cn("flex flex-col gap-2", className)}>
87
+ {text.totalCharacters !== undefined && (
88
+ // Not an error — the file is fine, we are showing part of it. A status
89
+ // region, so AT hears it once rather than on every scroll.
90
+ <p role="status" className="text-meta text-muted-foreground">
91
+ {t("viewer.text.truncated", {
92
+ shown: formatNumber(text.text.length),
93
+ total: formatNumber(text.totalCharacters),
94
+ })}
95
+ </p>
96
+ )}
97
+ <pre className="text-code font-mono break-words whitespace-pre-wrap">
98
+ {ranges.length === 0 ? (
99
+ text.text
100
+ ) : (
101
+ // `MatchHighlight` renders the WHOLE string with the matches wrapped,
102
+ // so nothing is lost when a highlight is present — a screen reader
103
+ // still reads the file continuously.
104
+ <MatchHighlight text={text.text} ranges={ranges} activeIndex={activeIndex} />
105
+ )}
106
+ </pre>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ const adapterModule: AdapterModule = {
112
+ manifest: textManifest,
113
+ create: () => new TextAdapter(),
114
+ Renderer: TextRenderer,
115
+ };
116
+
117
+ export default adapterModule;
@@ -0,0 +1,19 @@
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
+ * Claims only broad CATEGORIES, so any adapter naming an extension or an exact
8
+ * MIME outranks it automatically — this is the "readable as text" backstop.
9
+ */
10
+ export const textManifest: AdapterManifest = {
11
+ id: "text",
12
+ protocol: PROTOCOL_VERSION,
13
+ categories: ["text", "code", "data", "unknown"],
14
+ mediaTypes: ["text/"],
15
+ // `rect` is absent on purpose: a plain-text file has no geometry to point at,
16
+ // and a manifest that claimed it would let an app offer an affordance the
17
+ // renderer can never honour.
18
+ capabilities: { text: true, search: true, highlight: ["quote", "range"] },
19
+ };
@@ -0,0 +1,159 @@
1
+ import { normalizeFileSource } from "@elabs-ai/components-ui";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import type { ResolvedHighlight } from "../../core/highlight";
7
+ import { SAMPLE_XLSX_BASE64 } from "../office-fixture";
8
+ import xlsxModule, { type XlsxDocument } from "./xlsx-adapter";
9
+
10
+ const MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
11
+
12
+ function fixtureSource(name = "quarter.xlsx") {
13
+ const bytes = Uint8Array.from(atob(SAMPLE_XLSX_BASE64), (char) => char.charCodeAt(0));
14
+ return normalizeFileSource({
15
+ kind: "buffer",
16
+ buffer: bytes.buffer as ArrayBuffer,
17
+ name,
18
+ mediaType: MEDIA_TYPE,
19
+ });
20
+ }
21
+
22
+ const load = () => xlsxModule.create().load(fixtureSource(), {}) as Promise<XlsxDocument>;
23
+
24
+ describe("xlsx adapter — a real workbook through SheetJS", () => {
25
+ it("reads every sheet, keeping the tab names the author gave them", async () => {
26
+ const doc = await load();
27
+ expect(doc.sheets.map((sheet) => sheet.name)).toEqual(["Revenue", "Headcount"]);
28
+ expect(doc.pageCount).toBe(2);
29
+ });
30
+
31
+ it("takes the first row as headers and the rest as rows", async () => {
32
+ const [revenue] = (await load()).sheets;
33
+ expect(revenue?.columns).toEqual(["Region", "Revenue", "Closed"]);
34
+ expect(revenue?.rows[0]?.slice(0, 2)).toEqual(["EMEA", "4200000"]);
35
+ expect(revenue?.rows).toHaveLength(3);
36
+ });
37
+
38
+ it("shows a date as a date, not as Excel's serial day number", async () => {
39
+ const [revenue] = (await load()).sheets;
40
+ // Without `cellDates` this cell reads "46112", which means nothing to a reader.
41
+ expect(revenue?.rows[0]?.[2]).toBe("2026-03-31");
42
+ });
43
+
44
+ it("does not claim truncation when every row fits", async () => {
45
+ for (const sheet of (await load()).sheets) expect(sheet.totalRows).toBeUndefined();
46
+ });
47
+
48
+ it("projects the whole workbook to text so search and copy cross sheets", async () => {
49
+ const doc = await load();
50
+ expect(doc.text).toContain("Headcount");
51
+ expect(doc.text).toContain("Engineering\t34");
52
+ });
53
+
54
+ it("reports a file that is not a workbook as parse-failed", async () => {
55
+ const source = normalizeFileSource({
56
+ kind: "text",
57
+ text: "not a workbook",
58
+ name: "broken.xlsx",
59
+ mediaType: MEDIA_TYPE,
60
+ });
61
+ await expect(xlsxModule.create().load(source, {})).rejects.toMatchObject({
62
+ code: "parse-failed",
63
+ });
64
+ });
65
+ });
66
+
67
+ describe("xlsx renderer", () => {
68
+ it("renders a real table, and offers the sheets as tabs", async () => {
69
+ const doc = await load();
70
+ render(<xlsxModule.Renderer document={doc} source={fixtureSource()} />);
71
+
72
+ expect(screen.getByRole("tablist", { name: "Sheets" })).toBeInTheDocument();
73
+ expect(screen.getByRole("columnheader", { name: "Region" })).toBeInTheDocument();
74
+ expect(screen.getByRole("cell", { name: "4200000" })).toBeInTheDocument();
75
+ });
76
+
77
+ it("switches sheets without reloading the file", async () => {
78
+ const doc = await load();
79
+ render(<xlsxModule.Renderer document={doc} source={fixtureSource()} />);
80
+
81
+ await userEvent.click(screen.getByRole("tab", { name: "Headcount" }));
82
+ expect(await screen.findByRole("columnheader", { name: "Team" })).toBeInTheDocument();
83
+ });
84
+
85
+ it("drops the tab strip for a single-sheet workbook — chrome that decides nothing", async () => {
86
+ const doc = await load();
87
+ const single: XlsxDocument = { ...doc, sheets: doc.sheets.slice(0, 1), pageCount: 1 };
88
+ render(<xlsxModule.Renderer document={single} source={fixtureSource()} />);
89
+
90
+ expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
91
+ expect(screen.getByRole("table")).toBeInTheDocument();
92
+ });
93
+ });
94
+
95
+ describe("xlsx renderer — highlighting", () => {
96
+ const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
97
+ id,
98
+ source: "citation",
99
+ status: "resolved",
100
+ address: { kind: "range", start: range[0], end: range[1] },
101
+ active,
102
+ range,
103
+ });
104
+
105
+ /** Offsets of a passage in the workbook's own projection. */
106
+ const rangeOf = (doc: XlsxDocument, passage: string): [number, number] => {
107
+ const start = doc.text?.indexOf(passage) ?? -1;
108
+ expect(start).toBeGreaterThanOrEqual(0);
109
+ return [start, start + passage.length];
110
+ };
111
+
112
+ const renderWith = async (
113
+ pick: (doc: XlsxDocument) => ResolvedHighlight[],
114
+ activeHighlightId?: string,
115
+ ) => {
116
+ const doc = await load();
117
+ return render(
118
+ <xlsxModule.Renderer
119
+ document={doc}
120
+ source={fixtureSource()}
121
+ highlights={pick(doc)}
122
+ activeHighlightId={activeHighlightId}
123
+ />,
124
+ );
125
+ };
126
+
127
+ it("declares the address kinds it can actually honour", () => {
128
+ expect(xlsxModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
129
+ });
130
+
131
+ it("marks one cell of the sheet on screen", async () => {
132
+ const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "4200000"))]);
133
+ const marks = Array.from(container.querySelectorAll("mark"));
134
+ expect(marks).toHaveLength(1);
135
+ expect(marks[0]?.closest("td")?.textContent).toBe("4200000");
136
+ });
137
+
138
+ it("turns to the cited SHEET, the way the PDF turns to the cited page", async () => {
139
+ // "Engineering" is on Headcount, which is not the tab that opens.
140
+ const { container } = await renderWith(
141
+ (doc) => [cite("a", rangeOf(doc, "Engineering"), true)],
142
+ "a",
143
+ );
144
+ expect(await screen.findByRole("columnheader", { name: "Team" })).toBeInTheDocument();
145
+ expect(container.querySelector("mark")?.textContent).toBe("Engineering");
146
+ });
147
+
148
+ it("flags the current passage for assistive tech, not by colour alone", async () => {
149
+ const { container } = await renderWith((doc) => [cite("a", rangeOf(doc, "EMEA"), true)], "a");
150
+ const active = container.querySelector('mark[data-active][data-slot="match-highlight-mark"]');
151
+ expect(active?.getAttribute("aria-current")).toBe("true");
152
+ expect(active?.textContent).toBe("EMEA");
153
+ });
154
+
155
+ it("draws nothing when no highlight is passed", async () => {
156
+ const { container } = await renderWith(() => []);
157
+ expect(container.querySelectorAll("mark")).toHaveLength(0);
158
+ });
159
+ });
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Spreadsheet adapter — a workbook becomes sheets of rows, and rows become a
5
+ * real `Table`.
6
+ *
7
+ * SheetJS is only ever asked for the DATA. Its `sheet_to_html` helper exists and
8
+ * is not used: it returns a markup string with its own inline styling, which is
9
+ * exactly the "adapter emits HTML" failure this package was built to avoid
10
+ * (ADR 0024 §3). `sheet_to_json({ header: 1 })` gives raw cell values, which the
11
+ * shared `SheetTable` renders with brand-ui components — so a spreadsheet
12
+ * inherits the theme, the density dial and the keyboard semantics.
13
+ *
14
+ * **`xlsx` is an optional peer with two known advisories.** The npm build
15
+ * (`0.18.5`) carries a prototype-pollution and a ReDoS advisory, both fixed only
16
+ * in SheetJS's self-hosted CDN builds, which are not published to npm. That is a
17
+ * documented, deliberate trade (`docs/CONSUMING.md` §6): a consumer who never
18
+ * opens a spreadsheet never installs it and is never exposed. Treat any workbook
19
+ * opened here as untrusted input.
20
+ */
21
+
22
+ import type { ResolvedFileSource } from "@elabs-ai/components-ui";
23
+ import { cn, Tabs, TabsContent, TabsList, TabsTrigger, useLocale } from "@elabs-ai/components-ui";
24
+ import { useEffect, useMemo, useState } from "react";
25
+
26
+ import { gridToText, type GridRef } from "../../components/grid-text";
27
+ import { SheetTable } from "../../components/sheet-table";
28
+ import { ViewerError, toViewerError } from "../../core/errors";
29
+ import { toMarkRanges } from "../../core/highlight-marks";
30
+ import { spanAt, type TextIndex } from "../../core/text-index";
31
+ import type {
32
+ AdapterDocument,
33
+ AdapterLoadContext,
34
+ AdapterModule,
35
+ AdapterRendererProps,
36
+ FileAdapter,
37
+ } from "../../core/types";
38
+ import { xlsxManifest } from "./xlsx-manifest";
39
+
40
+ /**
41
+ * Rows rendered per sheet before truncation. Same bound, same reason, as the CSV
42
+ * adapter: a preview pane is for looking, and a real analysis grid is
43
+ * `DataTable` in `@elabs-ai/components-data`.
44
+ */
45
+ export const XLSX_ROW_LIMIT = 5_000;
46
+
47
+ export interface SheetModel {
48
+ /** The tab's own name, as authored in the workbook. */
49
+ name: string;
50
+ /** Header cells, taken from the sheet's first row. */
51
+ columns: string[];
52
+ /** Body rows, already capped at {@link XLSX_ROW_LIMIT}. */
53
+ rows: string[][];
54
+ /** Total body rows in the sheet, when more than what is shown. */
55
+ totalRows?: number;
56
+ }
57
+
58
+ export interface XlsxDocument extends AdapterDocument {
59
+ kind: "xlsx";
60
+ sheets: SheetModel[];
61
+ /** Which sheet and row each stretch of `text` came from. */
62
+ textIndex?: TextIndex<GridRef>;
63
+ }
64
+
65
+ /**
66
+ * Does this look like a workbook container at all?
67
+ *
68
+ * SheetJS sniffs its input and will happily read arbitrary text as a one-column
69
+ * CSV — so a damaged `.xlsx` comes back as a sheet holding one junk cell instead
70
+ * of failing. That is worse than an error: it looks like the file opened. Both
71
+ * containers this adapter claims have a fixed signature — OOXML/ODS is a zip
72
+ * (`PK\x03\x04`), legacy `.xls` is a CFB compound file — so anything else is
73
+ * rejected before SheetJS gets a chance to be helpful.
74
+ */
75
+ export function looksLikeWorkbook(bytes: Uint8Array): boolean {
76
+ const zip = [0x50, 0x4b, 0x03, 0x04];
77
+ const cfb = [0xd0, 0xcf, 0x11, 0xe0];
78
+ return [zip, cfb].some((signature) => signature.every((byte, index) => bytes[index] === byte));
79
+ }
80
+
81
+ /** SheetJS returns whatever the cell held — a date, a number, a formula result. */
82
+ function toCellText(value: unknown): string {
83
+ if (value === null || value === undefined) return "";
84
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
85
+ return String(value);
86
+ }
87
+
88
+ class XlsxAdapter implements FileAdapter {
89
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<XlsxDocument> {
90
+ let buffer: ArrayBuffer;
91
+ try {
92
+ buffer = await source.bytes(context.signal);
93
+ } catch (error) {
94
+ throw toViewerError(error, "read-failed", { fileName: source.name });
95
+ }
96
+
97
+ const bytes = new Uint8Array(buffer);
98
+ if (!looksLikeWorkbook(bytes)) {
99
+ throw new ViewerError("parse-failed", "The file is not a spreadsheet container.", {
100
+ fileName: source.name,
101
+ });
102
+ }
103
+
104
+ // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
105
+ const XLSX = await import("xlsx");
106
+
107
+ try {
108
+ // `cellDates` so a date column reads as a date rather than as Excel's
109
+ // serial day number, which is meaningless to a person looking at it.
110
+ const workbook = XLSX.read(bytes, { type: "array", cellDates: true });
111
+ const sheets = workbook.SheetNames.map((name) => {
112
+ const sheet = workbook.Sheets[name];
113
+ const grid: unknown[][] = sheet
114
+ ? XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: "" })
115
+ : [];
116
+ const [header = [], ...body] = grid;
117
+ const columns = header.map(toCellText);
118
+ const rows = body.slice(0, XLSX_ROW_LIMIT).map((row) => row.map(toCellText));
119
+ return body.length > XLSX_ROW_LIMIT
120
+ ? { name, columns, rows, totalRows: body.length }
121
+ : { name, columns, rows };
122
+ });
123
+
124
+ if (sheets.length === 0) {
125
+ throw new ViewerError("parse-failed", "The workbook contains no sheets.", {
126
+ fileName: source.name,
127
+ });
128
+ }
129
+
130
+ // The projection is built by the shared grid builder rather than joined by
131
+ // hand, so the text a citation is addressed against and the map back into
132
+ // the cells cannot drift apart (`core/text-index.ts`).
133
+ const textIndex = gridToText(sheets);
134
+ return {
135
+ kind: "xlsx",
136
+ sheets,
137
+ pageCount: sheets.length,
138
+ text: textIndex.text,
139
+ textIndex,
140
+ };
141
+ } catch (error) {
142
+ throw toViewerError(error, "parse-failed", { fileName: source.name });
143
+ }
144
+ }
145
+ }
146
+
147
+ function XlsxRenderer({
148
+ document: doc,
149
+ className,
150
+ highlights,
151
+ activeHighlightId,
152
+ }: AdapterRendererProps) {
153
+ const workbook = doc as XlsxDocument;
154
+ const { t } = useLocale();
155
+ const [first] = workbook.sheets;
156
+
157
+ const marks = useMemo(
158
+ () => toMarkRanges(highlights, workbook.text?.length ?? 0),
159
+ [highlights, workbook.text],
160
+ );
161
+
162
+ // Where each row of each sheet begins in the projection. Built once per
163
+ // document rather than per tab, because switching sheets must not re-walk the
164
+ // spans of every OTHER sheet to answer a question about this one.
165
+ const starts = useMemo(() => {
166
+ const bySheet = new Map<number, Map<number, number>>();
167
+ for (const span of workbook.textIndex?.spans ?? []) {
168
+ let rows = bySheet.get(span.ref.sheet);
169
+ if (!rows) {
170
+ rows = new Map<number, number>();
171
+ bySheet.set(span.ref.sheet, rows);
172
+ }
173
+ rows.set(span.ref.row, span.start);
174
+ }
175
+ return bySheet;
176
+ }, [workbook.textIndex]);
177
+ const rowStartFor = (sheet: number) => (row: number) => starts.get(sheet)?.get(row);
178
+
179
+ // Turning to the cited SHEET is this format's half of "take me there" — the
180
+ // row scroll inside it belongs to `SheetTable`. Keyed on the sheet NAME, so a
181
+ // reader who switches tabs while the same citation stays active is not
182
+ // dragged back (the PDF pager makes the same trade).
183
+ const activeSheet = useMemo(() => {
184
+ const active = highlights?.find((highlight) => highlight.id === activeHighlightId);
185
+ if (!active || active.status !== "resolved" || !active.range || !workbook.textIndex) {
186
+ return undefined;
187
+ }
188
+ return spanAt(workbook.textIndex, active.range[0])?.ref.sheet;
189
+ }, [highlights, activeHighlightId, workbook.textIndex]);
190
+ const activeSheetName =
191
+ activeSheet === undefined ? undefined : workbook.sheets[activeSheet]?.name;
192
+
193
+ const [pinned, setPinned] = useState<string>();
194
+ useEffect(() => {
195
+ if (activeSheetName !== undefined) setPinned(activeSheetName);
196
+ }, [activeSheetName]);
197
+
198
+ // A one-sheet workbook is the common case, and a tab strip holding a single
199
+ // tab is chrome that decides nothing — so it is not rendered.
200
+ if (!first) return null;
201
+ if (workbook.sheets.length === 1) {
202
+ return (
203
+ <SheetTable
204
+ columns={first.columns}
205
+ rows={first.rows}
206
+ totalRows={first.totalRows}
207
+ className={className}
208
+ marks={marks}
209
+ rowStart={rowStartFor(0)}
210
+ activeHighlightId={activeHighlightId}
211
+ />
212
+ );
213
+ }
214
+
215
+ return (
216
+ <Tabs
217
+ value={pinned ?? first.name}
218
+ onValueChange={setPinned}
219
+ className={cn("flex h-full min-h-0 flex-col gap-2", className)}
220
+ >
221
+ <TabsList aria-label={t("viewer.sheet.tabs")} className="shrink-0 self-start">
222
+ {workbook.sheets.map((sheet) => (
223
+ <TabsTrigger key={sheet.name} value={sheet.name}>
224
+ {sheet.name}
225
+ </TabsTrigger>
226
+ ))}
227
+ </TabsList>
228
+ {workbook.sheets.map((sheet, index) => (
229
+ <TabsContent key={sheet.name} value={sheet.name} className="mt-0 min-h-0 flex-1">
230
+ <SheetTable
231
+ columns={sheet.columns}
232
+ rows={sheet.rows}
233
+ totalRows={sheet.totalRows}
234
+ marks={marks}
235
+ rowStart={rowStartFor(index)}
236
+ activeHighlightId={activeHighlightId}
237
+ />
238
+ </TabsContent>
239
+ ))}
240
+ </Tabs>
241
+ );
242
+ }
243
+
244
+ const adapterModule: AdapterModule = {
245
+ manifest: xlsxManifest,
246
+ create: () => new XlsxAdapter(),
247
+ Renderer: XlsxRenderer,
248
+ };
249
+
250
+ export default adapterModule;
@@ -0,0 +1,26 @@
1
+ import { type AdapterManifest, PROTOCOL_VERSION } from "../../core/types";
2
+
3
+ /**
4
+ * Excel workbooks. Data-only, eager — see `core/types.ts` for why the manifest
5
+ * and the loader are split.
6
+ *
7
+ * `.csv` is deliberately NOT claimed here even though SheetJS can read it: the
8
+ * `csv` adapter is a better answer for a delimited text file (it detects the
9
+ * delimiter and needs a far smaller parser), and claiming it would make which
10
+ * adapter wins depend on registration order.
11
+ */
12
+ export const xlsxManifest: AdapterManifest = {
13
+ id: "xlsx",
14
+ protocol: PROTOCOL_VERSION,
15
+ extensions: ["xlsx", "xlsm", "xls", "ods"],
16
+ mediaTypes: [
17
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
18
+ "application/vnd.ms-excel",
19
+ "application/vnd.oasis.opendocument.spreadsheet",
20
+ ],
21
+ // Addressed against the same grid projection the CSV adapter uses
22
+ // (`gridToText`), so a citation resolves the same way in a workbook and in the
23
+ // CSV export of one sheet. No `rect`: a cell has no page geometry.
24
+ capabilities: { pages: true, text: true, highlight: ["quote", "range"] },
25
+ requires: ["xlsx"],
26
+ };
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { chunkOffset } from "../core/text-index";
4
+ import { GRID_CELL_SEPARATOR, GRID_HEAD_ROW, GRID_NAME_ROW, gridToText } from "./grid-text";
5
+
6
+ /** `[the text this span covers, what it points at]` — the pair every test reads. */
7
+ const mapped = ({ text, spans }: ReturnType<typeof gridToText>) =>
8
+ spans.map((span) => [text.slice(span.start, span.end), span.ref]);
9
+
10
+ describe("gridToText", () => {
11
+ it("projects one nameless sheet as a header row and body rows", () => {
12
+ const index = gridToText([{ columns: ["Region", "Revenue"], rows: [["EMEA", "4.2M"]] }]);
13
+
14
+ expect(index.text).toBe("Region\tRevenue\nEMEA\t4.2M");
15
+ expect(mapped(index)).toEqual([
16
+ ["Region\tRevenue", { sheet: 0, row: GRID_HEAD_ROW }],
17
+ ["EMEA\t4.2M", { sheet: 0, row: 0 }],
18
+ ]);
19
+ });
20
+
21
+ it("separates sheets with a blank line and names each one", () => {
22
+ const index = gridToText([
23
+ { name: "Q3", columns: ["a"], rows: [["1"]] },
24
+ { name: "Q4", columns: ["b"], rows: [["2"]] },
25
+ ]);
26
+
27
+ expect(index.text).toBe("Q3\na\n1\n\nQ4\nb\n2");
28
+ expect(mapped(index)).toEqual([
29
+ ["Q3", { sheet: 0, row: GRID_NAME_ROW }],
30
+ ["a", { sheet: 0, row: GRID_HEAD_ROW }],
31
+ ["1", { sheet: 0, row: 0 }],
32
+ ["Q4", { sheet: 1, row: GRID_NAME_ROW }],
33
+ ["b", { sheet: 1, row: GRID_HEAD_ROW }],
34
+ ["2", { sheet: 1, row: 0 }],
35
+ ]);
36
+ });
37
+
38
+ it("puts the blank line before whatever the next sheet's FIRST line is", () => {
39
+ // The second sheet has no name, so the separator lands on its header.
40
+ const index = gridToText([
41
+ { columns: ["a"], rows: [] },
42
+ { columns: ["b"], rows: [] },
43
+ ]);
44
+ expect(index.text).toBe("a\n\nb");
45
+ });
46
+
47
+ it("skips a sheet with nothing in it rather than shifting every offset after it", () => {
48
+ const index = gridToText([
49
+ { columns: ["a"], rows: [] },
50
+ { columns: [], rows: [] },
51
+ { columns: ["b"], rows: [] },
52
+ ]);
53
+ expect(index.text).toBe("a\n\nb");
54
+ expect(index.spans.map((span) => span.ref.sheet)).toEqual([0, 2]);
55
+ });
56
+
57
+ it("keeps an empty cell in its row, so the cells after it stay put", () => {
58
+ const index = gridToText([{ columns: ["a", "b", "c"], rows: [["1", "", "3"]] }]);
59
+ const row = index.spans[1];
60
+
61
+ expect(index.text.slice(row?.start, row?.end)).toBe("1\t\t3");
62
+ // The third cell is where the projection says it is — an empty cell is a
63
+ // separator, not a hole (this is why rows, not cells, are the finest ref).
64
+ expect(chunkOffset(["1", "", "3"], 2, row?.start, GRID_CELL_SEPARATOR)).toBe(
65
+ index.text.indexOf("3"),
66
+ );
67
+ });
68
+ });