@reekon-tools/boldr-utils 1.26.1 → 1.27.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 (80) hide show
  1. package/dist/annotation/canvas/pdfCanvasPages.d.ts +7 -4
  2. package/dist/annotation/canvas/pdfCanvasPages.js +93 -14
  3. package/dist/annotation/canvas/tileCalibration.d.ts +16 -0
  4. package/dist/annotation/canvas/tileCalibration.js +91 -0
  5. package/dist/annotation/canvas/useAnnotationCanvasState.js +7 -67
  6. package/dist/annotation/data/hooks/usePdfCanvasDoc.js +24 -7
  7. package/dist/export/nutrient/instantJsonToPdfCanvas.d.ts +39 -0
  8. package/dist/export/nutrient/instantJsonToPdfCanvas.js +631 -0
  9. package/dist/export/pdf/index.d.ts +1 -1
  10. package/dist/export/pdf/index.js +1 -1
  11. package/dist/export/pdf/marks.js +46 -4
  12. package/dist/export/pdf/renderCanvasToPdf.d.ts +9 -0
  13. package/dist/export/pdf/renderCanvasToPdf.js +21 -5
  14. package/dist/export/pdf/types.d.ts +11 -0
  15. package/dist/exports.d.ts +2 -1
  16. package/dist/exports.js +2 -1
  17. package/dist/server.d.ts +3 -1
  18. package/dist/server.js +3 -1
  19. package/dist/types/pdfCanvas.d.ts +12 -1
  20. package/package.json +1 -1
  21. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  22. package/dist/canvas/AnnotationCanvas.js +0 -10
  23. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  24. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  25. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  26. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  27. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  28. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  29. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  30. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  31. package/dist/canvas/Tool.d.ts +0 -38
  32. package/dist/canvas/Tool.js +0 -1
  33. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  34. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  35. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  36. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  37. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  38. package/dist/canvas/elements/ShapeElement.js +0 -62
  39. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  40. package/dist/canvas/elements/StrokeElement.js +0 -18
  41. package/dist/canvas/measurementPicker.d.ts +0 -10
  42. package/dist/canvas/measurementPicker.js +0 -1
  43. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  44. package/dist/canvas/measurementStampOverlay.js +0 -1
  45. package/dist/canvas/pointerAdapter.d.ts +0 -3
  46. package/dist/canvas/pointerAdapter.js +0 -19
  47. package/dist/canvas/stampLayout.d.ts +0 -5
  48. package/dist/canvas/stampLayout.js +0 -14
  49. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  50. package/dist/canvas/tools/measurementStampTool.js +0 -37
  51. package/dist/canvas/tools/panTool.d.ts +0 -5
  52. package/dist/canvas/tools/panTool.js +0 -25
  53. package/dist/canvas/tools/penTool.d.ts +0 -13
  54. package/dist/canvas/tools/penTool.js +0 -68
  55. package/dist/canvas/tools/selectTool.d.ts +0 -2
  56. package/dist/canvas/tools/selectTool.js +0 -182
  57. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  58. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  59. package/dist/canvas/viewport.d.ts +0 -16
  60. package/dist/canvas/viewport.js +0 -54
  61. package/dist/data/AnnotationDataContext.d.ts +0 -8
  62. package/dist/data/AnnotationDataContext.js +0 -11
  63. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  64. package/dist/data/AnnotationDataProvider.js +0 -4
  65. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  66. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  67. package/dist/data/canvasPersistence.d.ts +0 -3
  68. package/dist/data/canvasPersistence.js +0 -26
  69. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  70. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  71. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  72. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  73. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  74. package/dist/data/hooks/useAnnotationList.js +0 -26
  75. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  76. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  77. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  78. package/dist/hooks/useParseMeasurement.js +0 -14
  79. package/dist/utils/evaluateFormula.d.ts +0 -20
  80. package/dist/utils/evaluateFormula.js +0 -31
@@ -0,0 +1,631 @@
1
+ // Nutrient (PSPDFKit) Instant JSON → PdfCanvas page states.
2
+ //
3
+ // Legacy PDF files were annotated in the Nutrient viewer, which saves its
4
+ // annotations as Instant JSON in `${folderPath}/annotations.json` next to the
5
+ // untouched source PDF. This converts that payload into the Skia canvas model
6
+ // (one PdfCanvasPageState per page) so the file can be exported by
7
+ // renderPdfCanvasToPdf — and later edited — without any Nutrient runtime.
8
+ //
9
+ // Pure: no Firestore, no Storage, no rendering. The caller supplies the PDF's
10
+ // page sizes (describePdfPages) and resolves tile content itself.
11
+ //
12
+ // Coordinate space. Instant JSON geometry is in PDF points on the DISPLAYED
13
+ // page (after its own /Rotate), origin top-left, y down — the same space a
14
+ // PdfCanvas page's doc space uses at user rotation 0, so points copy across
15
+ // unchanged.
16
+ //
17
+ // Two families of annotation live in the payload:
18
+ // - REEKON measurement tiles. Every annotation carrying `customData.groupId`
19
+ // belongs to a tile: an ANCHOR (line / rectangle / polygon / ellipse) that
20
+ // holds the binding and geometry, and on files last saved by the web viewer
21
+ // or by old app builds, the rendered tile IMAGE too. Tile images are always
22
+ // derived, so they are dropped; anchors become PlacedMeasurementRefs that
23
+ // resolve live against the job's measurements, exactly like a Skia tile.
24
+ // Mirrors boldr-pro lib/annotation/schema.ts (computeTileBbox,
25
+ // groupHeaderBannerSize, buildAnchorAnnotation).
26
+ // - Everything the user drew with Nutrient's tools (ink, shapes, text,
27
+ // text markup, stamps). These map onto Skia strokes and shapes where the
28
+ // model can express them; the rest is reported in `skipped`.
29
+ import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
30
+ import { emptyPdfCanvasPageState } from '../../annotation/canvas/pdfCanvasPages.js';
31
+ import { GROUP_HEADER_TILE_WIDTH, STAMP_TILE_SIZE, } from '../../annotation/canvas/stampLayout.js';
32
+ /**
33
+ * `tileDocSize` for a converted file. At STAMP_TILE_SIZE the renderer's
34
+ * doc-per-base-unit factor is 1, so a tile's `scale` reads directly as
35
+ * "Nutrient tile edge ÷ 96".
36
+ */
37
+ export const NUTRIENT_TILE_DOC_SIZE = STAMP_TILE_SIZE;
38
+ // Nutrient-side tile defaults (boldr-pro lib/annotation/schema.ts).
39
+ const NUTRIENT_DEFAULT_TILE_SCALE = 100;
40
+ const NUTRIENT_ANCHOR_COLOR = '#2492FB';
41
+ const NUTRIENT_ANCHOR_LINE_WIDTH = 8;
42
+ /** Segments used to approximate a Nutrient ellipse (the Skia `ellipse` shape
43
+ * renders as a circle, so true ellipses are emitted as closed polygons). */
44
+ const ELLIPSE_SEGMENTS = 48;
45
+ /** Alpha given to text-markup highlights and multiply-blended ink, which
46
+ * Nutrient draws opaque but multiplied into the page. */
47
+ const MULTIPLY_ALPHA = 0.4;
48
+ /* ------------------------------------------------------------------ *
49
+ * Small helpers
50
+ * ------------------------------------------------------------------ */
51
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
52
+ const toPoint = (v) => {
53
+ if (Array.isArray(v) && isNum(v[0]) && isNum(v[1]))
54
+ return [v[0], v[1]];
55
+ if (v && typeof v === 'object') {
56
+ const { x, y } = v;
57
+ if (isNum(x) && isNum(y))
58
+ return [x, y];
59
+ }
60
+ return null;
61
+ };
62
+ const toPoints = (v) => Array.isArray(v) ? v.map(toPoint).filter((p) => p !== null) : [];
63
+ const toBbox = (v) => Array.isArray(v) && v.length >= 4 && v.slice(0, 4).every(isNum)
64
+ ? [v[0], v[1], v[2], v[3]]
65
+ : null;
66
+ const vec = ([x, y]) => ({ x, y });
67
+ /** `#RRGGBB` + opacity → `#RRGGBBAA` (the renderer splits the alpha out). */
68
+ const withAlpha = (color, alpha) => {
69
+ const hex = color.trim().replace(/^#/, '');
70
+ const six = hex.length === 3 || hex.length === 4
71
+ ? hex
72
+ .slice(0, 3)
73
+ .split('')
74
+ .map((c) => c + c)
75
+ .join('')
76
+ : hex.slice(0, 6);
77
+ if (!/^[0-9a-fA-F]{6}$/.test(six))
78
+ return color;
79
+ const a = Math.max(0, Math.min(1, alpha));
80
+ if (a >= 1)
81
+ return `#${six}`;
82
+ return `#${six}${Math.round(a * 255)
83
+ .toString(16)
84
+ .padStart(2, '0')}`;
85
+ };
86
+ const opacityOf = (a) => isNum(a.opacity) ? Math.max(0, Math.min(1, a.opacity)) : 1;
87
+ const isHidden = (a) => Array.isArray(a.flags) &&
88
+ (a.flags.includes('hidden') || a.flags.includes('noView'));
89
+ /** Nutrient line caps that draw an arrowhead. The Skia model has one arrow
90
+ * style, so every arrow variant maps to it; other caps render plain. */
91
+ const capOf = (cap) => typeof cap === 'string' && /arrow/i.test(cap) ? 'arrow' : undefined;
92
+ const createdAtOf = (a) => {
93
+ const t = typeof a.createdAt === 'string' ? Date.parse(a.createdAt) : NaN;
94
+ return Number.isFinite(t) ? t : 0;
95
+ };
96
+ /** Plain text of a text annotation: Instant JSON v1 carries
97
+ * `{format: 'plain' | 'xhtml', value}`; older payloads a bare string. */
98
+ const textOf = (raw) => {
99
+ if (typeof raw === 'string')
100
+ return raw;
101
+ if (!raw || typeof raw !== 'object')
102
+ return '';
103
+ const { format, value } = raw;
104
+ if (typeof value !== 'string')
105
+ return '';
106
+ if (format !== 'xhtml')
107
+ return value;
108
+ return value
109
+ .replace(/<br\s*\/?>/gi, '\n')
110
+ .replace(/<\/p>\s*<p[^>]*>/gi, '\n')
111
+ .replace(/<[^>]+>/g, '')
112
+ .replace(/&nbsp;/g, ' ')
113
+ .replace(/&lt;/g, '<')
114
+ .replace(/&gt;/g, '>')
115
+ .replace(/&quot;/g, '"')
116
+ .replace(/&#39;/g, "'")
117
+ .replace(/&amp;/g, '&')
118
+ .trim();
119
+ };
120
+ /** Rough width of a line of text; only used to flag boxes that relied on
121
+ * Nutrient's word wrap (the Skia text shape breaks on newlines only). */
122
+ const estimatedTextWidth = (line, fontSize) => line.length * fontSize * 0.55;
123
+ const ellipsePolygon = (bbox, inset) => {
124
+ const [left, top, w, h] = bbox;
125
+ const cx = left + w / 2;
126
+ const cy = top + h / 2;
127
+ const rx = Math.max(0, w / 2 - inset);
128
+ const ry = Math.max(0, h / 2 - inset);
129
+ const pts = [];
130
+ for (let i = 0; i < ELLIPSE_SEGMENTS; i++) {
131
+ const t = (i / ELLIPSE_SEGMENTS) * Math.PI * 2;
132
+ pts.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) });
133
+ }
134
+ return pts;
135
+ };
136
+ /** Nutrient strokes a rectangle / ellipse INSIDE its bbox; the Skia shape
137
+ * strokes centred on the path, so the path is inset by half the width. */
138
+ const insetRect = (bbox, inset) => {
139
+ const [left, top, w, h] = bbox;
140
+ const dx = Math.min(inset, w / 2);
141
+ const dy = Math.min(inset, h / 2);
142
+ return [
143
+ { x: left + dx, y: top + dy },
144
+ { x: left + w - dx, y: top + h - dy },
145
+ ];
146
+ };
147
+ /** Style for a fill-only shape. The renderer always strokes, so the stroke is
148
+ * made fully transparent. */
149
+ const fillOnlyStyle = (fill) => ({
150
+ fill,
151
+ stroke: withAlpha(fill, 0),
152
+ strokeWidth: 0.01,
153
+ });
154
+ /* ------------------------------------------------------------------ *
155
+ * Tiles
156
+ * ------------------------------------------------------------------ */
157
+ const tileCustomDataOf = (a) => {
158
+ const cd = a.customData;
159
+ if (!cd || typeof cd !== 'object')
160
+ return null;
161
+ const { groupId } = cd;
162
+ return typeof groupId === 'string' && groupId ? cd : null;
163
+ };
164
+ /** Tile edge in PDF points as the mobile app renders it (tileScale()). */
165
+ const nutrientTileEdge = (cd) => {
166
+ const s = isNum(cd.mobileScale) ? cd.mobileScale : cd.scale;
167
+ return isNum(s) && s > 0 ? s : NUTRIENT_DEFAULT_TILE_SCALE;
168
+ };
169
+ /** Skia `scale` reproducing the Nutrient footprint at NUTRIENT_TILE_DOC_SIZE:
170
+ * a square tile is `edge` wide; an expanded header banner is `2 × edge` wide
171
+ * (groupHeaderBannerSize) against a 240-unit Skia header base. Collapsed
172
+ * headers use the same factor — both apps derive the collapsed box from the
173
+ * expanded one by the same 90/200 design ratio. */
174
+ const skiaTileScale = (cd) => {
175
+ const edge = nutrientTileEdge(cd);
176
+ return cd.isGroupHeader
177
+ ? (2 * edge) / GROUP_HEADER_TILE_WIDTH
178
+ : edge / STAMP_TILE_SIZE;
179
+ };
180
+ const convertTileAnchor = (a, cd, elementId) => {
181
+ const createdAt = createdAtOf(a);
182
+ const base = {
183
+ id: elementId,
184
+ layerId: DEFAULT_LAYER_ID,
185
+ groupId: cd.groupId,
186
+ ...(typeof cd.columnId === 'string' && cd.columnId && !cd.isBlank
187
+ ? { columnId: cd.columnId }
188
+ : {}),
189
+ ...(cd.isGroupHeader ? { isGroupHeader: true } : {}),
190
+ ...(cd.isGroupHeader && cd.collapsed ? { collapsed: true } : {}),
191
+ anchor: { x: 0, y: 0 },
192
+ scale: skiaTileScale(cd),
193
+ createdAt,
194
+ };
195
+ const lineColor = withAlpha(a.strokeColor || NUTRIENT_ANCHOR_COLOR, opacityOf(a));
196
+ const lineWidth = isNum(a.strokeWidth)
197
+ ? a.strokeWidth
198
+ : NUTRIENT_ANCHOR_LINE_WIDTH;
199
+ const hidden = isHidden(a) || cd.measurementMode === 'none';
200
+ if (a.type === 'pspdfkit/shape/line') {
201
+ const start = toPoint(a.startPoint);
202
+ const end = toPoint(a.endPoint);
203
+ if (!start || !end)
204
+ return null;
205
+ // computeTileBbox: a `left` tile sits on the end point, others centre.
206
+ const linePos = cd.measurementMode === 'left' ? 1 : 0.5;
207
+ const anchor = {
208
+ x: start[0] + (end[0] - start[0]) * linePos,
209
+ y: start[1] + (end[1] - start[1]) * linePos,
210
+ };
211
+ if (cd.isGroupHeader || hidden) {
212
+ const placed = { ...base, anchor };
213
+ if (!cd.isGroupHeader || hidden)
214
+ return { placed };
215
+ return {
216
+ placed,
217
+ leader: {
218
+ id: `${elementId}-leader`,
219
+ layerId: DEFAULT_LAYER_ID,
220
+ kind: 'line',
221
+ geometry: { points: [vec(start), vec(end)] },
222
+ style: { stroke: lineColor, strokeWidth: lineWidth },
223
+ createdAt,
224
+ },
225
+ };
226
+ }
227
+ return {
228
+ placed: {
229
+ ...base,
230
+ anchor,
231
+ placement: 'line',
232
+ line: { a: vec(start), b: vec(end) },
233
+ linePos,
234
+ lineColor,
235
+ lineWidth,
236
+ },
237
+ };
238
+ }
239
+ const bbox = toBbox(a.bbox);
240
+ if (!bbox)
241
+ return null;
242
+ const [left, top, w, h] = bbox;
243
+ const anchor = { x: left + w / 2, y: top + h / 2 };
244
+ // Headers centre their banner in the (hidden) grab rect; `none` tiles are a
245
+ // bare stamp; polygon / ellipse anchors have no Skia placement, so the
246
+ // outline is emitted separately (see convert()) and the tile sits bare.
247
+ if (cd.isGroupHeader || hidden || a.type !== 'pspdfkit/shape/rectangle') {
248
+ return { placed: { ...base, anchor } };
249
+ }
250
+ return {
251
+ placed: {
252
+ ...base,
253
+ anchor,
254
+ placement: 'rectangle',
255
+ rect: { a: { x: left, y: top }, b: { x: left + w, y: top + h } },
256
+ lineColor,
257
+ lineWidth,
258
+ },
259
+ };
260
+ };
261
+ const strokeStyleOf = (a) => {
262
+ const opacity = opacityOf(a);
263
+ const style = {
264
+ stroke: withAlpha(a.strokeColor || '#000000', opacity),
265
+ strokeWidth: isNum(a.strokeWidth) ? a.strokeWidth : 1,
266
+ };
267
+ if (Array.isArray(a.strokeDashArray) && a.strokeDashArray.length > 0) {
268
+ style.dash = true;
269
+ }
270
+ if (a.fillColor)
271
+ style.fill = withAlpha(a.fillColor, opacity);
272
+ return style;
273
+ };
274
+ const shape = (id, kind, points, style, createdAt, extra = {}) => ({
275
+ id,
276
+ layerId: DEFAULT_LAYER_ID,
277
+ kind,
278
+ geometry: { points },
279
+ style,
280
+ createdAt,
281
+ ...extra,
282
+ });
283
+ const convertDrawn = (a, id) => {
284
+ const createdAt = createdAtOf(a);
285
+ const opacity = opacityOf(a);
286
+ switch (a.type) {
287
+ case 'pspdfkit/ink': {
288
+ const segments = Array.isArray(a.lines?.points) ? a.lines.points : [];
289
+ const multiply = a.blendMode === 'multiply';
290
+ const color = withAlpha(a.strokeColor || '#000000', opacity);
291
+ const strokes = segments
292
+ .map(toPoints)
293
+ .filter((pts) => pts.length > 0)
294
+ .map((pts, i) => ({
295
+ id: `${id}-${i}`,
296
+ layerId: DEFAULT_LAYER_ID,
297
+ // The Skia highlighter draws at 0.3 alpha, the closest match to
298
+ // Nutrient's multiply-blended highlighter ink.
299
+ tool: multiply ? 'highlighter' : 'pen',
300
+ color,
301
+ width: isNum(a.lineWidth) ? a.lineWidth : 1,
302
+ cap: 'round',
303
+ // A single tap is one point; the renderer draws a dot for a
304
+ // zero-length two-point stroke.
305
+ points: (pts.length === 1 ? [pts[0], pts[0]] : pts).flat(),
306
+ createdAt,
307
+ }));
308
+ if (strokes.length === 0)
309
+ return 'Ink annotation has no points';
310
+ return { strokes };
311
+ }
312
+ case 'pspdfkit/shape/line': {
313
+ const start = toPoint(a.startPoint);
314
+ const end = toPoint(a.endPoint);
315
+ if (!start || !end)
316
+ return 'Line has no end points';
317
+ const style = strokeStyleOf(a);
318
+ delete style.fill; // Nutrient fills only closed arrowheads.
319
+ const endCap = capOf(a.lineCaps?.end);
320
+ const startCap = capOf(a.lineCaps?.start);
321
+ if (endCap)
322
+ style.cap = endCap;
323
+ if (startCap)
324
+ style.startCap = startCap;
325
+ return {
326
+ shapes: [shape(id, 'line', [vec(start), vec(end)], style, createdAt)],
327
+ };
328
+ }
329
+ case 'pspdfkit/shape/rectangle':
330
+ case 'pspdfkit/shape/ellipse': {
331
+ const bbox = toBbox(a.bbox);
332
+ if (!bbox)
333
+ return 'Shape has no bounding box';
334
+ const style = strokeStyleOf(a);
335
+ const inset = (style.strokeWidth ?? 0) / 2;
336
+ if (a.type === 'pspdfkit/shape/rectangle') {
337
+ return {
338
+ shapes: [shape(id, 'rect', insetRect(bbox, inset), style, createdAt)],
339
+ };
340
+ }
341
+ const points = ellipsePolygon(bbox, inset);
342
+ return {
343
+ shapes: [
344
+ shape(id, 'polygon', points, style, createdAt, {
345
+ geometry: { points, closed: true },
346
+ }),
347
+ ],
348
+ };
349
+ }
350
+ case 'pspdfkit/shape/polygon':
351
+ case 'pspdfkit/shape/polyline': {
352
+ const pts = toPoints(a.points).map(vec);
353
+ if (pts.length < 2)
354
+ return 'Shape has fewer than two points';
355
+ const closed = a.type === 'pspdfkit/shape/polygon';
356
+ const style = strokeStyleOf(a);
357
+ if (!closed) {
358
+ delete style.fill;
359
+ const endCap = capOf(a.lineCaps?.end);
360
+ const startCap = capOf(a.lineCaps?.start);
361
+ if (endCap)
362
+ style.cap = endCap;
363
+ if (startCap)
364
+ style.startCap = startCap;
365
+ }
366
+ return {
367
+ shapes: [
368
+ shape(id, 'polygon', pts, style, createdAt, {
369
+ geometry: { points: pts, closed },
370
+ }),
371
+ ],
372
+ };
373
+ }
374
+ case 'pspdfkit/text': {
375
+ const bbox = toBbox(a.bbox);
376
+ const text = textOf(a.text);
377
+ if (!bbox)
378
+ return 'Text has no bounding box';
379
+ if (!text.trim())
380
+ return 'Text annotation is empty';
381
+ const [left, top, w, h] = bbox;
382
+ const fontSize = isNum(a.fontSize) && a.fontSize > 0 ? a.fontSize : 12;
383
+ const shapes = [];
384
+ if (a.backgroundColor) {
385
+ shapes.push(shape(`${id}-bg`, 'rect', [
386
+ { x: left, y: top },
387
+ { x: left + w, y: top + h },
388
+ ], fillOnlyStyle(withAlpha(a.backgroundColor, opacity)), createdAt));
389
+ }
390
+ // Nutrient `rotation` is counter-clockwise in quarter turns and `bbox`
391
+ // is the ROTATED box. The text shape rotates clockwise about its
392
+ // origin, so the origin is the unrotated box's top-left corner as it
393
+ // lands after the turn (see textRotationMatrix in export/pdf/marks.ts).
394
+ const ccw = isNum(a.rotation)
395
+ ? (((Math.round(a.rotation / 90) * 90) % 360) + 360) % 360
396
+ : 0;
397
+ const origin = ccw === 90
398
+ ? { x: left, y: top + h }
399
+ : ccw === 180
400
+ ? { x: left + w, y: top + h }
401
+ : ccw === 270
402
+ ? { x: left + w, y: top }
403
+ : { x: left, y: top };
404
+ // Unrotated, the text runs along the box's long side at 90/270.
405
+ const runLength = ccw === 90 || ccw === 270 ? h : w;
406
+ shapes.push(shape(id, 'text', [origin], {
407
+ stroke: withAlpha(a.fontColor || '#000000', opacity),
408
+ fontSize,
409
+ }, createdAt, {
410
+ text,
411
+ ...(ccw
412
+ ? { geometry: { points: [origin], rotation: 360 - ccw } }
413
+ : {}),
414
+ }));
415
+ const wraps = text
416
+ .split('\n')
417
+ .some((line) => estimatedTextWidth(line, fontSize) > runLength * 1.05);
418
+ const warning = ccw
419
+ ? 'Rotated text is exported rotated but shows upright in the Skia editor'
420
+ : wraps
421
+ ? 'Text relied on word wrap; lines only break at explicit newlines'
422
+ : undefined;
423
+ return { shapes, warning };
424
+ }
425
+ case 'pspdfkit/markup/highlight':
426
+ case 'pspdfkit/markup/underline':
427
+ case 'pspdfkit/markup/strikeout':
428
+ case 'pspdfkit/markup/squiggly': {
429
+ const rects = Array.isArray(a.rects)
430
+ ? a.rects.map(toBbox).filter((r) => r !== null)
431
+ : [];
432
+ if (rects.length === 0)
433
+ return 'Markup has no rects';
434
+ const color = a.color || a.strokeColor || '#FFFF00';
435
+ const shapes = rects.map(([left, top, w, h], i) => {
436
+ const sid = `${id}-${i}`;
437
+ if (a.type === 'pspdfkit/markup/highlight') {
438
+ return shape(sid, 'rect', [
439
+ { x: left, y: top },
440
+ { x: left + w, y: top + h },
441
+ ], fillOnlyStyle(withAlpha(color, opacity * MULTIPLY_ALPHA)), createdAt);
442
+ }
443
+ const y = a.type === 'pspdfkit/markup/strikeout' ? top + h / 2 : top + h * 0.92;
444
+ return shape(sid, 'line', [
445
+ { x: left, y },
446
+ { x: left + w, y },
447
+ ], {
448
+ stroke: withAlpha(color, opacity),
449
+ strokeWidth: Math.max(0.75, h * 0.07),
450
+ cap: 'butt',
451
+ }, createdAt);
452
+ });
453
+ return {
454
+ shapes,
455
+ warning: a.type === 'pspdfkit/markup/squiggly'
456
+ ? 'Squiggly underline is drawn straight'
457
+ : undefined,
458
+ };
459
+ }
460
+ case 'pspdfkit/stamp': {
461
+ const bbox = toBbox(a.bbox);
462
+ if (!bbox)
463
+ return 'Stamp has no bounding box';
464
+ const [left, top, w, h] = bbox;
465
+ const label = [a.title || a.stampType, a.subtitle]
466
+ .filter((s) => typeof s === 'string' && s.length > 0)
467
+ .join('\n');
468
+ const color = withAlpha(a.color || '#2492FB', opacity);
469
+ const fontSize = Math.max(6, Math.min(h / (label.includes('\n') ? 2.6 : 1.6), 36));
470
+ return {
471
+ shapes: [
472
+ shape(`${id}-border`, 'rect', insetRect(bbox, 1), { stroke: color, strokeWidth: 2 }, createdAt),
473
+ ...(label
474
+ ? [
475
+ shape(id, 'text', [
476
+ {
477
+ x: left + 4,
478
+ y: top + Math.max(0, (h - fontSize * 1.2) / 2),
479
+ },
480
+ ], { stroke: color, fontSize }, createdAt, { text: label }),
481
+ ]
482
+ : []),
483
+ ],
484
+ warning: `Stamp drawn as a labelled box (${w.toFixed(0)}×${h.toFixed(0)} pt)`,
485
+ };
486
+ }
487
+ default:
488
+ return `Unsupported annotation type`;
489
+ }
490
+ };
491
+ /* ------------------------------------------------------------------ *
492
+ * Entry point
493
+ * ------------------------------------------------------------------ */
494
+ const SKIP_REASONS = {
495
+ 'pspdfkit/image': 'Image annotations are not supported yet',
496
+ 'pspdfkit/note': 'Sticky notes have no canvas equivalent',
497
+ 'pspdfkit/comment-marker': 'Comments have no canvas equivalent',
498
+ 'pspdfkit/link': 'Links have no canvas equivalent',
499
+ 'pspdfkit/widget': 'Form fields live in the PDF itself and are kept as-is',
500
+ 'pspdfkit/markup/redaction': 'Unapplied redactions are not converted',
501
+ 'pspdfkit/media': 'Media annotations are not supported',
502
+ 'pspdfkit/file': 'File attachments are not supported',
503
+ 'pspdfkit/sound': 'Sound annotations are not supported',
504
+ };
505
+ export const instantJsonToPdfCanvas = (input) => {
506
+ const now = input.now ?? Date.now();
507
+ const raw = input.instantJson
508
+ ?.annotations;
509
+ const annotations = Array.isArray(raw)
510
+ ? raw.filter((a) => a && typeof a === 'object')
511
+ : [];
512
+ const result = {
513
+ pageStates: {},
514
+ tileDocSize: NUTRIENT_TILE_DOC_SIZE,
515
+ converted: {},
516
+ droppedTileImages: 0,
517
+ skipped: [],
518
+ warnings: [],
519
+ };
520
+ const usedIds = new Set();
521
+ const stateFor = (pageIndex) => {
522
+ let state = result.pageStates[pageIndex];
523
+ if (!state) {
524
+ state = { ...emptyPdfCanvasPageState(pageIndex), updatedAt: now };
525
+ result.pageStates[pageIndex] = state;
526
+ }
527
+ return state;
528
+ };
529
+ const count = (type) => {
530
+ result.converted[type] = (result.converted[type] ?? 0) + 1;
531
+ };
532
+ // Mobile keys tiles by `customData.id` and keeps one image per id, drawn at
533
+ // the LAST anchor carrying it (reconcileTile sweeps the others' images). A
534
+ // copied or rebound anchor can leave duplicates; draw what the app draws.
535
+ const lastAnchorOfTile = new Map();
536
+ annotations.forEach((a, index) => {
537
+ const cd = tileCustomDataOf(a);
538
+ if (cd &&
539
+ typeof cd.id === 'string' &&
540
+ cd.id &&
541
+ a.type !== 'pspdfkit/image') {
542
+ lastAnchorOfTile.set(cd.id, index);
543
+ }
544
+ });
545
+ annotations.forEach((a, index) => {
546
+ const type = typeof a.type === 'string' ? a.type : 'unknown';
547
+ const sourceId = (typeof a.id === 'string' && a.id) ||
548
+ (typeof a.name === 'string' && a.name) ||
549
+ `#${index}`;
550
+ const note = (reason, extra) => ({ id: sourceId, type, pageIndex: a.pageIndex, reason, ...extra });
551
+ const skip = (reason, extra) => result.skipped.push(note(reason, extra));
552
+ const pageIndex = a.pageIndex;
553
+ if (!isNum(pageIndex) || !Number.isInteger(pageIndex)) {
554
+ skip('Annotation has no page index');
555
+ return;
556
+ }
557
+ if (!input.pages[pageIndex]) {
558
+ skip(`Page ${pageIndex + 1} does not exist in the PDF`);
559
+ return;
560
+ }
561
+ // Unique element id per page state (web payloads can repeat a tile's
562
+ // customData id on its anchor and image).
563
+ let elementId = `nutrient-${sourceId.replace(/^#/, 'i')}`;
564
+ for (let n = 2; usedIds.has(elementId); n++) {
565
+ elementId = `nutrient-${sourceId.replace(/^#/, 'i')}-${n}`;
566
+ }
567
+ const tile = tileCustomDataOf(a);
568
+ if (tile) {
569
+ if (type === 'pspdfkit/image') {
570
+ result.droppedTileImages++;
571
+ return;
572
+ }
573
+ if (typeof tile.id === 'string' &&
574
+ tile.id &&
575
+ lastAnchorOfTile.get(tile.id) !== index) {
576
+ skip('Duplicate tile anchor; the app draws only one tile per tile id');
577
+ return;
578
+ }
579
+ const converted = convertTileAnchor(a, tile, elementId);
580
+ if (!converted) {
581
+ skip('Measurement tile anchor has no geometry');
582
+ return;
583
+ }
584
+ usedIds.add(elementId);
585
+ const state = stateFor(pageIndex);
586
+ state.placedMeasurements.push(converted.placed);
587
+ if (converted.leader)
588
+ state.shapes.push(converted.leader);
589
+ // Polygon / ellipse anchors: keep the drawn outline.
590
+ if (!isHidden(a) &&
591
+ tile.measurementMode !== 'none' &&
592
+ (type === 'pspdfkit/shape/polygon' || type === 'pspdfkit/shape/ellipse')) {
593
+ const outline = convertDrawn(a, `${elementId}-outline`);
594
+ if (typeof outline !== 'string') {
595
+ state.shapes.push(...(outline.shapes ?? []));
596
+ }
597
+ }
598
+ count(tile.isGroupHeader ? 'tile/header' : 'tile/measurement');
599
+ return;
600
+ }
601
+ if (isHidden(a)) {
602
+ skip('Hidden annotation');
603
+ return;
604
+ }
605
+ const known = SKIP_REASONS[type];
606
+ if (known) {
607
+ const text = type === 'pspdfkit/note' || type === 'pspdfkit/comment-marker'
608
+ ? textOf(a.text) || undefined
609
+ : undefined;
610
+ skip(type === 'pspdfkit/image' && a.isSignature
611
+ ? 'Image signatures are not supported yet'
612
+ : known, text ? { text } : undefined);
613
+ return;
614
+ }
615
+ const drawn = convertDrawn(a, elementId);
616
+ if (typeof drawn === 'string') {
617
+ skip(drawn);
618
+ return;
619
+ }
620
+ usedIds.add(elementId);
621
+ const state = stateFor(pageIndex);
622
+ if (drawn.strokes)
623
+ state.strokes.push(...drawn.strokes);
624
+ if (drawn.shapes)
625
+ state.shapes.push(...drawn.shapes);
626
+ if (drawn.warning)
627
+ result.warnings.push(note(drawn.warning));
628
+ count(type);
629
+ });
630
+ return result;
631
+ };
@@ -1,4 +1,4 @@
1
- export { renderCanvasToPdf, renderPdfCanvasToPdf, } from './renderCanvasToPdf.js';
1
+ export { renderCanvasToPdf, renderPdfCanvasToPdf, describePdfPages, } from './renderCanvasToPdf.js';
2
2
  export { stripPdfEncryption, type StripPdfEncryptionOptions, type StripPdfEncryptionResult, } from './decrypt.js';
3
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, type AffineMatrix, type PageRotation, type PdfBox, } from './pageMapping.js';
4
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, type TileFileInfo, type TileResolutionContext, } from './tileContent.js';
@@ -1,4 +1,4 @@
1
- export { renderCanvasToPdf, renderPdfCanvasToPdf, } from './renderCanvasToPdf.js';
1
+ export { renderCanvasToPdf, renderPdfCanvasToPdf, describePdfPages, } from './renderCanvasToPdf.js';
2
2
  export { stripPdfEncryption, } from './decrypt.js';
3
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, } from './pageMapping.js';
4
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, } from './tileContent.js';