@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,171 @@
1
+ /**
2
+ * The pdf.js seam — one place that knows the engine exists.
3
+ *
4
+ * Everything here is deliberately NOT React. Splitting the engine wiring from
5
+ * the renderer keeps two things true: the dynamic `import("pdfjs-dist")` has a
6
+ * single call site (so `heavy-deps:check` has one edge to police), and an app
7
+ * can configure the worker before any file is ever opened.
8
+ *
9
+ * ## Why a worker is not optional
10
+ *
11
+ * pdf.js parses and rasterizes on a worker thread; without one it falls back to
12
+ * doing that on the main thread, and a 40-page document freezes the tab. The
13
+ * default below resolves the worker that ships INSIDE `pdfjs-dist`, via
14
+ * `new URL(…, import.meta.url)` — the form every modern bundler understands, so
15
+ * it works with no configuration in Vite/webpack/Next.
16
+ *
17
+ * ## Why an app may still have to configure it
18
+ *
19
+ * A CSP without `worker-src blob:` — or a bundler that cannot see through the
20
+ * `new URL` form — needs the worker served as a real asset instead. Hence
21
+ * {@link configurePdfEngine}: set `workerSrc` to a URL your app serves, and the
22
+ * default is never consulted. `cMapUrl` / `standardFontDataUrl` are the same
23
+ * story for CJK text and non-embedded fonts, which pdf.js fetches on demand.
24
+ */
25
+
26
+ /** Engine wiring an app can override. Every field is optional. */
27
+ export interface PdfEngineConfig {
28
+ /** URL of `pdf.worker.min.mjs`. Defaults to the copy inside `pdfjs-dist`. */
29
+ workerSrc?: string;
30
+ /** Directory of pdf.js `cmaps/`, for documents with CJK text. */
31
+ cMapUrl?: string;
32
+ /** Directory of pdf.js `standard_fonts/`, for documents that embed no fonts. */
33
+ standardFontDataUrl?: string;
34
+ }
35
+
36
+ let config: PdfEngineConfig = {};
37
+
38
+ /**
39
+ * Point the PDF adapter at your own copies of the pdf.js assets.
40
+ *
41
+ * Call once at app start, before a PDF is opened. Calling it later is harmless
42
+ * but only affects documents opened afterwards — pdf.js reads the worker
43
+ * setting when a document is created.
44
+ */
45
+ export function configurePdfEngine(next: PdfEngineConfig): void {
46
+ config = { ...config, ...next };
47
+ }
48
+
49
+ /** What the adapter will actually use, after any {@link configurePdfEngine} call. */
50
+ export function getPdfEngineConfig(): Readonly<PdfEngineConfig> {
51
+ return config;
52
+ }
53
+
54
+ /**
55
+ * The pdf.js surface this adapter uses, declared structurally.
56
+ *
57
+ * `pdfjs-dist` is an OPTIONAL peer, so its types are not guaranteed to be
58
+ * installed — importing them would make this package's typecheck depend on a
59
+ * package a consumer may not have. Declaring the handful of members used here
60
+ * keeps the build honest without that coupling, and a shape change in pdf.js
61
+ * surfaces as a runtime failure the `parse-failed` panel already handles.
62
+ */
63
+ export interface PdfPageViewport {
64
+ width: number;
65
+ height: number;
66
+ }
67
+
68
+ export interface PdfTextItem {
69
+ str: string;
70
+ transform: number[];
71
+ width: number;
72
+ height: number;
73
+ fontName?: string;
74
+ }
75
+
76
+ export interface PdfPage {
77
+ getViewport(options: { scale: number; rotation?: number }): PdfPageViewport;
78
+ render(options: { canvasContext: CanvasRenderingContext2D; viewport: PdfPageViewport }): {
79
+ promise: Promise<void>;
80
+ cancel(): void;
81
+ };
82
+ getTextContent(): Promise<{ items: PdfTextItem[] }>;
83
+ cleanup(): void;
84
+ }
85
+
86
+ export interface PdfDocument {
87
+ numPages: number;
88
+ getPage(pageNumber: number): Promise<PdfPage>;
89
+ }
90
+
91
+ /**
92
+ * An open document plus the one call that releases its worker.
93
+ *
94
+ * The two are separate because **the worker belongs to the LOADING TASK, not to
95
+ * the document**: pdf.js removed `PDFDocumentProxy.destroy()` in v5, so
96
+ * `document.destroy()` is not a function on the engine this package installs. It
97
+ * type-checked anyway — the structural `PdfDocument` interface here declared a
98
+ * method the real proxy does not have — and threw a `TypeError` on every unmount,
99
+ * leaking exactly the worker per document that `dispose()` exists to prevent.
100
+ * Keeping the teardown on this object rather than on `document` is what stops the
101
+ * mistake from being expressible.
102
+ */
103
+ export interface PdfSession {
104
+ document: PdfDocument;
105
+ destroy(): Promise<void>;
106
+ }
107
+
108
+ interface PdfJsModule {
109
+ GlobalWorkerOptions: { workerSrc: string };
110
+ getDocument(options: Record<string, unknown>): {
111
+ promise: Promise<PdfDocument>;
112
+ destroy(): Promise<void>;
113
+ };
114
+ }
115
+
116
+ let enginePromise: Promise<PdfJsModule> | undefined;
117
+
118
+ /**
119
+ * Load pdf.js once per page load and wire its worker.
120
+ *
121
+ * The result is cached: opening a second PDF must not re-fetch a megabyte of
122
+ * engine. A FAILED load is not cached, so a transient network error can be
123
+ * retried by the viewer's own retry button.
124
+ */
125
+ export async function loadPdfEngine(): Promise<PdfJsModule> {
126
+ enginePromise ??= (async () => {
127
+ // The ONLY edge to the optional peer. A static import here would break
128
+ // every consumer that did not install pdfjs-dist (heavy-deps:check).
129
+ const pdfjs = (await import("pdfjs-dist")) as unknown as PdfJsModule;
130
+ pdfjs.GlobalWorkerOptions.workerSrc = config.workerSrc ?? defaultWorkerSrc();
131
+ return pdfjs;
132
+ })().catch((error: unknown) => {
133
+ enginePromise = undefined;
134
+ throw error;
135
+ });
136
+ return enginePromise;
137
+ }
138
+
139
+ /**
140
+ * The worker that ships with the installed `pdfjs-dist`.
141
+ *
142
+ * `new URL(…, import.meta.url)` is a *bundler instruction*, not a runtime path
143
+ * lookup: Vite, webpack 5 and Turbopack all rewrite it to an emitted asset URL.
144
+ * It is wrapped because a runtime without `import.meta.url` (a CJS test) throws
145
+ * here, and a missing worker should surface as "configure the worker", not as an
146
+ * unexplained crash.
147
+ */
148
+ function defaultWorkerSrc(): string {
149
+ return new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).href;
150
+ }
151
+
152
+ /** Open a document from bytes. The caller owns `destroy()` — see the adapter's `dispose`. */
153
+ export async function openPdfDocument(
154
+ bytes: Uint8Array,
155
+ signal?: AbortSignal,
156
+ ): Promise<PdfSession> {
157
+ const pdfjs = await loadPdfEngine();
158
+ const task = pdfjs.getDocument({
159
+ data: bytes,
160
+ // Defence in depth: pdf.js can evaluate font programs, and a viewer opens
161
+ // files the app did not write. The cost is a rare, slightly slower font
162
+ // path; the benefit is that a malicious PDF has no `eval` to reach for.
163
+ isEvalSupported: false,
164
+ cMapUrl: config.cMapUrl,
165
+ cMapPacked: true,
166
+ standardFontDataUrl: config.standardFontDataUrl,
167
+ });
168
+ signal?.addEventListener("abort", () => void task.destroy(), { once: true });
169
+ const document = await task.promise;
170
+ return { document, destroy: () => task.destroy() };
171
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A real, minimal PDF — two pages of live text, ~1 KB, generated once and frozen
3
+ * here so stories and tests need no network and no binary asset in the repo.
4
+ *
5
+ * It is a genuine file (catalog, page tree, content streams, xref), not a stub:
6
+ * the point is that pdf.js actually opens it, rasterizes a page and exposes
7
+ * selectable text — which a hand-waved fixture could not prove.
8
+ */
9
+ export const SAMPLE_PDF_BASE64 =
10
+ "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwg" +
11
+ "L1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUiA1IDAgUl0gL0NvdW50IDIgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUg" +
12
+ "L1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9G" +
13
+ "MSA3IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAxMzcgPj4Kc3Ry" +
14
+ "ZWFtCkJUIC9GMSAyNCBUZiA3MiA3MDAgVGQgKFF1YXJ0ZXJseSByZXBvcnQgLSBwYWdlIG9uZSkgVGogRVQKQlQgL0Yx" +
15
+ "IDEyIFRmIDcyIDY2MCBUZCAoR2VuZXJhdGVkIGZpeHR1cmUgZm9yIHRoZSBicmFuZC11aSBmaWxlIHZpZXdlci4pIFRq" +
16
+ "IEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9NZWRpYUJveCBb" +
17
+ "MCAwIDYxMiA3OTJdIC9SZXNvdXJjZXMgPDwgL0ZvbnQgPDwgL0YxIDcgMCBSID4+ID4+IC9Db250ZW50cyA2IDAgUiA+" +
18
+ "PgplbmRvYmoKNiAwIG9iago8PCAvTGVuZ3RoIDEyOSA+PgpzdHJlYW0KQlQgL0YxIDI0IFRmIDcyIDcwMCBUZCAoQXBw" +
19
+ "ZW5kaXggLSBwYWdlIHR3bykgVGogRVQKQlQgL0YxIDEyIFRmIDcyIDY2MCBUZCAoR2VuZXJhdGVkIGZpeHR1cmUgZm9y" +
20
+ "IHRoZSBicmFuZC11aSBmaWxlIHZpZXdlci4pIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNyAwIG9iago8PCAvVHlwZSAv" +
21
+ "Rm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA4CjAwMDAwMDAw" +
22
+ "MDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAg" +
23
+ "biAKMDAwMDAwMDI0NyAwMDAwMCBuIAowMDAwMDAwNDM1IDAwMDAwIG4gCjAwMDAwMDA1NjEgMDAwMDAgbiAKMDAwMDAw" +
24
+ "MDc0MSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDggL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjgxMQolJUVPRgo=";
25
+
26
+ /** The same file as a data URI, ready to hand to `FileViewer` as a `url` source. */
27
+ export const SAMPLE_PDF_DATA_URI = `data:application/pdf;base64,${SAMPLE_PDF_BASE64}`;
@@ -0,0 +1,30 @@
1
+ import type { AdapterManifest } from "../../core/types";
2
+ import { PROTOCOL_VERSION } from "../../core/types";
3
+
4
+ /**
5
+ * Eager, data-only. Split from the adapter module so `createDefaultRegistry`
6
+ * can answer "can this be opened, and what controls apply" without pulling
7
+ * pdf.js — a ~1 MB engine — into the entry chunk.
8
+ *
9
+ * `requires` is what the "install this" message names when `pdfjs-dist` is not
10
+ * present: the adapter is registered either way, so the reader is told what is
11
+ * missing instead of being told the file type is unsupported.
12
+ *
13
+ * The only adapter that declares `rect`: a PDF page has a fixed coordinate
14
+ * space, so a producer that knows where a passage sits on the page — an OCR
15
+ * pipeline, a layout-aware chunker — can address it geometrically and skip the
16
+ * text projection entirely.
17
+ */
18
+ export const pdfManifest: AdapterManifest = {
19
+ id: "pdf",
20
+ protocol: PROTOCOL_VERSION,
21
+ extensions: ["pdf"],
22
+ mediaTypes: ["application/pdf"],
23
+ capabilities: {
24
+ pages: true,
25
+ zoom: true,
26
+ text: true,
27
+ highlight: ["quote", "range", "rect"],
28
+ },
29
+ requires: ["pdfjs-dist"],
30
+ };
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ResolvedHighlight } from "../../core/highlight";
4
+ import { toMarkRanges } from "../../core/highlight-marks";
5
+ import { createTextIndexBuilder } from "../../core/text-index";
6
+ import {
7
+ PDF_PAGE_SEPARATOR,
8
+ pageOfHighlight,
9
+ pageTextChunk,
10
+ rangeBoxes,
11
+ rectBoxes,
12
+ type PdfTextSpan,
13
+ } from "./pdf-text";
14
+
15
+ const ITEMS = [{ str: "Alpha" }, { str: "" }, { str: "Beta" }];
16
+
17
+ /** The geometry the renderer derives from those items, at scale 1. */
18
+ const SPANS: PdfTextSpan[] = [
19
+ { text: "Alpha", left: 72, top: 80, width: 40, fontSize: 12, item: 0 },
20
+ { text: "Beta", left: 72, top: 100, width: 32, fontSize: 12, item: 2 },
21
+ ];
22
+
23
+ function resolved(over: Partial<ResolvedHighlight> = {}): ResolvedHighlight {
24
+ return {
25
+ id: "c1",
26
+ source: "citation",
27
+ status: "resolved",
28
+ address: { kind: "range", start: 0, end: 5 },
29
+ active: false,
30
+ ...over,
31
+ };
32
+ }
33
+
34
+ describe("pageTextChunk", () => {
35
+ it("joins items the way the projection does and records where each one landed", () => {
36
+ const chunk = pageTextChunk(ITEMS);
37
+ expect(chunk.text).toBe("Alpha Beta");
38
+ expect(chunk.spans).toEqual([
39
+ { start: 0, end: 5 },
40
+ { start: 6, end: 6 },
41
+ { start: 7, end: 11 },
42
+ ]);
43
+ });
44
+
45
+ it("keeps empty items in place, so no following offset shifts", () => {
46
+ // The empty item still consumes its separator. Dropping it would pull "Beta"
47
+ // back by one character and put every mark on this page one glyph off.
48
+ const chunk = pageTextChunk(ITEMS);
49
+ expect(chunk.text.slice(7, 11)).toBe("Beta");
50
+ expect(chunk.spans).toHaveLength(ITEMS.length);
51
+ });
52
+
53
+ it("agrees with the projection the loader builds from the same call", () => {
54
+ const builder = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });
55
+ builder.push(pageTextChunk(ITEMS).text, 1);
56
+ builder.push(pageTextChunk([{ str: "Gamma" }]).text, 2);
57
+ const index = builder.build();
58
+
59
+ expect(index.text).toBe("Alpha Beta\n\nGamma");
60
+ // Page 2 begins after the page separator — the offset a citation on that
61
+ // page is expressed against.
62
+ expect(index.spans[1]).toEqual({ start: 13, end: 18, ref: 2 });
63
+ });
64
+ });
65
+
66
+ describe("rangeBoxes", () => {
67
+ const chunk = pageTextChunk(ITEMS);
68
+
69
+ it("puts a box over the item the range lands in", () => {
70
+ const marks = toMarkRanges([resolved({ range: [7, 11] })], 11);
71
+ expect(rangeBoxes(SPANS, chunk, 0, marks)).toEqual([
72
+ { left: 72, top: 100, width: 32, height: 12, active: false },
73
+ ]);
74
+ });
75
+
76
+ it("cuts a partial match proportionally rather than lighting the whole run", () => {
77
+ // "Al" of "Alpha": two of five characters of a 40-wide item.
78
+ const marks = toMarkRanges([resolved({ range: [0, 2] })], 11);
79
+ const [box] = rangeBoxes(SPANS, chunk, 0, marks);
80
+ expect(box?.left).toBe(72);
81
+ expect(box?.width).toBe(16);
82
+ });
83
+
84
+ it("offsets by where the page starts in the projection", () => {
85
+ // Same page, but it is the second one: its chunk begins at 13.
86
+ const marks = toMarkRanges([resolved({ range: [20, 24] })], 24);
87
+ const [box] = rangeBoxes(SPANS, chunk, 13, marks);
88
+ expect(box?.top).toBe(100);
89
+ });
90
+
91
+ it("marks the box the viewer is pointed at, and only that one", () => {
92
+ const marks = toMarkRanges(
93
+ [resolved({ id: "a", range: [0, 5] }), resolved({ id: "b", range: [7, 11], active: true })],
94
+ 11,
95
+ );
96
+ expect(rangeBoxes(SPANS, chunk, 0, marks).map((box) => box.active)).toEqual([false, true]);
97
+ });
98
+
99
+ it("falls back to an em estimate when pdf.js reported no width", () => {
100
+ const spans: PdfTextSpan[] = [{ ...(SPANS[0] as PdfTextSpan), width: 0 }];
101
+ const marks = toMarkRanges([resolved({ range: [0, 5] })], 11);
102
+ // 5 characters × half of a 12pt em.
103
+ expect(rangeBoxes(spans, chunk, 0, marks)[0]?.width).toBe(30);
104
+ });
105
+
106
+ it("paints nothing when the marks are on another page", () => {
107
+ const marks = toMarkRanges([resolved({ range: [40, 44] })], 60);
108
+ expect(rangeBoxes(SPANS, chunk, 0, marks)).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe("rectBoxes", () => {
113
+ const size = { width: 600, height: 800 };
114
+
115
+ it("scales fractions to the page's CURRENT rendered size", () => {
116
+ const highlight = resolved({
117
+ address: { kind: "rect", page: 1, rects: [{ x: 0.1, y: 0.2, width: 0.5, height: 0.05 }] },
118
+ rects: [{ x: 0.1, y: 0.2, width: 0.5, height: 0.05 }],
119
+ });
120
+ expect(rectBoxes([highlight], size)).toEqual([
121
+ { left: 60, top: 160, width: 300, height: 40, active: false },
122
+ ]);
123
+ });
124
+
125
+ it("clamps a foreign document's geometry to the page", () => {
126
+ const rects = [{ x: -1, y: 2, width: 5, height: Number.NaN }];
127
+ const highlight = resolved({ address: { kind: "rect", page: 1, rects }, rects });
128
+ expect(rectBoxes([highlight], size)).toEqual([
129
+ { left: 0, top: 800, width: 600, height: 0, active: false },
130
+ ]);
131
+ });
132
+
133
+ it("draws one box per rect, because a chunk can wrap a column break", () => {
134
+ const rects = [
135
+ { x: 0, y: 0, width: 0.5, height: 0.1 },
136
+ { x: 0.5, y: 0.5, width: 0.5, height: 0.1 },
137
+ ];
138
+ const highlight = resolved({
139
+ address: { kind: "rect", page: 1, rects },
140
+ rects,
141
+ active: true,
142
+ });
143
+ const boxes = rectBoxes([highlight], size);
144
+ expect(boxes).toHaveLength(2);
145
+ expect(boxes.every((box) => box.active)).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("pageOfHighlight", () => {
150
+ const index = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });
151
+ index.push("Alpha", 1);
152
+ index.push("Gamma", 2);
153
+ const built = index.build();
154
+
155
+ it("prefers what the caller said", () => {
156
+ expect(pageOfHighlight(resolved({ page: 4, range: [0, 5] }), built)).toBe(4);
157
+ });
158
+
159
+ it("looks a located range up through the document's own index", () => {
160
+ expect(pageOfHighlight(resolved({ range: [7, 12] }), built)).toBe(2);
161
+ });
162
+
163
+ it("has no answer for a range on a separator, so the pager stays put", () => {
164
+ expect(pageOfHighlight(resolved({ range: [5, 6] }), built)).toBeUndefined();
165
+ });
166
+
167
+ it("has no answer for a highlight that never resolved", () => {
168
+ expect(pageOfHighlight(resolved({ status: "not-found" }), built)).toBeUndefined();
169
+ expect(pageOfHighlight(undefined, built)).toBeUndefined();
170
+ });
171
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * PDF text ↔ page geometry — the MAP half of the highlight funnel for a raster.
3
+ *
4
+ * A PDF page is pixels. There is no DOM text to wrap in a `<mark>`, so the paint
5
+ * step here is a layer of BOXES over the canvas, and this module is what turns
6
+ * "characters 812–847 of the projection" into those boxes.
7
+ *
8
+ * ## Why the projection is rebuilt rather than remembered
9
+ *
10
+ * `AdapterDocument.text` is assembled at LOAD time, one page at a time; the
11
+ * renderer re-fetches the current page's text content anyway (it needs the same
12
+ * items to position the selectable text layer). So both sides call
13
+ * {@link pageTextChunk}, and the projection and the geometry agree by
14
+ * construction instead of by two hand-kept copies of the same join rule.
15
+ *
16
+ * ## Why the boxes are proportional, and what that costs
17
+ *
18
+ * pdf.js reports one box per text ITEM — a run of glyphs — and no per-character
19
+ * geometry. When a range covers only part of an item, the box is cut
20
+ * proportionally by character count. That is a linear approximation of a
21
+ * proportional font, so it can be off by a glyph or two inside a long run; PDF
22
+ * items are usually a word or a line fragment, where the error is invisible.
23
+ *
24
+ * The alternative — DOM ranges over the transparent text layer — would be WORSE
25
+ * here, not better: that layer renders in a generic font at pdf.js's transform,
26
+ * so its glyph advances do not match the raster underneath it at all. The item
27
+ * box comes from the file itself and does.
28
+ */
29
+
30
+ import type { DocumentRect } from "@elabs-ai/components-ui";
31
+
32
+ import type { ResolvedHighlight } from "../../core/highlight";
33
+ import { localizeRanges, type MarkRanges } from "../../core/highlight-marks";
34
+ import { spanAt, type TextIndex } from "../../core/text-index";
35
+
36
+ /** Written between two text items of the same page. */
37
+ export const PDF_ITEM_SEPARATOR = " ";
38
+
39
+ /** Written between two pages of the projection. */
40
+ export const PDF_PAGE_SEPARATOR = "\n\n";
41
+
42
+ /** The part of a pdf.js text item this module needs. */
43
+ export interface PdfTextItemLike {
44
+ str: string;
45
+ }
46
+
47
+ /** Half-open `[start, end)` into the page's own chunk, one per item. */
48
+ export interface PdfItemSpan {
49
+ start: number;
50
+ end: number;
51
+ }
52
+
53
+ /** One page's contribution to the projection, plus where each item landed in it. */
54
+ export interface PdfPageChunk {
55
+ text: string;
56
+ /** Index-aligned with the items passed in — including the empty ones. */
57
+ spans: readonly PdfItemSpan[];
58
+ }
59
+
60
+ /**
61
+ * Join a page's text items the way the projection does, and record each item's
62
+ * offsets.
63
+ *
64
+ * Empty items keep their place: they still consume a separator, so an item's
65
+ * index into this array is the same index pdf.js handed back. Dropping them
66
+ * would shift every following offset by one and put every mark a character off.
67
+ */
68
+ export function pageTextChunk(items: readonly PdfTextItemLike[]): PdfPageChunk {
69
+ const spans: PdfItemSpan[] = [];
70
+ const parts: string[] = [];
71
+ let cursor = 0;
72
+
73
+ items.forEach((item, index) => {
74
+ if (index > 0) cursor += PDF_ITEM_SEPARATOR.length;
75
+ parts.push(item.str);
76
+ spans.push({ start: cursor, end: cursor + item.str.length });
77
+ cursor += item.str.length;
78
+ });
79
+
80
+ return { text: parts.join(PDF_ITEM_SEPARATOR), spans };
81
+ }
82
+
83
+ /** A positioned text item, in CSS pixels relative to the rendered page. */
84
+ export interface PdfTextSpan {
85
+ text: string;
86
+ left: number;
87
+ top: number;
88
+ /** The item's own advance width. `0` when pdf.js did not report one. */
89
+ width: number;
90
+ fontSize: number;
91
+ /** Index into the page's original items — the seam back to the projection. */
92
+ item: number;
93
+ }
94
+
95
+ /** A rectangle to paint over the page, in CSS pixels. */
96
+ export interface PdfHighlightBox {
97
+ left: number;
98
+ top: number;
99
+ width: number;
100
+ height: number;
101
+ /** Whether this box is the highlight the viewer is currently pointed at. */
102
+ active: boolean;
103
+ }
104
+
105
+ /**
106
+ * Fallback advance per character when pdf.js reports no width.
107
+ *
108
+ * Half an em is the rough mean advance of Latin text; it only ever decides how
109
+ * wide a box is drawn, never where the reader is taken.
110
+ */
111
+ const FALLBACK_ADVANCE = 0.5;
112
+
113
+ /**
114
+ * Boxes for every marked range that falls on this page.
115
+ *
116
+ * `pageStart` is where this page's chunk begins in the projection, which is what
117
+ * makes a whole-document offset comparable with a per-page item offset.
118
+ */
119
+ export function rangeBoxes(
120
+ spans: readonly PdfTextSpan[],
121
+ chunk: PdfPageChunk,
122
+ pageStart: number,
123
+ marks: MarkRanges,
124
+ ): PdfHighlightBox[] {
125
+ if (marks.ranges.length === 0) return [];
126
+
127
+ const boxes: PdfHighlightBox[] = [];
128
+ for (const span of spans) {
129
+ const item = chunk.spans[span.item];
130
+ if (!item || item.end === item.start) continue;
131
+
132
+ const local = localizeRanges(marks, pageStart + item.start, pageStart + item.end);
133
+ if (local.ranges.length === 0) continue;
134
+
135
+ const length = item.end - item.start;
136
+ const advance = span.width > 0 ? span.width / length : span.fontSize * FALLBACK_ADVANCE;
137
+ local.ranges.forEach(([from, to], index) => {
138
+ boxes.push({
139
+ left: span.left + advance * from,
140
+ top: span.top,
141
+ width: advance * (to - from),
142
+ height: span.fontSize,
143
+ active: index === local.activeIndex,
144
+ });
145
+ });
146
+ }
147
+ return boxes;
148
+ }
149
+
150
+ /**
151
+ * Boxes for `rect` addresses, whose geometry is already the answer.
152
+ *
153
+ * Fractions rather than pixels is what makes them survive zoom and a resize: the
154
+ * same address is correct at 50% and at 300% because it is multiplied by the
155
+ * page's CURRENT rendered size every time.
156
+ */
157
+ export function rectBoxes(
158
+ highlights: readonly ResolvedHighlight[],
159
+ size: { width: number; height: number },
160
+ ): PdfHighlightBox[] {
161
+ const boxes: PdfHighlightBox[] = [];
162
+ for (const highlight of highlights) {
163
+ for (const rect of highlight.rects ?? []) {
164
+ boxes.push({
165
+ left: clampFraction(rect.x) * size.width,
166
+ top: clampFraction(rect.y) * size.height,
167
+ width: clampFraction(rect.width) * size.width,
168
+ height: clampFraction(rect.height) * size.height,
169
+ active: highlight.active,
170
+ });
171
+ }
172
+ }
173
+ return boxes;
174
+ }
175
+
176
+ /** Keep a foreign document's geometry inside the page it claims to be on. */
177
+ function clampFraction(value: DocumentRect["x"]): number {
178
+ if (!Number.isFinite(value)) return 0;
179
+ return Math.max(0, Math.min(1, value));
180
+ }
181
+
182
+ /**
183
+ * Which page a highlight is on, so activating it can turn to that page.
184
+ *
185
+ * A `rect` carries its own page — geometry has no range to look up. Everything
186
+ * else is looked up through the document's own index, because the ref type is the
187
+ * adapter's business and this adapter's is the page number. A caller's page HINT
188
+ * is deliberately not consulted: it is the resolver's job to leave it off
189
+ * (`ResolvedHighlight.page`), since a stale hint would turn to a page the mark is
190
+ * not on. A range landing on a page separator, or past the extraction cap, has no
191
+ * page and the pager stays put rather than jumping somewhere arbitrary.
192
+ */
193
+ export function pageOfHighlight(
194
+ highlight: ResolvedHighlight | undefined,
195
+ index: TextIndex<unknown> | undefined,
196
+ ): number | undefined {
197
+ if (!highlight || highlight.status !== "resolved") return undefined;
198
+ if (highlight.page !== undefined) return highlight.page;
199
+ if (!highlight.range || !index) return undefined;
200
+ const span = spanAt(index, highlight.range[0]);
201
+ return typeof span?.ref === "number" ? span.ref : undefined;
202
+ }