@printwithsynergy/lens-pdf 0.3.0-beta.81
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 +661 -0
- package/README.md +344 -0
- package/dist/browser/codexOverlay.d.ts +109 -0
- package/dist/browser/codexOverlay.d.ts.map +1 -0
- package/dist/browser/codexOverlay.js +256 -0
- package/dist/browser/codexOverlay.js.map +1 -0
- package/dist/browser/constants.d.ts +13 -0
- package/dist/browser/constants.d.ts.map +1 -0
- package/dist/browser/constants.js +13 -0
- package/dist/browser/constants.js.map +1 -0
- package/dist/browser/index.d.ts +211 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +1190 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/pantone-gold.d.ts +59 -0
- package/dist/browser/pantone-gold.d.ts.map +1 -0
- package/dist/browser/pantone-gold.js +237 -0
- package/dist/browser/pantone-gold.js.map +1 -0
- package/dist/components/AnnotationCanvas.d.ts +27 -0
- package/dist/components/AnnotationCanvas.d.ts.map +1 -0
- package/dist/components/AnnotationCanvas.js +401 -0
- package/dist/components/AnnotationCanvas.js.map +1 -0
- package/dist/components/AnnotationNotesPanel.d.ts +15 -0
- package/dist/components/AnnotationNotesPanel.d.ts.map +1 -0
- package/dist/components/AnnotationNotesPanel.js +235 -0
- package/dist/components/AnnotationNotesPanel.js.map +1 -0
- package/dist/components/AnnotationThread.d.ts +18 -0
- package/dist/components/AnnotationThread.d.ts.map +1 -0
- package/dist/components/AnnotationThread.js +163 -0
- package/dist/components/AnnotationThread.js.map +1 -0
- package/dist/components/AnnotationToolbar.d.ts +39 -0
- package/dist/components/AnnotationToolbar.d.ts.map +1 -0
- package/dist/components/AnnotationToolbar.js +258 -0
- package/dist/components/AnnotationToolbar.js.map +1 -0
- package/dist/components/BoxOverlay.d.ts +20 -0
- package/dist/components/BoxOverlay.d.ts.map +1 -0
- package/dist/components/BoxOverlay.js +107 -0
- package/dist/components/BoxOverlay.js.map +1 -0
- package/dist/components/ColorPickerTool.d.ts +11 -0
- package/dist/components/ColorPickerTool.d.ts.map +1 -0
- package/dist/components/ColorPickerTool.js +220 -0
- package/dist/components/ColorPickerTool.js.map +1 -0
- package/dist/components/DensitometerTool.d.ts +25 -0
- package/dist/components/DensitometerTool.d.ts.map +1 -0
- package/dist/components/DensitometerTool.js +246 -0
- package/dist/components/DensitometerTool.js.map +1 -0
- package/dist/components/DielineInfoPanel.d.ts +27 -0
- package/dist/components/DielineInfoPanel.d.ts.map +1 -0
- package/dist/components/DielineInfoPanel.js +23 -0
- package/dist/components/DielineInfoPanel.js.map +1 -0
- package/dist/components/DielineOverlay.d.ts +10 -0
- package/dist/components/DielineOverlay.d.ts.map +1 -0
- package/dist/components/DielineOverlay.js +57 -0
- package/dist/components/DielineOverlay.js.map +1 -0
- package/dist/components/FindingsSidebar.d.ts +50 -0
- package/dist/components/FindingsSidebar.d.ts.map +1 -0
- package/dist/components/FindingsSidebar.js +78 -0
- package/dist/components/FindingsSidebar.js.map +1 -0
- package/dist/components/LayerCanvas.d.ts +30 -0
- package/dist/components/LayerCanvas.d.ts.map +1 -0
- package/dist/components/LayerCanvas.js +84 -0
- package/dist/components/LayerCanvas.js.map +1 -0
- package/dist/components/LayerPanel.d.ts +9 -0
- package/dist/components/LayerPanel.d.ts.map +1 -0
- package/dist/components/LayerPanel.js +144 -0
- package/dist/components/LayerPanel.js.map +1 -0
- package/dist/components/LensPDF.d.ts +61 -0
- package/dist/components/LensPDF.d.ts.map +1 -0
- package/dist/components/LensPDF.js +49 -0
- package/dist/components/LensPDF.js.map +1 -0
- package/dist/components/LensPDFDemo.d.ts +160 -0
- package/dist/components/LensPDFDemo.d.ts.map +1 -0
- package/dist/components/LensPDFDemo.js +1060 -0
- package/dist/components/LensPDFDemo.js.map +1 -0
- package/dist/components/LensPDFDemo.styles.d.ts +38 -0
- package/dist/components/LensPDFDemo.styles.d.ts.map +1 -0
- package/dist/components/LensPDFDemo.styles.js +282 -0
- package/dist/components/LensPDFDemo.styles.js.map +1 -0
- package/dist/components/LensPDFViewer.d.ts +79 -0
- package/dist/components/LensPDFViewer.d.ts.map +1 -0
- package/dist/components/LensPDFViewer.js +254 -0
- package/dist/components/LensPDFViewer.js.map +1 -0
- package/dist/components/MeasureTool.d.ts +16 -0
- package/dist/components/MeasureTool.d.ts.map +1 -0
- package/dist/components/MeasureTool.js +137 -0
- package/dist/components/MeasureTool.js.map +1 -0
- package/dist/components/MobileBottomSheet.d.ts +12 -0
- package/dist/components/MobileBottomSheet.d.ts.map +1 -0
- package/dist/components/MobileBottomSheet.js +113 -0
- package/dist/components/MobileBottomSheet.js.map +1 -0
- package/dist/components/MobileDrawer.d.ts +31 -0
- package/dist/components/MobileDrawer.d.ts.map +1 -0
- package/dist/components/MobileDrawer.js +67 -0
- package/dist/components/MobileDrawer.js.map +1 -0
- package/dist/components/PageCanvas.d.ts +33 -0
- package/dist/components/PageCanvas.d.ts.map +1 -0
- package/dist/components/PageCanvas.js +385 -0
- package/dist/components/PageCanvas.js.map +1 -0
- package/dist/components/PageNavigator.d.ts +18 -0
- package/dist/components/PageNavigator.d.ts.map +1 -0
- package/dist/components/PageNavigator.js +44 -0
- package/dist/components/PageNavigator.js.map +1 -0
- package/dist/components/SeparationCanvas.d.ts +12 -0
- package/dist/components/SeparationCanvas.d.ts.map +1 -0
- package/dist/components/SeparationCanvas.js +174 -0
- package/dist/components/SeparationCanvas.js.map +1 -0
- package/dist/components/TACHeatmapOverlay.d.ts +17 -0
- package/dist/components/TACHeatmapOverlay.d.ts.map +1 -0
- package/dist/components/TACHeatmapOverlay.js +119 -0
- package/dist/components/TACHeatmapOverlay.js.map +1 -0
- package/dist/components/ZoomControls.d.ts +11 -0
- package/dist/components/ZoomControls.d.ts.map +1 -0
- package/dist/components/ZoomControls.js +26 -0
- package/dist/components/ZoomControls.js.map +1 -0
- package/dist/components/defaultShellPlugins.d.ts +3 -0
- package/dist/components/defaultShellPlugins.d.ts.map +1 -0
- package/dist/components/defaultShellPlugins.js +273 -0
- package/dist/components/defaultShellPlugins.js.map +1 -0
- package/dist/components/index.d.ts +32 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +32 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/presets.d.ts +8 -0
- package/dist/components/presets.d.ts.map +1 -0
- package/dist/components/presets.js +14 -0
- package/dist/components/presets.js.map +1 -0
- package/dist/components/shellPlugins.d.ts +105 -0
- package/dist/components/shellPlugins.d.ts.map +1 -0
- package/dist/components/shellPlugins.js +52 -0
- package/dist/components/shellPlugins.js.map +1 -0
- package/dist/components/useIsMobile.d.ts +16 -0
- package/dist/components/useIsMobile.d.ts.map +1 -0
- package/dist/components/useIsMobile.js +30 -0
- package/dist/components/useIsMobile.js.map +1 -0
- package/dist/fallback-pdfjs/index.d.ts +60 -0
- package/dist/fallback-pdfjs/index.d.ts.map +1 -0
- package/dist/fallback-pdfjs/index.js +163 -0
- package/dist/fallback-pdfjs/index.js.map +1 -0
- package/dist/host/LensPDFProvider.d.ts +36 -0
- package/dist/host/LensPDFProvider.d.ts.map +1 -0
- package/dist/host/LensPDFProvider.js +12 -0
- package/dist/host/LensPDFProvider.js.map +1 -0
- package/dist/host/index.d.ts +167 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +173 -0
- package/dist/host/index.js.map +1 -0
- package/dist/host/pdfFallback.d.ts +50 -0
- package/dist/host/pdfFallback.d.ts.map +1 -0
- package/dist/host/pdfFallback.js +171 -0
- package/dist/host/pdfFallback.js.map +1 -0
- package/dist/host/pdfValidation.d.ts +45 -0
- package/dist/host/pdfValidation.d.ts.map +1 -0
- package/dist/host/pdfValidation.js +78 -0
- package/dist/host/pdfValidation.js.map +1 -0
- package/dist/host/shareLink.d.ts +80 -0
- package/dist/host/shareLink.d.ts.map +1 -0
- package/dist/host/shareLink.js +114 -0
- package/dist/host/shareLink.js.map +1 -0
- package/dist/host/useLensPDF.d.ts +73 -0
- package/dist/host/useLensPDF.d.ts.map +1 -0
- package/dist/host/useLensPDF.js +213 -0
- package/dist/host/useLensPDF.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin/context.d.ts +70 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +16 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/findings-location.d.ts +53 -0
- package/dist/plugin/findings-location.d.ts.map +1 -0
- package/dist/plugin/findings-location.js +72 -0
- package/dist/plugin/findings-location.js.map +1 -0
- package/dist/plugin/index.d.ts +19 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +16 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/plugin/registry.d.ts +61 -0
- package/dist/plugin/registry.d.ts.map +1 -0
- package/dist/plugin/registry.js +102 -0
- package/dist/plugin/registry.js.map +1 -0
- package/dist/plugin/services.d.ts +380 -0
- package/dist/plugin/services.d.ts.map +1 -0
- package/dist/plugin/services.js +104 -0
- package/dist/plugin/services.js.map +1 -0
- package/dist/plugin/types.d.ts +198 -0
- package/dist/plugin/types.d.ts.map +1 -0
- package/dist/plugin/types.js +24 -0
- package/dist/plugin/types.js.map +1 -0
- package/dist/types/index.d.ts +191 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +95 -0
- package/dist/types/index.js.map +1 -0
- package/dist/units/index.d.ts +64 -0
- package/dist/units/index.d.ts.map +1 -0
- package/dist/units/index.js +98 -0
- package/dist/units/index.js.map +1 -0
- package/docs/architecture.md +90 -0
- package/docs/components.md +569 -0
- package/docs/contributing.md +78 -0
- package/docs/fallback.md +174 -0
- package/docs/lens-pdf-viewer.md +128 -0
- package/docs/licensing.md +78 -0
- package/docs/measurement-units.md +87 -0
- package/docs/plugins.md +256 -0
- package/docs/security.md +69 -0
- package/docs/server.md +212 -0
- package/docs/services.md +210 -0
- package/docs/share-links.md +111 -0
- package/docs/theming.md +164 -0
- package/docs/validation.md +83 -0
- package/package.json +139 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@printwithsynergy/lens-pdf/browser`
|
|
3
|
+
*
|
|
4
|
+
* Browser-only ViewerServices factory. One call gives you a fully
|
|
5
|
+
* wired {@link ViewerServices} instance backed by pdf.js — every
|
|
6
|
+
* tool the package ships (page raster, layers, separations, TAC
|
|
7
|
+
* heatmap, color picker, densitometer, in-browser annotations)
|
|
8
|
+
* works against any PDF the browser can fetch, without a server
|
|
9
|
+
* backend.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createBrowserViewerServices } from "@printwithsynergy/lens-pdf/browser";
|
|
13
|
+
* const services = createBrowserViewerServices({ pdfUrl: "/proofs/abc.pdf" });
|
|
14
|
+
* await services.prepare(1); // pre-warm separations + heatmap + layers for page 1
|
|
15
|
+
* // <ViewerServicesContext.Provider value={services}> ... </Provider>
|
|
16
|
+
* services.dispose(); // free blob URLs / pdf.js doc on unmount
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Server-only features (true ICC separations, preflight findings,
|
|
20
|
+
* server-persisted annotations, PDF report exports) are explicitly
|
|
21
|
+
* left as `markUnwired` no-ops; their components self-hide.
|
|
22
|
+
*
|
|
23
|
+
* The CMYK numbers here are **rich-black approximations**, not ICC.
|
|
24
|
+
* Solid black RGB(0,0,0) reads as C=100, M=100, Y=100, K=80, TAC≈380%
|
|
25
|
+
* — close enough to a press CMYK rich-black to make the TAC heatmap
|
|
26
|
+
* and densitometer behave like their real backends. Production
|
|
27
|
+
* hosts wire a Ghostscript / MuPDF backend for ICC-correct readings.
|
|
28
|
+
*
|
|
29
|
+
* **Security**: this factory fetches whatever URL the host hands it.
|
|
30
|
+
* Sign / scope / expire the URL the same way you would any other PDF
|
|
31
|
+
* download — the viewer is a pure renderer and trusts the host to
|
|
32
|
+
* enforce access control upstream.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
import { useEffect, useState } from "react";
|
|
37
|
+
import * as pdfjs from "pdfjs-dist";
|
|
38
|
+
export { createCodexOverlayServices, extractInksFromColorWorld, extractLayersFromOcgs } from "./codexOverlay.js";
|
|
39
|
+
import { markUnwired, noopI18n, noopTelemetry, } from "../plugin/services.js";
|
|
40
|
+
import { defaultThemeTokens } from "../plugin/services.js";
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/** DPI used for color sampling, separations, TAC heatmap. Higher → more
|
|
45
|
+
* fidelity, more memory; 200 is a good balance for screen review. */
|
|
46
|
+
const ANALYSIS_DPI = 200;
|
|
47
|
+
/**
|
|
48
|
+
* Mobile-safe upper bound on canvas pixel area for the analysis raster
|
|
49
|
+
* and its derivative channels. iOS Safari documents a 16,777,216-pixel
|
|
50
|
+
* canvas budget per process; with 4+ derivative canvases (CMYK plates
|
|
51
|
+
* + spots + TAC + page) sharing it, anything above ~12 MP per canvas
|
|
52
|
+
* starts returning `null` from `toBlob`. Large-format pages
|
|
53
|
+
* (poster / packaging dielines) get scaled down here so analysis still
|
|
54
|
+
* runs — host-driven page rasters in `buildPageUrl` are unaffected.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_ANALYSIS_CANVAS_PIXELS = 12_000_000;
|
|
57
|
+
/**
|
|
58
|
+
* "Rich black" K factor. Press rich blacks land somewhere between
|
|
59
|
+
* 60 % and 100 % K with the rest filled in by C/M/Y; 0.8 gives
|
|
60
|
+
* realistic densitometer readings and a TAC heatmap that actually
|
|
61
|
+
* trips on solid-black artwork (TAC ≈ 380 % on RGB(0,0,0)).
|
|
62
|
+
*/
|
|
63
|
+
const K_FACTOR = 0.8;
|
|
64
|
+
export { PROCESS_CHANNELS } from "./constants.js";
|
|
65
|
+
import { PROCESS_CHANNELS } from "./constants.js";
|
|
66
|
+
export { pantoneGoldLookup, processPlateLookup, resolveSpotSwatch, rgbToHex, } from "./pantone-gold.js";
|
|
67
|
+
/**
|
|
68
|
+
* Default URL for the pdf.js worker, served via unpkg pinned to the
|
|
69
|
+
* exact `pdfjs-dist` version this package was built against. Hosts
|
|
70
|
+
* that don't want a runtime CDN dep can override via the
|
|
71
|
+
* `workerSrc` option, set `pdfjs.GlobalWorkerOptions.workerSrc`
|
|
72
|
+
* directly before constructing the services, or self-host the file.
|
|
73
|
+
*
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
export const defaultBrowserWorkerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
|
77
|
+
/**
|
|
78
|
+
* Extract OCG ids from a pdf.js {@link OptionalContentConfig}. pdf.js
|
|
79
|
+
* v4's `getGroups()` returns either:
|
|
80
|
+
* - `null` when the document has no OCGs
|
|
81
|
+
* - an Object literal keyed by ref id — `{ "10R": OptionalContentGroup, … }`
|
|
82
|
+
*
|
|
83
|
+
* Older / future versions might return a Map, so this helper accepts
|
|
84
|
+
* both shapes. Falls through to `getOrder()` and per-id `getGroup()`
|
|
85
|
+
* lookups when the primary path returns nothing — some PDFs only
|
|
86
|
+
* surface their OCG list via the order tree even when groups exist.
|
|
87
|
+
*/
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
function extractOcgIds(config) {
|
|
90
|
+
if (!config)
|
|
91
|
+
return [];
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = typeof config.getGroups === "function" ? config.getGroups() : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
raw = null;
|
|
98
|
+
}
|
|
99
|
+
if (raw instanceof Map) {
|
|
100
|
+
return Array.from(raw.keys()).map(String);
|
|
101
|
+
}
|
|
102
|
+
if (raw && typeof raw === "object") {
|
|
103
|
+
const ids = Object.keys(raw);
|
|
104
|
+
if (ids.length > 0)
|
|
105
|
+
return ids;
|
|
106
|
+
}
|
|
107
|
+
// Fallback: walk the order tree and collect every leaf id, in case
|
|
108
|
+
// `getGroups()` returned an empty object even though the doc declares
|
|
109
|
+
// OCGs through `/OCProperties /D /Order`.
|
|
110
|
+
try {
|
|
111
|
+
const order = typeof config.getOrder === "function" ? config.getOrder() : null;
|
|
112
|
+
if (Array.isArray(order)) {
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const visit = (node) => {
|
|
115
|
+
if (typeof node === "string") {
|
|
116
|
+
seen.add(node);
|
|
117
|
+
}
|
|
118
|
+
else if (Array.isArray(node)) {
|
|
119
|
+
node.forEach(visit);
|
|
120
|
+
}
|
|
121
|
+
else if (node && typeof node === "object") {
|
|
122
|
+
const o = node;
|
|
123
|
+
if (Array.isArray(o.order))
|
|
124
|
+
o.order.forEach(visit);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
order.forEach(visit);
|
|
128
|
+
return Array.from(seen);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
/** 1×1 transparent PNG — returned by `getPageImageUrl` while a tile
|
|
137
|
+
* is still being lazy-built. Page raster tiles are reactive (the
|
|
138
|
+
* PageCanvas re-renders on `notify`), so a placeholder bridges the
|
|
139
|
+
* brief gap until the real URL arrives. The OTHER URL builders
|
|
140
|
+
* (channel / heatmap / layer) are NOT placeholdered — those are
|
|
141
|
+
* consumed by canvases that cache the first loaded image and never
|
|
142
|
+
* retry, so a placeholder there would stick forever. Hosts must
|
|
143
|
+
* call `prepare(pageNum)` to pre-build them. */
|
|
144
|
+
const PLACEHOLDER_PNG = "data:image/png;base64," +
|
|
145
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// CMYK approximation
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
/**
|
|
150
|
+
* Convert an sRGB triplet to a CMYK approximation. Uses an additive
|
|
151
|
+
* decomposition with a "rich-black" K plate:
|
|
152
|
+
*
|
|
153
|
+
* C = 1 - R/255
|
|
154
|
+
* M = 1 - G/255
|
|
155
|
+
* Y = 1 - B/255
|
|
156
|
+
* K = min(C, M, Y) × {@link K_FACTOR}
|
|
157
|
+
* TAC = (C + M + Y + K) × 100 // [0, 380]
|
|
158
|
+
*
|
|
159
|
+
* Returns each ink in [0, 1] and total area coverage as a percentage
|
|
160
|
+
* in [0, 380].
|
|
161
|
+
*
|
|
162
|
+
* This is intentionally NOT a GCR substitution — keeping the full
|
|
163
|
+
* C/M/Y values lets the TAC heatmap actually trip on solid-black
|
|
164
|
+
* artwork instead of bottoming out at 100 % from K alone, which is
|
|
165
|
+
* the behaviour print-prepress reviewers expect.
|
|
166
|
+
*
|
|
167
|
+
* @public
|
|
168
|
+
*/
|
|
169
|
+
export function rgbToCmyk(r, g, b) {
|
|
170
|
+
const c = 1 - r / 255;
|
|
171
|
+
const m = 1 - g / 255;
|
|
172
|
+
const y = 1 - b / 255;
|
|
173
|
+
const k = Math.min(c, m, y) * K_FACTOR;
|
|
174
|
+
return { c, m, y, k, tac: (c + m + y + k) * 100 };
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Spot-ink detection (raw-bytes regex)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
const PROCESS_INK_RGB = {
|
|
180
|
+
cyan: [0, 174, 239],
|
|
181
|
+
magenta: [236, 0, 140],
|
|
182
|
+
yellow: [255, 242, 0],
|
|
183
|
+
black: [35, 31, 32],
|
|
184
|
+
c: [0, 174, 239],
|
|
185
|
+
m: [236, 0, 140],
|
|
186
|
+
y: [255, 242, 0],
|
|
187
|
+
k: [35, 31, 32],
|
|
188
|
+
};
|
|
189
|
+
const PROCESS_NAME_SET = new Set(Object.keys(PROCESS_INK_RGB));
|
|
190
|
+
/**
|
|
191
|
+
* Lookup table for common spot-ink families. Match is a case-insensitive
|
|
192
|
+
* `includes` against the parsed colour name; first hit wins. We're not
|
|
193
|
+
* trying to be a Pantone library — just enough so the densitometer
|
|
194
|
+
* reads sane swatches for ubiquitous spots ("Reflex Blue",
|
|
195
|
+
* "Pantone 185 C", "Warm Red", etc.) instead of falling through to the
|
|
196
|
+
* hash-derived RGB fallback.
|
|
197
|
+
*/
|
|
198
|
+
const SPOT_NAME_RGB = [
|
|
199
|
+
{ pattern: /reflex\s*blue/i, rgb: [0, 32, 156] },
|
|
200
|
+
{ pattern: /process\s*blue/i, rgb: [0, 133, 202] },
|
|
201
|
+
{ pattern: /rhodamine/i, rgb: [225, 35, 135] },
|
|
202
|
+
{ pattern: /rubine/i, rgb: [206, 9, 73] },
|
|
203
|
+
{ pattern: /warm\s*red/i, rgb: [232, 65, 24] },
|
|
204
|
+
{ pattern: /orange\s*021/i, rgb: [254, 80, 0] },
|
|
205
|
+
{ pattern: /violet/i, rgb: [144, 0, 255] },
|
|
206
|
+
{ pattern: /green/i, rgb: [0, 169, 92] },
|
|
207
|
+
{ pattern: /\bred\b/i, rgb: [237, 28, 36] },
|
|
208
|
+
{ pattern: /\bblue\b/i, rgb: [44, 62, 147] },
|
|
209
|
+
{ pattern: /\bsilver\b/i, rgb: [201, 199, 196] },
|
|
210
|
+
{ pattern: /\bgold\b/i, rgb: [212, 175, 55] },
|
|
211
|
+
{ pattern: /\bwhite\b/i, rgb: [240, 240, 240] },
|
|
212
|
+
{ pattern: /varnish|gloss|matte/i, rgb: [180, 195, 210] },
|
|
213
|
+
{ pattern: /die\s*line|dieline|cut\s*contour/i, rgb: [240, 30, 30] },
|
|
214
|
+
{ pattern: /pantone\s*185/i, rgb: [232, 17, 45] },
|
|
215
|
+
{ pattern: /pantone\s*186/i, rgb: [206, 17, 38] },
|
|
216
|
+
{ pattern: /pantone\s*286/i, rgb: [0, 51, 160] },
|
|
217
|
+
{ pattern: /pantone\s*287/i, rgb: [0, 56, 168] },
|
|
218
|
+
{ pattern: /pantone\s*348/i, rgb: [0, 132, 61] },
|
|
219
|
+
{ pattern: /pantone\s*485/i, rgb: [218, 41, 28] },
|
|
220
|
+
{ pattern: /pantone\s*7406/i, rgb: [241, 196, 15] },
|
|
221
|
+
{ pattern: /pantone\s*877/i, rgb: [201, 199, 196] },
|
|
222
|
+
];
|
|
223
|
+
/** Decode PDF name-object encoding (`#XX` hex → byte). */
|
|
224
|
+
function decodePdfName(raw) {
|
|
225
|
+
return raw.replace(/#([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
226
|
+
}
|
|
227
|
+
function spotNameToRgb(name) {
|
|
228
|
+
for (const { pattern, rgb } of SPOT_NAME_RGB) {
|
|
229
|
+
if (pattern.test(name))
|
|
230
|
+
return rgb;
|
|
231
|
+
}
|
|
232
|
+
// Hash-derived fallback so each spot gets a stable, distinct swatch.
|
|
233
|
+
let h = 0;
|
|
234
|
+
for (let i = 0; i < name.length; i++) {
|
|
235
|
+
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
236
|
+
}
|
|
237
|
+
const hue = h % 360;
|
|
238
|
+
const sat = 0.7;
|
|
239
|
+
const light = 0.45;
|
|
240
|
+
return hslToRgb(hue, sat, light);
|
|
241
|
+
}
|
|
242
|
+
function hslToRgb(h, s, l) {
|
|
243
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
244
|
+
const hh = h / 60;
|
|
245
|
+
const x = c * (1 - Math.abs((hh % 2) - 1));
|
|
246
|
+
let r1 = 0, g1 = 0, b1 = 0;
|
|
247
|
+
if (hh < 1)
|
|
248
|
+
[r1, g1, b1] = [c, x, 0];
|
|
249
|
+
else if (hh < 2)
|
|
250
|
+
[r1, g1, b1] = [x, c, 0];
|
|
251
|
+
else if (hh < 3)
|
|
252
|
+
[r1, g1, b1] = [0, c, x];
|
|
253
|
+
else if (hh < 4)
|
|
254
|
+
[r1, g1, b1] = [0, x, c];
|
|
255
|
+
else if (hh < 5)
|
|
256
|
+
[r1, g1, b1] = [x, 0, c];
|
|
257
|
+
else
|
|
258
|
+
[r1, g1, b1] = [c, 0, x];
|
|
259
|
+
const m = l - c / 2;
|
|
260
|
+
return [
|
|
261
|
+
Math.round((r1 + m) * 255),
|
|
262
|
+
Math.round((g1 + m) * 255),
|
|
263
|
+
Math.round((b1 + m) * 255),
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Best-effort spot-ink detector. Scans the raw PDF bytes (latin-1
|
|
268
|
+
* decoded) for `/Separation /Name …` and `/DeviceN [/N1 /N2 …]`
|
|
269
|
+
* declarations. Misses spots inside compressed object streams — for
|
|
270
|
+
* those the densitometer falls back to "process only" with no harm
|
|
271
|
+
* done. Hosts that need 100 % accurate ink lists wire a backend.
|
|
272
|
+
*
|
|
273
|
+
* @public
|
|
274
|
+
*/
|
|
275
|
+
export function detectSpotInksFromPdfBytes(bytes) {
|
|
276
|
+
const text = new TextDecoder("latin1").decode(bytes);
|
|
277
|
+
const found = new Map();
|
|
278
|
+
// /Separation /Name AltColorSpace TintTransform
|
|
279
|
+
const sepRe = /\/Separation\s*\/([A-Za-z0-9_#%\-\+\*\(\)\.]+)\s+\/(?:DeviceCMYK|DeviceRGB|DeviceGray|CalRGB|CalGray|Lab)/g;
|
|
280
|
+
let m;
|
|
281
|
+
while ((m = sepRe.exec(text)) !== null) {
|
|
282
|
+
const raw = m[1];
|
|
283
|
+
if (!raw)
|
|
284
|
+
continue;
|
|
285
|
+
const name = decodePdfName(raw).trim();
|
|
286
|
+
const lower = name.toLowerCase();
|
|
287
|
+
if (PROCESS_NAME_SET.has(lower) || lower === "all" || lower === "none")
|
|
288
|
+
continue;
|
|
289
|
+
if (!found.has(lower)) {
|
|
290
|
+
found.set(lower, { name, type: "spot", altRgb: spotNameToRgb(name) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// /DeviceN [ /Name1 /Name2 ... ] /AltCS TintTransform
|
|
294
|
+
const dnRe = /\/DeviceN\s*\[([^\]]{1,500})\]/g;
|
|
295
|
+
while ((m = dnRe.exec(text)) !== null) {
|
|
296
|
+
const arr = m[1] ?? "";
|
|
297
|
+
const inkRe = /\/([A-Za-z0-9_#%\-\+\*\(\)\.]+)/g;
|
|
298
|
+
let im;
|
|
299
|
+
while ((im = inkRe.exec(arr)) !== null) {
|
|
300
|
+
const raw = im[1];
|
|
301
|
+
if (!raw)
|
|
302
|
+
continue;
|
|
303
|
+
const name = decodePdfName(raw).trim();
|
|
304
|
+
const lower = name.toLowerCase();
|
|
305
|
+
if (PROCESS_NAME_SET.has(lower) || lower === "all" || lower === "none")
|
|
306
|
+
continue;
|
|
307
|
+
if (!found.has(lower)) {
|
|
308
|
+
found.set(lower, { name, type: "spot", altRgb: spotNameToRgb(name) });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return Array.from(found.values());
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Estimate ink coverage at a sampled pixel using cosine similarity in
|
|
316
|
+
* subtractive (paper-white → ink) space.
|
|
317
|
+
*
|
|
318
|
+
* - Pixel exactly equal to paper white → 0 % coverage.
|
|
319
|
+
* - Pixel exactly equal to the ink's alternate sRGB → 100 % coverage.
|
|
320
|
+
* - Pixel pointing in a different hue direction (e.g. cyan area asked
|
|
321
|
+
* "how much Pantone 185?") → 0 % via the cosine cutoff.
|
|
322
|
+
*
|
|
323
|
+
* Returns ink amount in [0, 1]. Heuristic — RGB doesn't carry enough
|
|
324
|
+
* information to recover true colorant coverage, so this only fires
|
|
325
|
+
* when the pixel really matches the ink's hue.
|
|
326
|
+
*/
|
|
327
|
+
function estimateInkCoverage(r, g, b, altR, altG, altB) {
|
|
328
|
+
const dpR = (255 - r) / 255;
|
|
329
|
+
const dpG = (255 - g) / 255;
|
|
330
|
+
const dpB = (255 - b) / 255;
|
|
331
|
+
const daR = (255 - altR) / 255;
|
|
332
|
+
const daG = (255 - altG) / 255;
|
|
333
|
+
const daB = (255 - altB) / 255;
|
|
334
|
+
const magP = Math.hypot(dpR, dpG, dpB);
|
|
335
|
+
const magA = Math.hypot(daR, daG, daB);
|
|
336
|
+
if (magP < 1e-4 || magA < 1e-4)
|
|
337
|
+
return 0;
|
|
338
|
+
const cos = (dpR * daR + dpG * daG + dpB * daB) / (magP * magA);
|
|
339
|
+
if (cos < 0.92)
|
|
340
|
+
return 0;
|
|
341
|
+
return Math.max(0, Math.min(1, magP / magA));
|
|
342
|
+
}
|
|
343
|
+
async function rasterizeBlobUrl(width, height, fill) {
|
|
344
|
+
const canvas = document.createElement("canvas");
|
|
345
|
+
canvas.width = width;
|
|
346
|
+
canvas.height = height;
|
|
347
|
+
const ctx = canvas.getContext("2d");
|
|
348
|
+
if (!ctx)
|
|
349
|
+
throw new Error("[lens-pdf] 2D context unavailable.");
|
|
350
|
+
const out = ctx.createImageData(width, height);
|
|
351
|
+
const total = width * height;
|
|
352
|
+
for (let p = 0; p < total; p++) {
|
|
353
|
+
const i = p * 4;
|
|
354
|
+
const [r, g, b, a] = fill(i);
|
|
355
|
+
out.data[i] = r;
|
|
356
|
+
out.data[i + 1] = g;
|
|
357
|
+
out.data[i + 2] = b;
|
|
358
|
+
out.data[i + 3] = a;
|
|
359
|
+
}
|
|
360
|
+
ctx.putImageData(out, 0, 0);
|
|
361
|
+
const blob = await canvasToPngBlob(canvas, "rasterizeBlobUrl");
|
|
362
|
+
return URL.createObjectURL(blob);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Encode a canvas to a PNG blob. iOS Safari intermittently returns
|
|
366
|
+
* `null` from `canvas.toBlob` for large or memory-pressured canvases
|
|
367
|
+
* even when `toDataURL` succeeds, so we fall back to the synchronous
|
|
368
|
+
* data-URL path before giving up.
|
|
369
|
+
*/
|
|
370
|
+
async function canvasToPngBlob(canvas, caller) {
|
|
371
|
+
const direct = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
372
|
+
if (direct)
|
|
373
|
+
return direct;
|
|
374
|
+
let dataUrl;
|
|
375
|
+
try {
|
|
376
|
+
dataUrl = canvas.toDataURL("image/png");
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
throw new Error(`[lens-pdf] ${caller}: PNG encode failed (canvas ${canvas.width}x${canvas.height} exceeds browser limit): ${err.message}`);
|
|
380
|
+
}
|
|
381
|
+
if (!dataUrl || dataUrl === "data:,") {
|
|
382
|
+
throw new Error(`[lens-pdf] ${caller}: PNG encode returned empty (canvas ${canvas.width}x${canvas.height} exceeds browser limit)`);
|
|
383
|
+
}
|
|
384
|
+
const res = await fetch(dataUrl);
|
|
385
|
+
return res.blob();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Heatmap colour stops. Tuned for the rich-black TAC formula:
|
|
389
|
+
* < 200 % → green (clean)
|
|
390
|
+
* 200–limit → amber (watch)
|
|
391
|
+
* ≥ limit → red (over the press limit)
|
|
392
|
+
*/
|
|
393
|
+
function heatmapColor(tac, limit) {
|
|
394
|
+
if (tac < 200)
|
|
395
|
+
return [0, 180, 0, 200];
|
|
396
|
+
if (tac < limit)
|
|
397
|
+
return [255, 200, 0, 200];
|
|
398
|
+
return [255, 0, 0, 220];
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Factory
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
/**
|
|
404
|
+
* Build a fully-wired {@link ViewerServices} backed by pdf.js. Every
|
|
405
|
+
* service the package consumes is implemented; consumers can drop the
|
|
406
|
+
* returned object straight into a `<ViewerServicesContext.Provider>`
|
|
407
|
+
* and every viewer-only feature works.
|
|
408
|
+
*
|
|
409
|
+
* @public
|
|
410
|
+
*/
|
|
411
|
+
export function createBrowserViewerServices(opts) {
|
|
412
|
+
const tokens = opts.tokens ?? defaultThemeTokens;
|
|
413
|
+
const defaultTacLimit = opts.tacLimit ?? 300;
|
|
414
|
+
const authorEmail = opts.annotationAuthorEmail ?? "you@browser.local";
|
|
415
|
+
const workerSrc = opts.workerSrc ?? defaultBrowserWorkerSrc;
|
|
416
|
+
if (pdfjs.GlobalWorkerOptions.workerSrc !== workerSrc) {
|
|
417
|
+
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
|
418
|
+
}
|
|
419
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
420
|
+
let docPromise = null;
|
|
421
|
+
// Raw PDF bytes (latin-1 decodable copy) used for spot-ink detection.
|
|
422
|
+
let bytesPromise = null;
|
|
423
|
+
// Lazily-parsed ink list (CMYK process inks + any spots extracted
|
|
424
|
+
// from the raw bytes). Cached once for the lifetime of the services.
|
|
425
|
+
let inksPromise = null;
|
|
426
|
+
// Keyed `${pageNum}@${dpi}` — page raster blob URLs for `pageImages`.
|
|
427
|
+
const pageUrls = new Map();
|
|
428
|
+
const pageBuilds = new Map();
|
|
429
|
+
// Keyed by page — the "analysis" raster used for color sample,
|
|
430
|
+
// densitometer, separations, and the TAC heatmap. Always rendered
|
|
431
|
+
// at ANALYSIS_DPI so reads don't depend on what the page-image cache
|
|
432
|
+
// happens to contain.
|
|
433
|
+
const analysisRasters = new Map();
|
|
434
|
+
const analysisBuilds = new Map();
|
|
435
|
+
// Keyed `${pageNum}|${channelName}` — channel image blob URLs.
|
|
436
|
+
const channelUrls = new Map();
|
|
437
|
+
const channelBuilds = new Map();
|
|
438
|
+
// Keyed `${pageNum}|${tacLimit}` — TAC heatmap blob URLs.
|
|
439
|
+
const heatmapUrls = new Map();
|
|
440
|
+
const heatmapBuilds = new Map();
|
|
441
|
+
// Per-page OCG metadata — id list keeps draw order; index → id map
|
|
442
|
+
// lets the layer URL builder re-key by ocg_index (the public API).
|
|
443
|
+
const ocgIdsPerPage = new Map();
|
|
444
|
+
// Keyed `${pageNum}|${layerIndex}` — single-layer transparent PNGs.
|
|
445
|
+
const layerUrls = new Map();
|
|
446
|
+
const layerBuilds = new Map();
|
|
447
|
+
// In-memory annotation store — single anonymous author per page.
|
|
448
|
+
const annotations = new Map();
|
|
449
|
+
// Subscribers notified when a URL becomes available so consumers can
|
|
450
|
+
// re-render and pick up the fresh blob URL.
|
|
451
|
+
const subscribers = new Set();
|
|
452
|
+
// All blob URLs we've created (so dispose() can revoke them all).
|
|
453
|
+
const blobs = [];
|
|
454
|
+
function notify() {
|
|
455
|
+
for (const cb of subscribers)
|
|
456
|
+
cb();
|
|
457
|
+
}
|
|
458
|
+
// ── pdf.js helpers ───────────────────────────────────────────────────
|
|
459
|
+
async function getBytes() {
|
|
460
|
+
if (!bytesPromise) {
|
|
461
|
+
bytesPromise = (async () => {
|
|
462
|
+
const response = await fetch(opts.pdfUrl);
|
|
463
|
+
if (!response.ok) {
|
|
464
|
+
throw new Error(`[lens-pdf] PDF fetch failed: ${response.status} ${response.statusText}`);
|
|
465
|
+
}
|
|
466
|
+
const buf = await response.arrayBuffer();
|
|
467
|
+
return new Uint8Array(buf);
|
|
468
|
+
})();
|
|
469
|
+
}
|
|
470
|
+
return bytesPromise;
|
|
471
|
+
}
|
|
472
|
+
async function getDoc() {
|
|
473
|
+
if (!docPromise) {
|
|
474
|
+
docPromise = (async () => {
|
|
475
|
+
const bytes = await getBytes();
|
|
476
|
+
// .slice() so pdf.js's worker can transfer/own its own copy
|
|
477
|
+
// without invalidating ours; we still need it for ink parsing.
|
|
478
|
+
return pdfjs.getDocument({ data: bytes.slice() })
|
|
479
|
+
.promise;
|
|
480
|
+
})();
|
|
481
|
+
}
|
|
482
|
+
return docPromise;
|
|
483
|
+
}
|
|
484
|
+
async function getInks() {
|
|
485
|
+
if (!inksPromise) {
|
|
486
|
+
inksPromise = (async () => {
|
|
487
|
+
const inks = PROCESS_CHANNELS.map((name) => ({
|
|
488
|
+
name,
|
|
489
|
+
type: "process",
|
|
490
|
+
altRgb: PROCESS_INK_RGB[name.toLowerCase()] ?? [0, 0, 0],
|
|
491
|
+
}));
|
|
492
|
+
try {
|
|
493
|
+
const bytes = await getBytes();
|
|
494
|
+
const spots = detectSpotInksFromPdfBytes(bytes);
|
|
495
|
+
inks.push(...spots);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
// eslint-disable-next-line no-console
|
|
499
|
+
console.warn("[lens-pdf] spot ink detection failed", err);
|
|
500
|
+
}
|
|
501
|
+
return inks;
|
|
502
|
+
})();
|
|
503
|
+
}
|
|
504
|
+
return inksPromise;
|
|
505
|
+
}
|
|
506
|
+
async function buildPageUrl(pageNum, dpi) {
|
|
507
|
+
const doc = await getDoc();
|
|
508
|
+
const page = await doc.getPage(pageNum);
|
|
509
|
+
const scale = dpi / 72;
|
|
510
|
+
const viewport = page.getViewport({ scale });
|
|
511
|
+
const canvas = document.createElement("canvas");
|
|
512
|
+
canvas.width = Math.ceil(viewport.width);
|
|
513
|
+
canvas.height = Math.ceil(viewport.height);
|
|
514
|
+
const ctx = canvas.getContext("2d");
|
|
515
|
+
if (!ctx) {
|
|
516
|
+
throw new Error("[lens-pdf] 2D context unavailable for page raster.");
|
|
517
|
+
}
|
|
518
|
+
ctx.fillStyle = "#ffffff";
|
|
519
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
520
|
+
// ``intent: "print"`` is what tells pdf.js to enable overprint
|
|
521
|
+
// preview + run the print-quality compositing path. The default
|
|
522
|
+
// ``display`` intent skips overprint, so a spot/CMYK object set
|
|
523
|
+
// to overprint renders as an opaque white block hiding the
|
|
524
|
+
// artwork behind it. Acrobat defaults to overprint-ON for the
|
|
525
|
+
// same content — this brings the viewer in line.
|
|
526
|
+
await page.render({
|
|
527
|
+
canvasContext: ctx,
|
|
528
|
+
viewport,
|
|
529
|
+
intent: "print",
|
|
530
|
+
}).promise;
|
|
531
|
+
const blob = await canvasToPngBlob(canvas, "buildPageUrl");
|
|
532
|
+
const url = URL.createObjectURL(blob);
|
|
533
|
+
blobs.push(url);
|
|
534
|
+
return url;
|
|
535
|
+
}
|
|
536
|
+
async function buildAnalysisRaster(pageNum) {
|
|
537
|
+
const doc = await getDoc();
|
|
538
|
+
const page = await doc.getPage(pageNum);
|
|
539
|
+
const baseViewport = page.getViewport({ scale: 1 });
|
|
540
|
+
const widthPts = baseViewport.width;
|
|
541
|
+
const heightPts = baseViewport.height;
|
|
542
|
+
let ptsToPx = ANALYSIS_DPI / 72;
|
|
543
|
+
let probe = page.getViewport({ scale: ptsToPx });
|
|
544
|
+
const fullArea = Math.ceil(probe.width) * Math.ceil(probe.height);
|
|
545
|
+
if (fullArea > MAX_ANALYSIS_CANVAS_PIXELS) {
|
|
546
|
+
ptsToPx *= Math.sqrt(MAX_ANALYSIS_CANVAS_PIXELS / fullArea);
|
|
547
|
+
probe = page.getViewport({ scale: ptsToPx });
|
|
548
|
+
}
|
|
549
|
+
const viewport = probe;
|
|
550
|
+
const widthPx = Math.ceil(viewport.width);
|
|
551
|
+
const heightPx = Math.ceil(viewport.height);
|
|
552
|
+
const canvas = document.createElement("canvas");
|
|
553
|
+
canvas.width = widthPx;
|
|
554
|
+
canvas.height = heightPx;
|
|
555
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
556
|
+
if (!ctx) {
|
|
557
|
+
throw new Error("[lens-pdf] 2D context unavailable for analysis raster.");
|
|
558
|
+
}
|
|
559
|
+
ctx.fillStyle = "#ffffff";
|
|
560
|
+
ctx.fillRect(0, 0, widthPx, heightPx);
|
|
561
|
+
// ``intent: "print"`` — see buildPageUrl above for the rationale.
|
|
562
|
+
// The analysis raster feeds the densitometer + color picker, so
|
|
563
|
+
// it must match the visible page raster pixel-for-pixel.
|
|
564
|
+
await page.render({
|
|
565
|
+
canvasContext: ctx,
|
|
566
|
+
viewport,
|
|
567
|
+
intent: "print",
|
|
568
|
+
}).promise;
|
|
569
|
+
const rgba = ctx.getImageData(0, 0, widthPx, heightPx);
|
|
570
|
+
return { pageNum, widthPts, heightPts, widthPx, heightPx, rgba };
|
|
571
|
+
}
|
|
572
|
+
async function getAnalysisRaster(pageNum) {
|
|
573
|
+
const cached = analysisRasters.get(pageNum);
|
|
574
|
+
if (cached)
|
|
575
|
+
return cached;
|
|
576
|
+
const inflight = analysisBuilds.get(pageNum);
|
|
577
|
+
if (inflight)
|
|
578
|
+
return inflight;
|
|
579
|
+
const promise = buildAnalysisRaster(pageNum).then((raster) => {
|
|
580
|
+
analysisRasters.set(pageNum, raster);
|
|
581
|
+
analysisBuilds.delete(pageNum);
|
|
582
|
+
return raster;
|
|
583
|
+
});
|
|
584
|
+
analysisBuilds.set(pageNum, promise);
|
|
585
|
+
return promise;
|
|
586
|
+
}
|
|
587
|
+
// ── Lazy URL helpers ─────────────────────────────────────────────────
|
|
588
|
+
function ensurePageUrl(pageNum, dpi) {
|
|
589
|
+
const key = `${pageNum}@${dpi}`;
|
|
590
|
+
if (pageUrls.has(key) || pageBuilds.has(key))
|
|
591
|
+
return;
|
|
592
|
+
const promise = buildPageUrl(pageNum, dpi)
|
|
593
|
+
.then((url) => {
|
|
594
|
+
pageUrls.set(key, url);
|
|
595
|
+
pageBuilds.delete(key);
|
|
596
|
+
notify();
|
|
597
|
+
return url;
|
|
598
|
+
})
|
|
599
|
+
.catch((err) => {
|
|
600
|
+
pageBuilds.delete(key);
|
|
601
|
+
// eslint-disable-next-line no-console
|
|
602
|
+
console.error("[lens-pdf] page raster failed", err);
|
|
603
|
+
throw err;
|
|
604
|
+
});
|
|
605
|
+
pageBuilds.set(key, promise);
|
|
606
|
+
}
|
|
607
|
+
async function buildChannelUrl(pageNum, channelName) {
|
|
608
|
+
const lower = channelName.toLowerCase();
|
|
609
|
+
const processIndex = lower === "cyan"
|
|
610
|
+
? 0
|
|
611
|
+
: lower === "magenta"
|
|
612
|
+
? 1
|
|
613
|
+
: lower === "yellow"
|
|
614
|
+
? 2
|
|
615
|
+
: lower === "black"
|
|
616
|
+
? 3
|
|
617
|
+
: -1;
|
|
618
|
+
const raster = await getAnalysisRaster(pageNum);
|
|
619
|
+
const data = raster.rgba.data;
|
|
620
|
+
let url;
|
|
621
|
+
if (processIndex >= 0) {
|
|
622
|
+
// Process plate: closed-form rgbToCmyk so the rendered separation
|
|
623
|
+
// matches the densitometer's CMYK readout pixel-for-pixel.
|
|
624
|
+
url = await rasterizeBlobUrl(raster.widthPx, raster.heightPx, (i) => {
|
|
625
|
+
const r = data[i] ?? 255;
|
|
626
|
+
const g = data[i + 1] ?? 255;
|
|
627
|
+
const b = data[i + 2] ?? 255;
|
|
628
|
+
const cmyk = rgbToCmyk(r, g, b);
|
|
629
|
+
const ink = processIndex === 0
|
|
630
|
+
? cmyk.c
|
|
631
|
+
: processIndex === 1
|
|
632
|
+
? cmyk.m
|
|
633
|
+
: processIndex === 2
|
|
634
|
+
? cmyk.y
|
|
635
|
+
: cmyk.k;
|
|
636
|
+
const grey = Math.max(0, Math.min(255, Math.round(255 * (1 - ink))));
|
|
637
|
+
return [grey, grey, grey, 255];
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
// Spot plate: cosine-similarity coverage estimate vs. the spot's
|
|
642
|
+
// alternate sRGB. Heuristic — recovers a usable separation for
|
|
643
|
+
// any spot the PDF declares, even when pdf.js composited it into
|
|
644
|
+
// the RGB raster.
|
|
645
|
+
const inks = await getInks();
|
|
646
|
+
const ink = inks.find((k) => k.name.toLowerCase() === lower && k.type === "spot");
|
|
647
|
+
if (!ink) {
|
|
648
|
+
url = await rasterizeBlobUrl(raster.widthPx, raster.heightPx, () => [
|
|
649
|
+
255, 255, 255, 255,
|
|
650
|
+
]);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
const [aR, aG, aB] = ink.altRgb;
|
|
654
|
+
url = await rasterizeBlobUrl(raster.widthPx, raster.heightPx, (i) => {
|
|
655
|
+
const r = data[i] ?? 255;
|
|
656
|
+
const g = data[i + 1] ?? 255;
|
|
657
|
+
const b = data[i + 2] ?? 255;
|
|
658
|
+
const cov = estimateInkCoverage(r, g, b, aR, aG, aB);
|
|
659
|
+
const grey = Math.max(0, Math.min(255, Math.round(255 * (1 - cov))));
|
|
660
|
+
return [grey, grey, grey, 255];
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
blobs.push(url);
|
|
665
|
+
return url;
|
|
666
|
+
}
|
|
667
|
+
function ensureChannelUrl(pageNum, channelName) {
|
|
668
|
+
const key = `${pageNum}|${channelName}`;
|
|
669
|
+
const cached = channelUrls.get(key);
|
|
670
|
+
if (cached)
|
|
671
|
+
return Promise.resolve(cached);
|
|
672
|
+
const inflight = channelBuilds.get(key);
|
|
673
|
+
if (inflight)
|
|
674
|
+
return inflight;
|
|
675
|
+
const promise = buildChannelUrl(pageNum, channelName)
|
|
676
|
+
.then((url) => {
|
|
677
|
+
channelUrls.set(key, url);
|
|
678
|
+
channelBuilds.delete(key);
|
|
679
|
+
notify();
|
|
680
|
+
return url;
|
|
681
|
+
})
|
|
682
|
+
.catch((err) => {
|
|
683
|
+
channelBuilds.delete(key);
|
|
684
|
+
// eslint-disable-next-line no-console
|
|
685
|
+
console.error("[lens-pdf] channel raster failed", err);
|
|
686
|
+
throw err;
|
|
687
|
+
});
|
|
688
|
+
channelBuilds.set(key, promise);
|
|
689
|
+
return promise;
|
|
690
|
+
}
|
|
691
|
+
async function buildHeatmapUrl(pageNum, tacLimit) {
|
|
692
|
+
const raster = await getAnalysisRaster(pageNum);
|
|
693
|
+
const data = raster.rgba.data;
|
|
694
|
+
// TAC = process CMYK coverage **plus** every detected spot ink's
|
|
695
|
+
// cosine-similarity coverage estimate, matching what the
|
|
696
|
+
// densitometer and color picker report for the same pixel. PDFs
|
|
697
|
+
// without declared spots fall through with zero spot contribution
|
|
698
|
+
// (`getInks()` returns only the four process channels), so pure
|
|
699
|
+
// CMYK files behave exactly like the previous CMYK-only heatmap.
|
|
700
|
+
const inks = await getInks();
|
|
701
|
+
const spots = inks.filter((ink) => ink.type === "spot");
|
|
702
|
+
const url = await rasterizeBlobUrl(raster.widthPx, raster.heightPx, (i) => {
|
|
703
|
+
const r = data[i] ?? 255;
|
|
704
|
+
const g = data[i + 1] ?? 255;
|
|
705
|
+
const b = data[i + 2] ?? 255;
|
|
706
|
+
const { tac: cmykPct } = rgbToCmyk(r, g, b);
|
|
707
|
+
let spotPct = 0;
|
|
708
|
+
for (const ink of spots) {
|
|
709
|
+
spotPct +=
|
|
710
|
+
estimateInkCoverage(r, g, b, ink.altRgb[0], ink.altRgb[1], ink.altRgb[2]) * 100;
|
|
711
|
+
}
|
|
712
|
+
const tac = cmykPct + spotPct;
|
|
713
|
+
if (tac < 1)
|
|
714
|
+
return [0, 0, 0, 0];
|
|
715
|
+
return heatmapColor(tac, tacLimit);
|
|
716
|
+
});
|
|
717
|
+
blobs.push(url);
|
|
718
|
+
return url;
|
|
719
|
+
}
|
|
720
|
+
function ensureHeatmapUrl(pageNum, tacLimit) {
|
|
721
|
+
const key = `${pageNum}|${tacLimit}`;
|
|
722
|
+
const cached = heatmapUrls.get(key);
|
|
723
|
+
if (cached)
|
|
724
|
+
return Promise.resolve(cached);
|
|
725
|
+
const inflight = heatmapBuilds.get(key);
|
|
726
|
+
if (inflight)
|
|
727
|
+
return inflight;
|
|
728
|
+
const promise = buildHeatmapUrl(pageNum, tacLimit)
|
|
729
|
+
.then((url) => {
|
|
730
|
+
heatmapUrls.set(key, url);
|
|
731
|
+
heatmapBuilds.delete(key);
|
|
732
|
+
notify();
|
|
733
|
+
return url;
|
|
734
|
+
})
|
|
735
|
+
.catch((err) => {
|
|
736
|
+
heatmapBuilds.delete(key);
|
|
737
|
+
// eslint-disable-next-line no-console
|
|
738
|
+
console.error("[lens-pdf] heatmap raster failed", err);
|
|
739
|
+
throw err;
|
|
740
|
+
});
|
|
741
|
+
heatmapBuilds.set(key, promise);
|
|
742
|
+
return promise;
|
|
743
|
+
}
|
|
744
|
+
// ── Layer rendering (single OCG with transparent bg) ─────────────────
|
|
745
|
+
async function getOcgIds(pageNum) {
|
|
746
|
+
const cached = ocgIdsPerPage.get(pageNum);
|
|
747
|
+
if (cached)
|
|
748
|
+
return cached;
|
|
749
|
+
try {
|
|
750
|
+
const doc = await getDoc();
|
|
751
|
+
const config = await doc.getOptionalContentConfig();
|
|
752
|
+
const ids = extractOcgIds(config);
|
|
753
|
+
ocgIdsPerPage.set(pageNum, ids);
|
|
754
|
+
return ids;
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
// eslint-disable-next-line no-console
|
|
758
|
+
console.warn("[lens-pdf] OCG enumeration failed", err);
|
|
759
|
+
ocgIdsPerPage.set(pageNum, []);
|
|
760
|
+
return [];
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function buildLayerUrl(pageNum, layerIndex, dpi) {
|
|
764
|
+
const ids = await getOcgIds(pageNum);
|
|
765
|
+
if (layerIndex < 0 || layerIndex >= ids.length) {
|
|
766
|
+
throw new Error(`[lens-pdf] layer ${layerIndex} out of range`);
|
|
767
|
+
}
|
|
768
|
+
const targetId = ids[layerIndex];
|
|
769
|
+
const doc = await getDoc();
|
|
770
|
+
const page = await doc.getPage(pageNum);
|
|
771
|
+
// pdf.js render() accepts a `optionalContentConfigPromise` so we
|
|
772
|
+
// can flip every other OCG off and render the chosen group in
|
|
773
|
+
// isolation. The base config exposes `setVisibility(id, visible)`
|
|
774
|
+
// mutators directly on the result of getOptionalContentConfig().
|
|
775
|
+
const config = await doc.getOptionalContentConfig();
|
|
776
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
777
|
+
const cfgAny = config;
|
|
778
|
+
if (typeof cfgAny.setVisibility === "function") {
|
|
779
|
+
for (const id of ids)
|
|
780
|
+
cfgAny.setVisibility(id, id === targetId);
|
|
781
|
+
}
|
|
782
|
+
else if (typeof cfgAny.setOCGState === "function") {
|
|
783
|
+
// older pdf.js: { state: [["set", id, visible], ...] }
|
|
784
|
+
const state = ids.map((id) => ["set", id, id === targetId]);
|
|
785
|
+
cfgAny.setOCGState({ state });
|
|
786
|
+
}
|
|
787
|
+
const scale = dpi / 72;
|
|
788
|
+
const viewport = page.getViewport({ scale });
|
|
789
|
+
const canvas = document.createElement("canvas");
|
|
790
|
+
canvas.width = Math.ceil(viewport.width);
|
|
791
|
+
canvas.height = Math.ceil(viewport.height);
|
|
792
|
+
const ctx = canvas.getContext("2d");
|
|
793
|
+
if (!ctx) {
|
|
794
|
+
throw new Error("[lens-pdf] 2D context unavailable for layer raster.");
|
|
795
|
+
}
|
|
796
|
+
// No paper fill — caller composites layers over their own
|
|
797
|
+
// white-paper canvas via source-over blending. Transparent bg
|
|
798
|
+
// means hidden layers reveal the canvas underneath.
|
|
799
|
+
await page.render({
|
|
800
|
+
canvasContext: ctx,
|
|
801
|
+
viewport,
|
|
802
|
+
// ``intent: "print"`` matches the other render paths so the
|
|
803
|
+
// layer raster composites overprints the same way Acrobat does.
|
|
804
|
+
intent: "print",
|
|
805
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
806
|
+
optionalContentConfigPromise: Promise.resolve(config),
|
|
807
|
+
background: "rgba(0,0,0,0)",
|
|
808
|
+
}).promise;
|
|
809
|
+
const blob = await canvasToPngBlob(canvas, "buildLayerUrl");
|
|
810
|
+
const url = URL.createObjectURL(blob);
|
|
811
|
+
blobs.push(url);
|
|
812
|
+
return url;
|
|
813
|
+
}
|
|
814
|
+
function ensureLayerUrl(pageNum, layerIndex, dpi) {
|
|
815
|
+
const key = `${pageNum}|${layerIndex}@${dpi}`;
|
|
816
|
+
const cached = layerUrls.get(key);
|
|
817
|
+
if (cached)
|
|
818
|
+
return Promise.resolve(cached);
|
|
819
|
+
const inflight = layerBuilds.get(key);
|
|
820
|
+
if (inflight)
|
|
821
|
+
return inflight;
|
|
822
|
+
const promise = buildLayerUrl(pageNum, layerIndex, dpi)
|
|
823
|
+
.then((url) => {
|
|
824
|
+
layerUrls.set(key, url);
|
|
825
|
+
layerBuilds.delete(key);
|
|
826
|
+
notify();
|
|
827
|
+
return url;
|
|
828
|
+
})
|
|
829
|
+
.catch((err) => {
|
|
830
|
+
layerBuilds.delete(key);
|
|
831
|
+
// eslint-disable-next-line no-console
|
|
832
|
+
console.error("[lens-pdf] layer raster failed", err);
|
|
833
|
+
throw err;
|
|
834
|
+
});
|
|
835
|
+
layerBuilds.set(key, promise);
|
|
836
|
+
return promise;
|
|
837
|
+
}
|
|
838
|
+
// ── Sample helpers ───────────────────────────────────────────────────
|
|
839
|
+
async function sampleAt(pageNum, pdfX, pdfY) {
|
|
840
|
+
const raster = await getAnalysisRaster(pageNum);
|
|
841
|
+
const ptsToPx = ANALYSIS_DPI / 72;
|
|
842
|
+
const pxX = Math.max(0, Math.min(raster.widthPx - 1, Math.round(pdfX * ptsToPx)));
|
|
843
|
+
const pxY = Math.max(0, Math.min(raster.heightPx - 1, Math.round((raster.heightPts - pdfY) * ptsToPx)));
|
|
844
|
+
const i = (pxY * raster.widthPx + pxX) * 4;
|
|
845
|
+
const r = raster.rgba.data[i] ?? 255;
|
|
846
|
+
const g = raster.rgba.data[i + 1] ?? 255;
|
|
847
|
+
const b = raster.rgba.data[i + 2] ?? 255;
|
|
848
|
+
const cmyk = rgbToCmyk(r, g, b);
|
|
849
|
+
const hex = "#" +
|
|
850
|
+
[r, g, b]
|
|
851
|
+
.map((v) => v.toString(16).padStart(2, "0"))
|
|
852
|
+
.join("")
|
|
853
|
+
.toUpperCase();
|
|
854
|
+
return {
|
|
855
|
+
rgb: [r, g, b],
|
|
856
|
+
cmyk: { c: cmyk.c, m: cmyk.m, y: cmyk.y, k: cmyk.k },
|
|
857
|
+
tac: cmyk.tac,
|
|
858
|
+
hex,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
// ── ViewerServices impl ──────────────────────────────────────────────
|
|
862
|
+
const services = {
|
|
863
|
+
pageImages: {
|
|
864
|
+
getPageImageUrl: ({ pageNum, dpi }) => {
|
|
865
|
+
const key = `${pageNum}@${dpi}`;
|
|
866
|
+
const cached = pageUrls.get(key);
|
|
867
|
+
if (cached)
|
|
868
|
+
return cached;
|
|
869
|
+
ensurePageUrl(pageNum, dpi);
|
|
870
|
+
return PLACEHOLDER_PNG;
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
layers: {
|
|
874
|
+
getLayerImageUrl: ({ pageNum, layerIndex, dpi }) => {
|
|
875
|
+
const key = `${pageNum}|${layerIndex}@${dpi}`;
|
|
876
|
+
const cached = layerUrls.get(key);
|
|
877
|
+
if (cached)
|
|
878
|
+
return cached;
|
|
879
|
+
// No placeholder — LayerCanvas caches the first-loaded image
|
|
880
|
+
// and never retries, so a placeholder would stick. Hosts
|
|
881
|
+
// should `await services.prepare(pageNum)` before mounting
|
|
882
|
+
// <LayerCanvas>; consumers calling cold get an empty string
|
|
883
|
+
// and the canvas just paints paper-white.
|
|
884
|
+
ensureLayerUrl(pageNum, layerIndex, dpi);
|
|
885
|
+
return "";
|
|
886
|
+
},
|
|
887
|
+
listLayers: async () => {
|
|
888
|
+
try {
|
|
889
|
+
const doc = await getDoc();
|
|
890
|
+
const config = await doc.getOptionalContentConfig();
|
|
891
|
+
const ids = extractOcgIds(config);
|
|
892
|
+
if (ids.length === 0) {
|
|
893
|
+
return [];
|
|
894
|
+
}
|
|
895
|
+
// Cache the same list for every page — OCGs are document-
|
|
896
|
+
// level, so getOcgIds() should resolve to the same set
|
|
897
|
+
// regardless of which page asked for them.
|
|
898
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
899
|
+
ocgIdsPerPage.set(i, ids);
|
|
900
|
+
}
|
|
901
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
902
|
+
const cfgAny = config;
|
|
903
|
+
return ids.map((id, index) => {
|
|
904
|
+
// pdf.js's `getGroup(id)` returns an OCG instance with
|
|
905
|
+
// `.name`. Older builds expose it via `getGroups()[id]`.
|
|
906
|
+
// Try the dedicated accessor first, then fall back.
|
|
907
|
+
let name = `Layer ${index + 1}`;
|
|
908
|
+
try {
|
|
909
|
+
const g = cfgAny.getGroup?.(id);
|
|
910
|
+
if (g?.name)
|
|
911
|
+
name = g.name;
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
/* ignore */
|
|
915
|
+
}
|
|
916
|
+
if (name === `Layer ${index + 1}`) {
|
|
917
|
+
try {
|
|
918
|
+
const groups = cfgAny.getGroups?.();
|
|
919
|
+
const fromObj = groups?.[id]?.name;
|
|
920
|
+
if (fromObj)
|
|
921
|
+
name = fromObj;
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
/* ignore */
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// pdf.js's isVisible expects `{ type: "OCG", id }`, not
|
|
928
|
+
// a bare id string — pass that shape so the default-on
|
|
929
|
+
// flag is correct for layered PDFs.
|
|
930
|
+
let visible = true;
|
|
931
|
+
try {
|
|
932
|
+
visible = cfgAny.isVisible?.({ type: "OCG", id }) ?? true;
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
/* ignore */
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
name,
|
|
939
|
+
ocg_index: index,
|
|
940
|
+
default_on: visible,
|
|
941
|
+
};
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
// eslint-disable-next-line no-console
|
|
946
|
+
console.warn("[lens-pdf] listLayers failed", err);
|
|
947
|
+
return [];
|
|
948
|
+
}
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
separations: {
|
|
952
|
+
getChannelImageUrl: ({ pageNum, channelName }) => {
|
|
953
|
+
const key = `${pageNum}|${channelName}`;
|
|
954
|
+
const cached = channelUrls.get(key);
|
|
955
|
+
if (cached)
|
|
956
|
+
return cached;
|
|
957
|
+
// Same caching gotcha as layers — no placeholder. Hosts call
|
|
958
|
+
// `prepare(pageNum)` before mounting <SeparationCanvas>.
|
|
959
|
+
ensureChannelUrl(pageNum, channelName);
|
|
960
|
+
return "";
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
tacHeatmap: {
|
|
964
|
+
getHeatmapImageUrl: ({ pageNum, tacLimit }) => {
|
|
965
|
+
const limit = tacLimit ?? defaultTacLimit;
|
|
966
|
+
const key = `${pageNum}|${limit}`;
|
|
967
|
+
const cached = heatmapUrls.get(key);
|
|
968
|
+
if (cached)
|
|
969
|
+
return cached;
|
|
970
|
+
ensureHeatmapUrl(pageNum, limit);
|
|
971
|
+
return "";
|
|
972
|
+
},
|
|
973
|
+
// pdf.js renderer doesn't expose per-text-run bboxes the same
|
|
974
|
+
// way poppler does, so the demo skips the run tooltips. The
|
|
975
|
+
// pixel heatmap still works.
|
|
976
|
+
listRuns: async () => [],
|
|
977
|
+
},
|
|
978
|
+
colorSample: {
|
|
979
|
+
sampleAt: async ({ pageNum, pdfX, pdfY }) => {
|
|
980
|
+
const sample = await sampleAt(pageNum, pdfX, pdfY);
|
|
981
|
+
if (!sample)
|
|
982
|
+
return null;
|
|
983
|
+
const inksList = await getInks();
|
|
984
|
+
const [r, g, b] = sample.rgb;
|
|
985
|
+
const inks = inksList.map((ink) => {
|
|
986
|
+
if (ink.type === "process") {
|
|
987
|
+
const lower = ink.name.toLowerCase();
|
|
988
|
+
const pct = lower === "cyan"
|
|
989
|
+
? sample.cmyk.c * 100
|
|
990
|
+
: lower === "magenta"
|
|
991
|
+
? sample.cmyk.m * 100
|
|
992
|
+
: lower === "yellow"
|
|
993
|
+
? sample.cmyk.y * 100
|
|
994
|
+
: sample.cmyk.k * 100;
|
|
995
|
+
return { name: ink.name, percent: pct, type: "process" };
|
|
996
|
+
}
|
|
997
|
+
const cov = estimateInkCoverage(r, g, b, ink.altRgb[0], ink.altRgb[1], ink.altRgb[2]);
|
|
998
|
+
return { name: ink.name, percent: cov * 100, type: "spot" };
|
|
999
|
+
});
|
|
1000
|
+
const spotTotal = inks
|
|
1001
|
+
.filter((i) => i.type === "spot")
|
|
1002
|
+
.reduce((s, c) => s + c.percent, 0);
|
|
1003
|
+
const out = {
|
|
1004
|
+
x: pdfX,
|
|
1005
|
+
y: pdfY,
|
|
1006
|
+
rgb: sample.rgb,
|
|
1007
|
+
hex: sample.hex,
|
|
1008
|
+
tac: Math.round((sample.tac + spotTotal) * 10) / 10,
|
|
1009
|
+
inks,
|
|
1010
|
+
};
|
|
1011
|
+
return out;
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
densitometer: {
|
|
1015
|
+
sampleAt: async ({ pageNum, pdfX, pdfY, tacLimit }) => {
|
|
1016
|
+
const limit = tacLimit ?? defaultTacLimit;
|
|
1017
|
+
const sample = await sampleAt(pageNum, pdfX, pdfY);
|
|
1018
|
+
if (!sample) {
|
|
1019
|
+
throw new Error("Sampling failed for this point.");
|
|
1020
|
+
}
|
|
1021
|
+
const inks = await getInks();
|
|
1022
|
+
const [r, g, b] = sample.rgb;
|
|
1023
|
+
const channels = inks.map((ink) => {
|
|
1024
|
+
if (ink.type === "process") {
|
|
1025
|
+
// CMYK channels come from the closed-form rgbToCmyk so the
|
|
1026
|
+
// process readout matches the TAC heatmap exactly.
|
|
1027
|
+
const lower = ink.name.toLowerCase();
|
|
1028
|
+
const pct = lower === "cyan"
|
|
1029
|
+
? sample.cmyk.c * 100
|
|
1030
|
+
: lower === "magenta"
|
|
1031
|
+
? sample.cmyk.m * 100
|
|
1032
|
+
: lower === "yellow"
|
|
1033
|
+
? sample.cmyk.y * 100
|
|
1034
|
+
: sample.cmyk.k * 100;
|
|
1035
|
+
return { name: ink.name, percent: pct };
|
|
1036
|
+
}
|
|
1037
|
+
// Spot inks: cosine-similarity coverage estimate. Only fires
|
|
1038
|
+
// when the sampled pixel matches the spot's hue direction
|
|
1039
|
+
// (cos > 0.92), so unrelated areas read 0 instead of nonsense.
|
|
1040
|
+
const cov = estimateInkCoverage(r, g, b, ink.altRgb[0], ink.altRgb[1], ink.altRgb[2]);
|
|
1041
|
+
return { name: ink.name, percent: cov * 100 };
|
|
1042
|
+
});
|
|
1043
|
+
const spotTotal = channels
|
|
1044
|
+
.slice(PROCESS_CHANNELS.length)
|
|
1045
|
+
.reduce((s, c) => s + c.percent, 0);
|
|
1046
|
+
const tac = sample.tac + spotTotal;
|
|
1047
|
+
const out = {
|
|
1048
|
+
x: pdfX,
|
|
1049
|
+
y: pdfY,
|
|
1050
|
+
dpi: ANALYSIS_DPI,
|
|
1051
|
+
channels,
|
|
1052
|
+
tac,
|
|
1053
|
+
tac_limit: limit,
|
|
1054
|
+
limit_exceeded: tac > limit,
|
|
1055
|
+
};
|
|
1056
|
+
return out;
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
annotations: {
|
|
1060
|
+
list: async () => Array.from(annotations.values()).sort((a, b) => a.pageNum - b.pageNum),
|
|
1061
|
+
getForPage: async (pageNum) => annotations.get(pageNum) ?? null,
|
|
1062
|
+
saveForPage: async (pageNum, fabricJson) => {
|
|
1063
|
+
const now = new Date().toISOString();
|
|
1064
|
+
const existing = annotations.get(pageNum);
|
|
1065
|
+
if (existing) {
|
|
1066
|
+
annotations.set(pageNum, {
|
|
1067
|
+
...existing,
|
|
1068
|
+
fabricJson,
|
|
1069
|
+
updatedAt: now,
|
|
1070
|
+
});
|
|
1071
|
+
notify();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
annotations.set(pageNum, {
|
|
1075
|
+
id: `browser-${pageNum}`,
|
|
1076
|
+
jobId: "browser",
|
|
1077
|
+
pageNum,
|
|
1078
|
+
authorEmail: authorEmail,
|
|
1079
|
+
authorName: "You",
|
|
1080
|
+
createdAt: now,
|
|
1081
|
+
updatedAt: now,
|
|
1082
|
+
fabricJson,
|
|
1083
|
+
});
|
|
1084
|
+
notify();
|
|
1085
|
+
},
|
|
1086
|
+
remove: async (id) => {
|
|
1087
|
+
for (const [page, entry] of annotations) {
|
|
1088
|
+
if (entry.id === id) {
|
|
1089
|
+
annotations.delete(page);
|
|
1090
|
+
notify();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
// Reports require a server-side renderer (HTML report dashboard +
|
|
1097
|
+
// PDF export) — leave as no-op so the toolbar items self-hide.
|
|
1098
|
+
reports: markUnwired({
|
|
1099
|
+
getHtmlReportUrl: () => "",
|
|
1100
|
+
getPdfDownloadUrl: () => "",
|
|
1101
|
+
}),
|
|
1102
|
+
telemetry: noopTelemetry,
|
|
1103
|
+
i18n: noopI18n,
|
|
1104
|
+
tokens,
|
|
1105
|
+
// ── Lifecycle / metadata extensions ──────────────────────────────
|
|
1106
|
+
async getPageCount() {
|
|
1107
|
+
const doc = await getDoc();
|
|
1108
|
+
return doc.numPages;
|
|
1109
|
+
},
|
|
1110
|
+
async getPageDimensions(pageNum) {
|
|
1111
|
+
const doc = await getDoc();
|
|
1112
|
+
const page = await doc.getPage(pageNum);
|
|
1113
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
1114
|
+
return { widthPts: viewport.width, heightPts: viewport.height };
|
|
1115
|
+
},
|
|
1116
|
+
getInks,
|
|
1117
|
+
async prepare(pageNum, prepareOpts) {
|
|
1118
|
+
const limit = prepareOpts?.tacLimit ?? defaultTacLimit;
|
|
1119
|
+
const raster = await getAnalysisRaster(pageNum);
|
|
1120
|
+
const ids = await getOcgIds(pageNum);
|
|
1121
|
+
const inks = await getInks();
|
|
1122
|
+
// CMYK + spot channel plates + the heatmap all read from the
|
|
1123
|
+
// same analysis raster, so they can be built in parallel.
|
|
1124
|
+
// Layers MUST be serialised because pdf.js's
|
|
1125
|
+
// OptionalContentConfig has shared mutable state for visibility.
|
|
1126
|
+
await Promise.all([
|
|
1127
|
+
...inks.map((ink) => ensureChannelUrl(pageNum, ink.name)),
|
|
1128
|
+
ensureHeatmapUrl(pageNum, limit),
|
|
1129
|
+
]);
|
|
1130
|
+
for (let layerIndex = 0; layerIndex < ids.length; layerIndex++) {
|
|
1131
|
+
await ensureLayerUrl(pageNum, layerIndex, ANALYSIS_DPI);
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
widthPts: raster.widthPts,
|
|
1135
|
+
heightPts: raster.heightPts,
|
|
1136
|
+
layerCount: ids.length,
|
|
1137
|
+
};
|
|
1138
|
+
},
|
|
1139
|
+
subscribe(listener) {
|
|
1140
|
+
subscribers.add(listener);
|
|
1141
|
+
return () => subscribers.delete(listener);
|
|
1142
|
+
},
|
|
1143
|
+
dispose() {
|
|
1144
|
+
for (const url of blobs)
|
|
1145
|
+
URL.revokeObjectURL(url);
|
|
1146
|
+
blobs.length = 0;
|
|
1147
|
+
pageUrls.clear();
|
|
1148
|
+
pageBuilds.clear();
|
|
1149
|
+
analysisRasters.clear();
|
|
1150
|
+
analysisBuilds.clear();
|
|
1151
|
+
channelUrls.clear();
|
|
1152
|
+
channelBuilds.clear();
|
|
1153
|
+
heatmapUrls.clear();
|
|
1154
|
+
heatmapBuilds.clear();
|
|
1155
|
+
layerUrls.clear();
|
|
1156
|
+
layerBuilds.clear();
|
|
1157
|
+
ocgIdsPerPage.clear();
|
|
1158
|
+
annotations.clear();
|
|
1159
|
+
subscribers.clear();
|
|
1160
|
+
docPromise = null;
|
|
1161
|
+
bytesPromise = null;
|
|
1162
|
+
inksPromise = null;
|
|
1163
|
+
},
|
|
1164
|
+
};
|
|
1165
|
+
return services;
|
|
1166
|
+
}
|
|
1167
|
+
// ---------------------------------------------------------------------------
|
|
1168
|
+
// React helpers
|
|
1169
|
+
// ---------------------------------------------------------------------------
|
|
1170
|
+
/**
|
|
1171
|
+
* React hook that re-renders whenever a {@link BrowserViewerServices}
|
|
1172
|
+
* instance fires a `subscribe` event (i.e. a lazily-built page tile,
|
|
1173
|
+
* channel image, heatmap, or annotation has become available).
|
|
1174
|
+
*
|
|
1175
|
+
* Use this in the top-level component that holds the services instance
|
|
1176
|
+
* so children re-read the synchronous URL builders and pick up freshly-
|
|
1177
|
+
* cached blob URLs.
|
|
1178
|
+
*
|
|
1179
|
+
* @public
|
|
1180
|
+
*/
|
|
1181
|
+
export function useBrowserViewerServicesVersion(services) {
|
|
1182
|
+
const [v, setV] = useState(0);
|
|
1183
|
+
useEffect(() => {
|
|
1184
|
+
if (!services)
|
|
1185
|
+
return;
|
|
1186
|
+
return services.subscribe(() => setV((x) => x + 1));
|
|
1187
|
+
}, [services]);
|
|
1188
|
+
return v;
|
|
1189
|
+
}
|
|
1190
|
+
//# sourceMappingURL=index.js.map
|