@reekon-tools/boldr-utils 1.26.2 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +14 -11
- package/dist/annotation/canvas/Tool.d.ts +1 -1
- package/dist/annotation/canvas/tools/selectTool.js +35 -10
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +3 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +40 -4
- package/dist/export/nutrient/instantJsonToPdfCanvas.d.ts +39 -0
- package/dist/export/nutrient/instantJsonToPdfCanvas.js +631 -0
- package/dist/export/pdf/index.d.ts +1 -1
- package/dist/export/pdf/index.js +1 -1
- package/dist/export/pdf/marks.js +46 -4
- package/dist/export/pdf/renderCanvasToPdf.d.ts +9 -0
- package/dist/export/pdf/renderCanvasToPdf.js +14 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
15
15
|
activeToolId: string;
|
|
16
16
|
selection: Selection | null;
|
|
17
17
|
onSelectionChange(selection: Selection | null): void;
|
|
18
|
+
onViewportChange?(viewport: ViewportState): void;
|
|
18
19
|
measurements?: Measurement[];
|
|
19
20
|
fallbackUnit?: Units;
|
|
20
21
|
fractionalTolerance?: FractionalTolerance;
|
|
@@ -5,7 +5,6 @@ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeom
|
|
|
5
5
|
import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
|
|
6
6
|
import { visualShapeBounds } from './shapeGeometry.js';
|
|
7
7
|
import { backgroundCropBars, backgroundLayerDocRect, backgroundLayersOf, isBackgroundLayerLocked, } from './backgroundLayers.js';
|
|
8
|
-
import { YellowColors } from '../../theme/colors.js';
|
|
9
8
|
import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
|
|
10
9
|
import { PlaneElement, PLANE_CHROME_COLOR, PLANE_GRID_COLOR, PLANE_GRID_OPACITY, PLANE_GRID_WIDTH, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
|
|
11
10
|
import { ShapeElement } from './elements/ShapeElement.js';
|
|
@@ -26,10 +25,6 @@ const SELECTION_STROKE = 1.5;
|
|
|
26
25
|
// the target the dragged endpoint is currently locked onto. Orange so it reads
|
|
27
26
|
// against both the blue selection chrome and the yellow lock accent.
|
|
28
27
|
const SNAP_INDICATOR_COLOR = '#FF7A00';
|
|
29
|
-
// Outline of a selected LOCKED background layer: the lock accent (the same
|
|
30
|
-
// yellow the selected group-header border uses), distinct from the blue
|
|
31
|
-
// manipulable-selection chrome — locked means "selected, but pinned".
|
|
32
|
-
const BACKGROUND_LOCKED_COLOR = YellowColors.mainYellow;
|
|
33
28
|
// Link badge on a LINKED background layer (layer.fileId — an expanded file
|
|
34
29
|
// tile, see fileTileSwap.ts): a chain glyph in a white disc at the layer's
|
|
35
30
|
// top-right, saying "this is a group file's image, not a plain background".
|
|
@@ -310,16 +305,24 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
310
305
|
// its opposite corner) plus four edge crop bars (raster layers only —
|
|
311
306
|
// dragging a bar windows the image non-destructively). Chrome tracks
|
|
312
307
|
// the live drag/resize transform the same way the layer itself does
|
|
313
|
-
// above. A LOCKED layer (backgroundLayerIsLocked) keeps the
|
|
314
|
-
// it is selected, and the host's chrome offers the lock
|
|
315
|
-
//
|
|
316
|
-
//
|
|
308
|
+
// above. A LOCKED layer (backgroundLayerIsLocked) keeps the same blue
|
|
309
|
+
// outline — it is selected, and the host's chrome offers the lock
|
|
310
|
+
// toggle — but no handles or bars: it cannot move, scale or crop, so
|
|
311
|
+
// a grabbable gizmo would lie. During a native crop-bar
|
|
317
312
|
// drag the chrome is just the LIVE outline — handles and bars would
|
|
318
313
|
// sit on stale committed edges.
|
|
319
314
|
const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
|
|
320
315
|
if (layer) {
|
|
316
|
+
// The outline is screen-constant (the handle ring width — 2px /
|
|
317
|
+
// zoom, a number on web and a derived value on native), not the
|
|
318
|
+
// doc-unit SELECTION_STROKE: a background is often huge in doc
|
|
319
|
+
// space and viewed zoomed out, where a doc-unit stroke thins to
|
|
320
|
+
// nothing — and for a LOCKED layer this border is the only
|
|
321
|
+
// selected indicator. Doc units only when the host passes no
|
|
322
|
+
// handle sizing (no drag-select tool).
|
|
323
|
+
const outlineWidth = handleRingWidth ?? SELECTION_STROKE;
|
|
321
324
|
if (layer.id === bgCropId && liveBgCrop) {
|
|
322
|
-
return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth:
|
|
325
|
+
return (_jsx(Rect, { x: liveBgCrop.x, y: liveBgCrop.y, width: liveBgCrop.width, height: liveBgCrop.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: outlineWidth }));
|
|
323
326
|
}
|
|
324
327
|
const locked = isBackgroundLayerLocked(effectiveCanvas.viewport, layer.id);
|
|
325
328
|
const r = backgroundLayerDocRect(layer);
|
|
@@ -331,7 +334,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
331
334
|
{ x: r.x, y: r.y + r.height },
|
|
332
335
|
{ x: r.x + r.width, y: r.y + r.height },
|
|
333
336
|
];
|
|
334
|
-
return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color:
|
|
337
|
+
return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: outlineWidth }), !locked &&
|
|
335
338
|
layer.format !== 'svg' &&
|
|
336
339
|
backgroundCropBars(layer).map((bar) => {
|
|
337
340
|
const inset = Math.min(bar.width, bar.height) * 0.18;
|
|
@@ -87,7 +87,7 @@ export interface DragSelectionConfig {
|
|
|
87
87
|
} | null;
|
|
88
88
|
buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
89
89
|
isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number, headerTileScaleFactor?: number, fileTileScaleFactor?: number): boolean;
|
|
90
|
-
hitTestBackground?(doc: AnnotationCanvasState, world: Vec2): {
|
|
90
|
+
hitTestBackground?(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
|
|
91
91
|
id: AnnotationElementId;
|
|
92
92
|
locked: boolean;
|
|
93
93
|
} | null;
|
|
@@ -488,7 +488,7 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
|
|
|
488
488
|
// DragSelectionConfig AND the web pointer handlers — one source of truth).
|
|
489
489
|
// Backgrounds can cover the whole document, so they never join the general
|
|
490
490
|
// hit-test: a tap selects one only when every stroke/shape/measurement
|
|
491
|
-
// misses, and a drag translates one only when it is ALREADY selected — the
|
|
491
|
+
// misses AND it lands on the layer's perimeter (findBackgroundHit), and a drag translates one only when it is ALREADY selected — the
|
|
492
492
|
// layout tool's two-step model, which keeps a full-bleed background from
|
|
493
493
|
// hijacking pan/marquee gestures. A LOCKED layer (backgroundLayerIsLocked —
|
|
494
494
|
// by default the first image, which IS the canvas) is selectable like any
|
|
@@ -502,16 +502,38 @@ const pointInBackgroundLayer = (layer, p) => {
|
|
|
502
502
|
const r = backgroundLayerDocRect(layer);
|
|
503
503
|
return (p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height);
|
|
504
504
|
};
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
505
|
+
// Screen-px half-width of the edge band that selects an UNSELECTED background
|
|
506
|
+
// (converted to doc space via zoom). A zoomed-in background often fills the
|
|
507
|
+
// whole viewport, so a tap anywhere on it selecting the image made selection
|
|
508
|
+
// accidental — and a selected background's chrome gets in the way of placing
|
|
509
|
+
// more objects. Selecting now takes a deliberate tap on the image's perimeter:
|
|
510
|
+
// within this distance of the visible (cropped) rect's outline, inside or out.
|
|
511
|
+
// Once selected, the whole rect is live again (findSelectedBackgroundHit), so
|
|
512
|
+
// body drags and the tap-toggle deselect are unchanged.
|
|
513
|
+
const BACKGROUND_EDGE_GRAB_PX = 24;
|
|
514
|
+
// Topmost background layer whose perimeter band contains a world point
|
|
515
|
+
// (reverse array order — later layers draw on top). A point in a layer's
|
|
516
|
+
// interior (past the band) is occluded by that layer: it never falls through
|
|
517
|
+
// to the edge of a layer drawn beneath it, whose outline is hidden there. A
|
|
518
|
+
// layer smaller than two band-widths on screen is all band, so it stays
|
|
519
|
+
// selectable anywhere on it.
|
|
520
|
+
const findBackgroundHit = (doc, world, zoom) => {
|
|
521
|
+
const band = BACKGROUND_EDGE_GRAB_PX / zoom;
|
|
509
522
|
const layers = backgroundLayersOf(doc.viewport);
|
|
510
523
|
for (let i = layers.length - 1; i >= 0; i--) {
|
|
511
524
|
const layer = layers[i];
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
525
|
+
const r = backgroundLayerDocRect(layer);
|
|
526
|
+
const left = world.x - r.x;
|
|
527
|
+
const right = r.x + r.width - world.x;
|
|
528
|
+
const top = world.y - r.y;
|
|
529
|
+
const bottom = r.y + r.height - world.y;
|
|
530
|
+
// Outside the band-expanded rect: this layer is not under the point.
|
|
531
|
+
if (Math.min(left, right, top, bottom) < -band)
|
|
532
|
+
continue;
|
|
533
|
+
// Inside the band-inset rect: the layer's interior covers the point.
|
|
534
|
+
if (Math.min(left, right, top, bottom) > band)
|
|
535
|
+
return null;
|
|
536
|
+
return { id: layer.id, locked: backgroundLayerIsLocked(layer, i) };
|
|
515
537
|
}
|
|
516
538
|
return null;
|
|
517
539
|
};
|
|
@@ -907,11 +929,14 @@ export const createSelectTool = (options = {}) => ({
|
|
|
907
929
|
delta: { x: 0, y: 0 },
|
|
908
930
|
};
|
|
909
931
|
}
|
|
910
|
-
// An unselected background selects
|
|
932
|
+
// An unselected background selects — but only from its perimeter
|
|
933
|
+
// band (findBackgroundHit); its interior behaves like empty canvas,
|
|
934
|
+
// so a full-bleed image never gets selected by accident. A free one
|
|
935
|
+
// consumes the gesture
|
|
911
936
|
// (no pan, no drag): translating it takes a second gesture once
|
|
912
937
|
// selected, the layout tool's two-step model. A locked one is the
|
|
913
938
|
// canvas: it selects AND the drag pans, as on empty canvas.
|
|
914
|
-
const bg = findBackgroundHit(ctx.document, world);
|
|
939
|
+
const bg = findBackgroundHit(ctx.document, world, zoom);
|
|
915
940
|
if (bg) {
|
|
916
941
|
ctx.setSelection({ ids: [bg.id] });
|
|
917
942
|
if (bg.locked && options.panOnEmptyDrag) {
|
|
@@ -14,6 +14,8 @@ export interface AnnotationCanvasHandle {
|
|
|
14
14
|
redo(): void;
|
|
15
15
|
canUndo(): boolean;
|
|
16
16
|
canRedo(): boolean;
|
|
17
|
+
zoomBy(factor: number, focalScreen?: Vec2): void;
|
|
18
|
+
getZoom(): number;
|
|
17
19
|
zoomToFit(opts?: {
|
|
18
20
|
animated?: boolean;
|
|
19
21
|
durationMs?: number;
|
|
@@ -72,6 +74,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
72
74
|
activeToolId: string;
|
|
73
75
|
selection: Selection | null;
|
|
74
76
|
onSelectionChange(selection: Selection | null): void;
|
|
77
|
+
onViewportChange?(viewport: ViewportState): void;
|
|
75
78
|
measurements?: Measurement[];
|
|
76
79
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
77
80
|
requestTextInput?: RequestTextInput;
|
|
@@ -83,7 +83,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
|
|
|
83
83
|
// inners share this hook; each wraps it with platform-specific event
|
|
84
84
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
85
85
|
export const useAnnotationCanvasState = (props) => {
|
|
86
|
-
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
86
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, onViewportChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
87
87
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
88
88
|
const [toolState, setToolState] = useState(undefined);
|
|
89
89
|
// Synchronous mirror for the DISPATCHERS. The native tap gesture
|
|
@@ -127,7 +127,17 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
127
127
|
// (Adding a background mid-session also fits it, so you see the whole image
|
|
128
128
|
// you just dropped in.) Canvases that never gain a background keep the 1:1
|
|
129
129
|
// default — for a screen-sized document that already frames it correctly.
|
|
130
|
-
|
|
130
|
+
//
|
|
131
|
+
// Pre-latched in two cases, both "someone already framed the view":
|
|
132
|
+
// - The host passed `initialViewport` — it owns framing (the paged PDF
|
|
133
|
+
// editor fits each page from the file record at mount, long before the
|
|
134
|
+
// page's annotation doc hydrates).
|
|
135
|
+
// - The user (or the host, through the handle) has already moved the
|
|
136
|
+
// viewport this mount. The doc can hydrate mid-gesture — a page turn's
|
|
137
|
+
// snapshot lands while the user is already wheel-zooming — and a late fit
|
|
138
|
+
// here would stomp that viewport, which reads as zoom pivoting around a
|
|
139
|
+
// broken focal point. Every pan/zoom/fit path below latches the ref.
|
|
140
|
+
const didInitialFitRef = useRef(!!initialViewport);
|
|
131
141
|
useEffect(() => {
|
|
132
142
|
if (didInitialFitRef.current)
|
|
133
143
|
return;
|
|
@@ -257,9 +267,11 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
257
267
|
: Promise.resolve(null);
|
|
258
268
|
},
|
|
259
269
|
applyPan(deltaScreen) {
|
|
270
|
+
didInitialFitRef.current = true;
|
|
260
271
|
setViewport((v) => panBy(v, deltaScreen));
|
|
261
272
|
},
|
|
262
273
|
applyZoom(focalScreen, nextZoom) {
|
|
274
|
+
didInitialFitRef.current = true;
|
|
263
275
|
setViewport((v) => zoomAt(v, focalScreen, nextZoom, zoomLimitsRef.current));
|
|
264
276
|
},
|
|
265
277
|
}), [
|
|
@@ -369,18 +381,23 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
369
381
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
370
382
|
}, [activeTool, ctx]);
|
|
371
383
|
const pan = useCallback((deltaScreen) => {
|
|
384
|
+
didInitialFitRef.current = true;
|
|
372
385
|
setViewport((v) => panBy(v, deltaScreen));
|
|
373
386
|
}, []);
|
|
374
387
|
const zoom = useCallback((focalScreen, nextZoom) => {
|
|
388
|
+
didInitialFitRef.current = true;
|
|
375
389
|
setViewport((v) => zoomAt(v, focalScreen, nextZoom, zoomLimitsRef.current));
|
|
376
390
|
}, []);
|
|
377
391
|
// `v.zoom * factor` inside the updater, so a burst of events that React
|
|
378
392
|
// batches into one commit composes instead of each recomputing the same
|
|
379
|
-
// target from the same stale base. See `zoomBy` on the API type.
|
|
393
|
+
// target from the same stale base. See `zoomBy` on the API type. Clamped
|
|
394
|
+
// like the other zoom paths — an unclamped wheel burst could sail past the
|
|
395
|
+
// document floor/ceiling.
|
|
380
396
|
const zoomBy = useCallback((focalScreen, factor) => {
|
|
381
397
|
if (!Number.isFinite(factor) || factor <= 0)
|
|
382
398
|
return;
|
|
383
|
-
|
|
399
|
+
didInitialFitRef.current = true;
|
|
400
|
+
setViewport((v) => zoomAt(v, focalScreen, v.zoom * factor, zoomLimitsRef.current));
|
|
384
401
|
}, []);
|
|
385
402
|
// Imperative API mirror — set via prop so it survives WithSkiaWeb's lazy
|
|
386
403
|
// boundary on web.
|
|
@@ -445,6 +462,12 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
445
462
|
return c.viewport.screenToWorld({ x: cx, y: cy });
|
|
446
463
|
};
|
|
447
464
|
imperativeRef.current = {
|
|
465
|
+
zoomBy(factor, focalScreen) {
|
|
466
|
+
zoomBy(focalScreen ?? { x: width / 2, y: height / 2 }, factor);
|
|
467
|
+
},
|
|
468
|
+
getZoom() {
|
|
469
|
+
return ctxRef.current.viewport.state.zoom;
|
|
470
|
+
},
|
|
448
471
|
undo() {
|
|
449
472
|
const entry = undoStackRef.current.pop();
|
|
450
473
|
if (!entry)
|
|
@@ -466,6 +489,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
466
489
|
return redoStackRef.current.length > 0;
|
|
467
490
|
},
|
|
468
491
|
zoomToFit(opts) {
|
|
492
|
+
didInitialFitRef.current = true;
|
|
469
493
|
// The document is read through ctxRef, not the effect closure: this
|
|
470
494
|
// effect's deps deliberately exclude `canvas`, so the closed-over
|
|
471
495
|
// binding can predate tiles that landed or moved since — and the fit
|
|
@@ -479,6 +503,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
479
503
|
}
|
|
480
504
|
},
|
|
481
505
|
resetView() {
|
|
506
|
+
didInitialFitRef.current = true;
|
|
482
507
|
// "Reset view" frames the whole document/image again — the same fit the
|
|
483
508
|
// canvas opens with — rather than snapping to a 1:1 top-left view, which
|
|
484
509
|
// for a high-res background is the very crop this is meant to escape.
|
|
@@ -487,6 +512,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
487
512
|
fitContentToRect(rect, opts) {
|
|
488
513
|
if (!(rect.width > 0) || !(rect.height > 0))
|
|
489
514
|
return;
|
|
515
|
+
didInitialFitRef.current = true;
|
|
490
516
|
const target = computeContentFitRect(ctxRef.current.document, rect);
|
|
491
517
|
if (opts?.animated && animateViewport) {
|
|
492
518
|
animateViewport(target, opts.durationMs ?? 450);
|
|
@@ -498,6 +524,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
498
524
|
fitDocRect(rect, opts) {
|
|
499
525
|
if (!(width > 0) || !(height > 0))
|
|
500
526
|
return;
|
|
527
|
+
didInitialFitRef.current = true;
|
|
501
528
|
const limits = zoomLimitsRef.current;
|
|
502
529
|
const target = fitDocRectViewport(rect, width, height, {
|
|
503
530
|
paddingPx: opts?.paddingPx,
|
|
@@ -844,7 +871,16 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
844
871
|
canvas.viewport.height,
|
|
845
872
|
width,
|
|
846
873
|
height,
|
|
874
|
+
zoomBy,
|
|
847
875
|
]);
|
|
876
|
+
// Viewport subscription for hosts (zoom readouts and the like). A ref keeps
|
|
877
|
+
// the effect off the callback identity — hosts often pass a fresh closure
|
|
878
|
+
// every render.
|
|
879
|
+
const onViewportChangeRef = useRef(onViewportChange);
|
|
880
|
+
onViewportChangeRef.current = onViewportChange;
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
onViewportChangeRef.current?.(viewport);
|
|
883
|
+
}, [viewport]);
|
|
848
884
|
const worldTransform = useMemo(() => [
|
|
849
885
|
{ scale: viewport.zoom },
|
|
850
886
|
{ translateX: -viewport.pan.x },
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PdfCanvasPage, PdfCanvasPageState } from '../../types/pdfCanvas.js';
|
|
2
|
+
/**
|
|
3
|
+
* `tileDocSize` for a converted file. At STAMP_TILE_SIZE the renderer's
|
|
4
|
+
* doc-per-base-unit factor is 1, so a tile's `scale` reads directly as
|
|
5
|
+
* "Nutrient tile edge ÷ 96".
|
|
6
|
+
*/
|
|
7
|
+
export declare const NUTRIENT_TILE_DOC_SIZE = 96;
|
|
8
|
+
export interface NutrientSkippedAnnotation {
|
|
9
|
+
/** Instant JSON id / name, or `#index` when it has neither. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Instant JSON `type`, e.g. `pspdfkit/note`. */
|
|
12
|
+
type: string;
|
|
13
|
+
pageIndex?: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
/** Text the annotation carried (notes, comments), so nothing is lost silently. */
|
|
16
|
+
text?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface InstantJsonConversion {
|
|
19
|
+
/** Page states by page index; only pages that received content appear. */
|
|
20
|
+
pageStates: Record<number, PdfCanvasPageState>;
|
|
21
|
+
/** Value for `PdfCanvasData.tileDocSize` on the converted file. */
|
|
22
|
+
tileDocSize: number;
|
|
23
|
+
/** Annotations converted, by Instant JSON type. */
|
|
24
|
+
converted: Record<string, number>;
|
|
25
|
+
/** Derived tile images dropped (they are re-rendered from live data). */
|
|
26
|
+
droppedTileImages: number;
|
|
27
|
+
skipped: NutrientSkippedAnnotation[];
|
|
28
|
+
/** Converted, but with a known fidelity loss (e.g. text that won't wrap). */
|
|
29
|
+
warnings: NutrientSkippedAnnotation[];
|
|
30
|
+
}
|
|
31
|
+
export interface InstantJsonToPdfCanvasInput {
|
|
32
|
+
/** Parsed `annotations.json`. */
|
|
33
|
+
instantJson: unknown;
|
|
34
|
+
/** The source PDF's pages (describePdfPages), index-aligned. */
|
|
35
|
+
pages: readonly PdfCanvasPage[];
|
|
36
|
+
/** Epoch millis stamped as `updatedAt` on every page state. */
|
|
37
|
+
now?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const instantJsonToPdfCanvas: (input: InstantJsonToPdfCanvasInput) => InstantJsonConversion;
|
|
@@ -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(/ /g, ' ')
|
|
113
|
+
.replace(/</g, '<')
|
|
114
|
+
.replace(/>/g, '>')
|
|
115
|
+
.replace(/"/g, '"')
|
|
116
|
+
.replace(/'/g, "'")
|
|
117
|
+
.replace(/&/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';
|
package/dist/export/pdf/index.js
CHANGED
|
@@ -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';
|
package/dist/export/pdf/marks.js
CHANGED
|
@@ -9,8 +9,8 @@ import { DEFAULT_TEXT_FONT_SIZE, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR,
|
|
|
9
9
|
import { normalizeRect, placementOf, } from '../../annotation/canvas/measurementGeometry.js';
|
|
10
10
|
import { MEASUREMENT_LINE_COLOR, MEASUREMENT_LINE_WIDTH, MEASUREMENT_RECT_WIDTH, } from '../../annotation/canvas/measurementLineStyle.js';
|
|
11
11
|
import { parseHexColor } from './color.js';
|
|
12
|
-
import { circlePathOps, fillStateOps, flatPolylinePathOps, polylinePathOps, rectPathOps, strokeStateOps, } from './emit.js';
|
|
13
|
-
import { boundsToRect } from './pageMapping.js';
|
|
12
|
+
import { circlePathOps, fillStateOps, flatPolylinePathOps, polylinePathOps, rectPathOps, strokeStateOps, withMatrix, } from './emit.js';
|
|
13
|
+
import { boundsToRect, } from './pageMapping.js';
|
|
14
14
|
// Readable glyph colour over a highlight band — mirror of
|
|
15
15
|
// ShapeElement.highlightTextColor (which lives behind a Skia import).
|
|
16
16
|
const highlightTextColor = (hex) => {
|
|
@@ -118,10 +118,51 @@ export const strokeOps = (s, ctx) => {
|
|
|
118
118
|
/* ------------------------------------------------------------------ *
|
|
119
119
|
* Shapes
|
|
120
120
|
* ------------------------------------------------------------------ */
|
|
121
|
+
/**
|
|
122
|
+
* Doc-space rotation of a text shape: `geometry.rotation` degrees CLOCKWISE
|
|
123
|
+
* (y-down) about its origin, `points[0]`. Only set by converted Nutrient text
|
|
124
|
+
* so far — the Skia canvas still draws every text shape upright.
|
|
125
|
+
*/
|
|
126
|
+
const textRotationMatrix = (shape) => {
|
|
127
|
+
const deg = shape.geometry.rotation;
|
|
128
|
+
const origin = shape.geometry.points[0];
|
|
129
|
+
if (!deg || !origin || !Number.isFinite(deg) || deg % 360 === 0)
|
|
130
|
+
return null;
|
|
131
|
+
const t = (deg * Math.PI) / 180;
|
|
132
|
+
const c = Math.cos(t);
|
|
133
|
+
const s = Math.sin(t);
|
|
134
|
+
return [
|
|
135
|
+
c,
|
|
136
|
+
s,
|
|
137
|
+
-s,
|
|
138
|
+
c,
|
|
139
|
+
origin.x - c * origin.x + s * origin.y,
|
|
140
|
+
origin.y - s * origin.x - c * origin.y,
|
|
141
|
+
];
|
|
142
|
+
};
|
|
121
143
|
export const shapeBounds = (shape) => {
|
|
122
144
|
if (shape.kind === 'text') {
|
|
123
145
|
const b = textShapeBounds(shape);
|
|
124
|
-
|
|
146
|
+
if (!b)
|
|
147
|
+
return null;
|
|
148
|
+
const m = textRotationMatrix(shape);
|
|
149
|
+
if (!m)
|
|
150
|
+
return boundsToRect(b, 4);
|
|
151
|
+
const corners = [
|
|
152
|
+
[b.minX, b.minY],
|
|
153
|
+
[b.maxX, b.minY],
|
|
154
|
+
[b.minX, b.maxY],
|
|
155
|
+
[b.maxX, b.maxY],
|
|
156
|
+
].map(([x, y]) => ({
|
|
157
|
+
x: m[0] * x + m[2] * y + m[4],
|
|
158
|
+
y: m[1] * x + m[3] * y + m[5],
|
|
159
|
+
}));
|
|
160
|
+
return boundsToRect({
|
|
161
|
+
minX: Math.min(...corners.map((p) => p.x)),
|
|
162
|
+
minY: Math.min(...corners.map((p) => p.y)),
|
|
163
|
+
maxX: Math.max(...corners.map((p) => p.x)),
|
|
164
|
+
maxY: Math.max(...corners.map((p) => p.y)),
|
|
165
|
+
}, 4);
|
|
125
166
|
}
|
|
126
167
|
const b = visualShapeBounds(shape);
|
|
127
168
|
if (!b)
|
|
@@ -169,7 +210,8 @@ const textShapeOps = (shape, ctx) => {
|
|
|
169
210
|
reason: 'Some characters have no glyph in the export fonts and were dropped',
|
|
170
211
|
});
|
|
171
212
|
}
|
|
172
|
-
|
|
213
|
+
const rotation = textRotationMatrix(shape);
|
|
214
|
+
return rotation ? withMatrix(rotation, ops) : ops;
|
|
173
215
|
};
|
|
174
216
|
export const shapeOps = (shape, ctx) => {
|
|
175
217
|
const { kind, geometry, style } = shape;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { PdfCanvasPage } from '../../types/pdfCanvas.js';
|
|
1
2
|
import { type RenderCanvasToPdfInput, type RenderCanvasToPdfResult, type RenderPdfCanvasToPdfInput } from './types.js';
|
|
2
3
|
export declare const renderCanvasToPdf: (input: RenderCanvasToPdfInput) => Promise<RenderCanvasToPdfResult>;
|
|
4
|
+
/**
|
|
5
|
+
* The `PdfCanvasDocument.pages` record for a PDF: each page's extents after
|
|
6
|
+
* its own /Rotate, measured exactly as renderPdfCanvasToPdf validates them,
|
|
7
|
+
* at user rotation 0. For building a PdfCanvas record server-side (e.g. when
|
|
8
|
+
* converting a legacy Nutrient file) without rasterizing. The PDF must be
|
|
9
|
+
* unencrypted (stripPdfEncryption).
|
|
10
|
+
*/
|
|
11
|
+
export declare const describePdfPages: (sourcePdf: Uint8Array) => Promise<PdfCanvasPage[]>;
|
|
3
12
|
/**
|
|
4
13
|
* Export a PdfCanvas file. Each page's state is drawn in that page's own doc
|
|
5
14
|
* space (the DISPLAYED page: rotated extents, origin top-left). A user
|
|
@@ -334,6 +334,20 @@ export const renderCanvasToPdf = async (input) => {
|
|
|
334
334
|
const pdf = await doc.save({ useObjectStreams: false });
|
|
335
335
|
return { pdf, pageCount: pages.length, annotationCount, skipped };
|
|
336
336
|
};
|
|
337
|
+
/**
|
|
338
|
+
* The `PdfCanvasDocument.pages` record for a PDF: each page's extents after
|
|
339
|
+
* its own /Rotate, measured exactly as renderPdfCanvasToPdf validates them,
|
|
340
|
+
* at user rotation 0. For building a PdfCanvas record server-side (e.g. when
|
|
341
|
+
* converting a legacy Nutrient file) without rasterizing. The PDF must be
|
|
342
|
+
* unencrypted (stripPdfEncryption).
|
|
343
|
+
*/
|
|
344
|
+
export const describePdfPages = async (sourcePdf) => {
|
|
345
|
+
const doc = await loadSource(sourcePdf);
|
|
346
|
+
return doc.getPages().map((page) => {
|
|
347
|
+
const size = rotatedBoxSize(pageBox(page), normalizeRotation(page.getRotation().angle));
|
|
348
|
+
return { width: size.width, height: size.height, rotation: 0 };
|
|
349
|
+
});
|
|
350
|
+
};
|
|
337
351
|
/**
|
|
338
352
|
* Export a PdfCanvas file. Each page's state is drawn in that page's own doc
|
|
339
353
|
* space (the DISPLAYED page: rotated extents, origin top-left). A user
|
package/dist/server.d.ts
CHANGED
|
@@ -19,5 +19,6 @@ export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, }
|
|
|
19
19
|
export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
|
|
20
20
|
export * from './calculator/index.js';
|
|
21
21
|
export * from './export/pdf/index.js';
|
|
22
|
+
export { instantJsonToPdfCanvas, NUTRIENT_TILE_DOC_SIZE, type InstantJsonConversion, type InstantJsonToPdfCanvasInput, type NutrientSkippedAnnotation, } from './export/nutrient/instantJsonToPdfCanvas.js';
|
|
22
23
|
export { applyGroupFormulaUm, groupFormulaExpression, unitNumberToMicrometers, type GroupFormulaFrame, } from './formulas/groupFormula.js';
|
|
23
24
|
export { MEASUREMENT_LINE_COLOR, MEASUREMENT_LINE_WIDTH, MEASUREMENT_RECT_WIDTH, } from './annotation/canvas/measurementLineStyle.js';
|
package/dist/server.js
CHANGED
|
@@ -26,6 +26,7 @@ export * from './calculator/index.js';
|
|
|
26
26
|
// Canvas → annotated PDF export (Cloud Function renderer). Pure pdf-lib; no
|
|
27
27
|
// React or Skia anywhere in its import graph.
|
|
28
28
|
export * from './export/pdf/index.js';
|
|
29
|
+
export { instantJsonToPdfCanvas, NUTRIENT_TILE_DOC_SIZE, } from './export/nutrient/instantJsonToPdfCanvas.js';
|
|
29
30
|
// groupFormula's own micrometersToUnitNumber (integer constants, NaN on
|
|
30
31
|
// unknown units) stays module-local: the canonical export of that name is the
|
|
31
32
|
// mathjs-exact one from evaluateFormula.js above, and no consumer imported
|