@reekon-tools/boldr-utils 1.21.1 → 1.22.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/AnnotationCanvasSkia.js +3 -11
- package/dist/annotation/canvas/measurementLineStyle.d.ts +3 -0
- package/dist/annotation/canvas/measurementLineStyle.js +14 -0
- package/dist/export/pdf/color.d.ts +7 -0
- package/dist/export/pdf/color.js +39 -0
- package/dist/export/pdf/emit.d.ts +37 -0
- package/dist/export/pdf/emit.js +129 -0
- package/dist/export/pdf/fonts.d.ts +38 -0
- package/dist/export/pdf/fonts.js +97 -0
- package/dist/export/pdf/index.d.ts +5 -0
- package/dist/export/pdf/index.js +5 -0
- package/dist/export/pdf/layers.d.ts +5 -0
- package/dist/export/pdf/layers.js +19 -0
- package/dist/export/pdf/marks.d.ts +20 -0
- package/dist/export/pdf/marks.js +336 -0
- package/dist/export/pdf/pageMapping.d.ts +49 -0
- package/dist/export/pdf/pageMapping.js +67 -0
- package/dist/export/pdf/renderCanvasToPdf.d.ts +2 -0
- package/dist/export/pdf/renderCanvasToPdf.js +201 -0
- package/dist/export/pdf/tileContent.d.ts +39 -0
- package/dist/export/pdf/tileContent.js +218 -0
- package/dist/export/pdf/tiles.d.ts +43 -0
- package/dist/export/pdf/tiles.js +392 -0
- package/dist/export/pdf/types.d.ts +101 -0
- package/dist/export/pdf/types.js +10 -0
- package/dist/exports.d.ts +1 -1
- package/dist/exports.js +1 -1
- package/dist/formulas/calculateFormula.d.ts +1 -1
- package/dist/formulas/calculateFormula.js +23 -10
- package/dist/formulas/evaluateFormula.d.ts +0 -10
- package/dist/formulas/evaluateFormula.js +1 -12
- package/dist/formulas/groupFormula.d.ts +43 -0
- package/dist/formulas/groupFormula.js +168 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +6 -1
- package/package.json +4 -1
- package/dist/canvas/AnnotationCanvas.d.ts +0 -11
- package/dist/canvas/AnnotationCanvas.js +0 -10
- package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
- package/dist/canvas/AnnotationCanvas.native.js +0 -6
- package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
- package/dist/canvas/AnnotationCanvasInner.js +0 -219
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/Tool.d.ts +0 -38
- package/dist/canvas/Tool.js +0 -1
- package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
- package/dist/canvas/elements/BackgroundImageElement.js +0 -37
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/ShapeElement.js +0 -62
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/measurementPicker.d.ts +0 -10
- package/dist/canvas/measurementPicker.js +0 -1
- package/dist/canvas/measurementStampOverlay.d.ts +0 -11
- package/dist/canvas/measurementStampOverlay.js +0 -1
- package/dist/canvas/pointerAdapter.d.ts +0 -3
- package/dist/canvas/pointerAdapter.js +0 -19
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
- package/dist/canvas/tools/measurementStampTool.js +0 -37
- package/dist/canvas/tools/panTool.d.ts +0 -5
- package/dist/canvas/tools/panTool.js +0 -25
- package/dist/canvas/tools/penTool.d.ts +0 -13
- package/dist/canvas/tools/penTool.js +0 -68
- package/dist/canvas/tools/selectTool.d.ts +0 -2
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
- package/dist/canvas/useAnnotationCanvasState.js +0 -210
- package/dist/canvas/viewport.d.ts +0 -16
- package/dist/canvas/viewport.js +0 -54
- package/dist/data/AnnotationDataContext.d.ts +0 -8
- package/dist/data/AnnotationDataContext.js +0 -11
- package/dist/data/AnnotationDataProvider.d.ts +0 -65
- package/dist/data/AnnotationDataProvider.js +0 -4
- package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
- package/dist/data/InMemoryAnnotationProvider.js +0 -197
- package/dist/data/canvasPersistence.d.ts +0 -3
- package/dist/data/canvasPersistence.js +0 -26
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
- package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
- package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
- package/dist/data/hooks/useAnnotationDoc.js +0 -33
- package/dist/data/hooks/useAnnotationList.d.ts +0 -7
- package/dist/data/hooks/useAnnotationList.js +0 -26
- package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
- package/dist/data/hooks/useAnnotationMutations.js +0 -11
- package/dist/hooks/useParseMeasurement.d.ts +0 -4
- package/dist/hooks/useParseMeasurement.js +0 -14
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Vector marks: strokes, shapes and the measurement annotation's line /
|
|
2
|
+
// rectangle, emitted as PDF path operators in DOC space. Mirrors what
|
|
3
|
+
// StrokeElement, ShapeElement and AnnotationCanvasSkia's measurement pass draw,
|
|
4
|
+
// reusing the same geometry helpers so caps, dashes and arrowheads agree.
|
|
5
|
+
import { fill, popGraphicsState, pushGraphicsState, rectangle, stroke, } from 'pdf-lib';
|
|
6
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../../annotation/canvas/strokeGeometry.js';
|
|
7
|
+
import { visualShapeBounds } from '../../annotation/canvas/shapeGeometry.js';
|
|
8
|
+
import { DEFAULT_TEXT_FONT_SIZE, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR, textShapeBounds, } from '../../annotation/canvas/textGeometry.js';
|
|
9
|
+
import { normalizeRect, placementOf, } from '../../annotation/canvas/measurementGeometry.js';
|
|
10
|
+
import { MEASUREMENT_LINE_COLOR, MEASUREMENT_LINE_WIDTH, MEASUREMENT_RECT_WIDTH, } from '../../annotation/canvas/measurementLineStyle.js';
|
|
11
|
+
import { parseHexColor } from './color.js';
|
|
12
|
+
import { circlePathOps, fillStateOps, flatPolylinePathOps, polylinePathOps, rectPathOps, strokeStateOps, } from './emit.js';
|
|
13
|
+
import { boundsToRect } from './pageMapping.js';
|
|
14
|
+
// Readable glyph colour over a highlight band — mirror of
|
|
15
|
+
// ShapeElement.highlightTextColor (which lives behind a Skia import).
|
|
16
|
+
const highlightTextColor = (hex) => {
|
|
17
|
+
const h = hex.replace('#', '');
|
|
18
|
+
if (h.length < 6)
|
|
19
|
+
return '#1A1A1A';
|
|
20
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
21
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
22
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
23
|
+
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
24
|
+
return lum > 0.6 ? '#1A1A1A' : '#FFFFFF';
|
|
25
|
+
};
|
|
26
|
+
const arrowheadOps = (tip, from, width, color, gs) => {
|
|
27
|
+
if (!tip || !from || (tip.x === from.x && tip.y === from.y))
|
|
28
|
+
return [];
|
|
29
|
+
const tri = arrowheadTriangle(tip, from, width);
|
|
30
|
+
return [
|
|
31
|
+
pushGraphicsState(),
|
|
32
|
+
...fillStateOps(color, gs),
|
|
33
|
+
...polylinePathOps(tri, true),
|
|
34
|
+
fill(),
|
|
35
|
+
popGraphicsState(),
|
|
36
|
+
];
|
|
37
|
+
};
|
|
38
|
+
/* ------------------------------------------------------------------ *
|
|
39
|
+
* Strokes
|
|
40
|
+
* ------------------------------------------------------------------ */
|
|
41
|
+
export const strokeBounds = (s) => {
|
|
42
|
+
if (s.points.length < 2)
|
|
43
|
+
return null;
|
|
44
|
+
let minX = Infinity;
|
|
45
|
+
let minY = Infinity;
|
|
46
|
+
let maxX = -Infinity;
|
|
47
|
+
let maxY = -Infinity;
|
|
48
|
+
for (let i = 0; i + 1 < s.points.length; i += 2) {
|
|
49
|
+
const x = s.points[i];
|
|
50
|
+
const y = s.points[i + 1];
|
|
51
|
+
if (x < minX)
|
|
52
|
+
minX = x;
|
|
53
|
+
if (y < minY)
|
|
54
|
+
minY = y;
|
|
55
|
+
if (x > maxX)
|
|
56
|
+
maxX = x;
|
|
57
|
+
if (y > maxY)
|
|
58
|
+
maxY = y;
|
|
59
|
+
}
|
|
60
|
+
// Round caps and arrowheads reach past the points by ~2× the width.
|
|
61
|
+
return boundsToRect({ minX, minY, maxX, maxY }, Math.max(s.width * 2, 6));
|
|
62
|
+
};
|
|
63
|
+
export const strokeOps = (s, ctx) => {
|
|
64
|
+
const n = s.points.length;
|
|
65
|
+
if (n < 2)
|
|
66
|
+
return [];
|
|
67
|
+
const opacity = s.tool === 'highlighter' ? 0.3 : 1;
|
|
68
|
+
const alpha = opacity * parseHexColor(s.color).alpha;
|
|
69
|
+
const gs = ctx.alphaState(alpha);
|
|
70
|
+
const isDot = n === 4 && s.points[0] === s.points[2] && s.points[1] === s.points[3];
|
|
71
|
+
if (isDot) {
|
|
72
|
+
// Skia paints a zero-length round-capped contour as a dot; PDF only does
|
|
73
|
+
// so for round caps, and not reliably across viewers — draw it explicitly.
|
|
74
|
+
const x = s.points[0];
|
|
75
|
+
const y = s.points[1];
|
|
76
|
+
const r = s.width / 2;
|
|
77
|
+
const cap = toSkiaStrokeCap(s.cap);
|
|
78
|
+
if (cap === 'butt')
|
|
79
|
+
return [];
|
|
80
|
+
return [
|
|
81
|
+
pushGraphicsState(),
|
|
82
|
+
...fillStateOps(s.color, gs),
|
|
83
|
+
...(cap === 'square'
|
|
84
|
+
? rectPathOps(x - r, y - r, s.width, s.width)
|
|
85
|
+
: circlePathOps(x, y, r)),
|
|
86
|
+
fill(),
|
|
87
|
+
popGraphicsState(),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
const ops = [
|
|
91
|
+
pushGraphicsState(),
|
|
92
|
+
...strokeStateOps({
|
|
93
|
+
color: s.color,
|
|
94
|
+
width: s.width,
|
|
95
|
+
cap: s.cap,
|
|
96
|
+
join: 'round',
|
|
97
|
+
dash: !!s.dash,
|
|
98
|
+
graphicsState: gs,
|
|
99
|
+
}),
|
|
100
|
+
...flatPolylinePathOps(s.points),
|
|
101
|
+
stroke(),
|
|
102
|
+
popGraphicsState(),
|
|
103
|
+
];
|
|
104
|
+
if (n >= 4) {
|
|
105
|
+
const first = { x: s.points[0], y: s.points[1] };
|
|
106
|
+
const second = { x: s.points[2], y: s.points[3] };
|
|
107
|
+
const last = { x: s.points[n - 2], y: s.points[n - 1] };
|
|
108
|
+
const penult = { x: s.points[n - 4], y: s.points[n - 3] };
|
|
109
|
+
if (s.startCap === 'arrow') {
|
|
110
|
+
ops.push(...arrowheadOps(first, second, s.width, s.color, gs));
|
|
111
|
+
}
|
|
112
|
+
if (s.cap === 'arrow') {
|
|
113
|
+
ops.push(...arrowheadOps(last, penult, s.width, s.color, gs));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return ops;
|
|
117
|
+
};
|
|
118
|
+
/* ------------------------------------------------------------------ *
|
|
119
|
+
* Shapes
|
|
120
|
+
* ------------------------------------------------------------------ */
|
|
121
|
+
export const shapeBounds = (shape) => {
|
|
122
|
+
if (shape.kind === 'text') {
|
|
123
|
+
const b = textShapeBounds(shape);
|
|
124
|
+
return b ? boundsToRect(b, 4) : null;
|
|
125
|
+
}
|
|
126
|
+
const b = visualShapeBounds(shape);
|
|
127
|
+
if (!b)
|
|
128
|
+
return null;
|
|
129
|
+
const w = shape.style.strokeWidth ?? 2;
|
|
130
|
+
return boundsToRect(b, Math.max(w * 2, 6));
|
|
131
|
+
};
|
|
132
|
+
const textShapeOps = (shape, ctx) => {
|
|
133
|
+
const origin = shape.geometry.points[0];
|
|
134
|
+
if (!origin || !shape.text)
|
|
135
|
+
return [];
|
|
136
|
+
const fontSize = shape.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
137
|
+
const colorStr = shape.style.stroke ?? '#000000';
|
|
138
|
+
const decoration = shape.style.textDecoration;
|
|
139
|
+
const glyphColor = decoration === 'highlight' ? highlightTextColor(colorStr) : colorStr;
|
|
140
|
+
const gs = ctx.alphaState(parseHexColor(colorStr).alpha);
|
|
141
|
+
const lineHeight = TEXT_LINE_HEIGHT_FACTOR * fontSize;
|
|
142
|
+
const ops = [];
|
|
143
|
+
let lost = false;
|
|
144
|
+
shape.text.split('\n').forEach((line, i) => {
|
|
145
|
+
const top = origin.y + i * lineHeight;
|
|
146
|
+
const baseline = top + TEXT_BASELINE_FACTOR * fontSize;
|
|
147
|
+
const picked = ctx.fonts.pick(line);
|
|
148
|
+
if (picked.dropped)
|
|
149
|
+
lost = true;
|
|
150
|
+
const width = picked.text ? ctx.fonts.width(picked.text, fontSize) : 0;
|
|
151
|
+
if (decoration === 'highlight' && width > 0) {
|
|
152
|
+
ops.push(pushGraphicsState(), ...fillStateOps(colorStr, gs), rectangle(origin.x, top, width, lineHeight), fill(), popGraphicsState());
|
|
153
|
+
}
|
|
154
|
+
ops.push(...ctx.fonts.textOps(ctx.page, line, fontSize, glyphColor, origin.x, baseline, {
|
|
155
|
+
graphicsState: gs,
|
|
156
|
+
}));
|
|
157
|
+
if ((decoration === 'underline' || decoration === 'squiggle') &&
|
|
158
|
+
width > 0) {
|
|
159
|
+
// Skia positions the underline just under the baseline at ~1/14 em; the
|
|
160
|
+
// wavy variant has no PDF operator and is drawn solid.
|
|
161
|
+
const thickness = fontSize / 14;
|
|
162
|
+
ops.push(pushGraphicsState(), ...fillStateOps(colorStr, gs), rectangle(origin.x, baseline + fontSize * 0.08, width, thickness), fill(), popGraphicsState());
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (lost) {
|
|
166
|
+
ctx.skipped.push({
|
|
167
|
+
id: shape.id,
|
|
168
|
+
kind: 'text',
|
|
169
|
+
reason: 'Some characters have no glyph in the export fonts and were dropped',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return ops;
|
|
173
|
+
};
|
|
174
|
+
export const shapeOps = (shape, ctx) => {
|
|
175
|
+
const { kind, geometry, style } = shape;
|
|
176
|
+
if (kind === 'text')
|
|
177
|
+
return textShapeOps(shape, ctx);
|
|
178
|
+
const strokeColor = style.stroke ?? '#000000';
|
|
179
|
+
const strokeWidth = style.strokeWidth ?? 2;
|
|
180
|
+
const gs = ctx.alphaState(parseHexColor(strokeColor).alpha);
|
|
181
|
+
const closed = geometry.closed !== false;
|
|
182
|
+
const pts = geometry.points;
|
|
183
|
+
const strokeWith = (pathOps, join, cap) => [
|
|
184
|
+
pushGraphicsState(),
|
|
185
|
+
...strokeStateOps({
|
|
186
|
+
color: strokeColor,
|
|
187
|
+
width: strokeWidth,
|
|
188
|
+
cap,
|
|
189
|
+
join,
|
|
190
|
+
dash: !!style.dash,
|
|
191
|
+
graphicsState: gs,
|
|
192
|
+
}),
|
|
193
|
+
...pathOps,
|
|
194
|
+
stroke(),
|
|
195
|
+
popGraphicsState(),
|
|
196
|
+
];
|
|
197
|
+
const fillWith = (pathOps) => style.fill
|
|
198
|
+
? [
|
|
199
|
+
pushGraphicsState(),
|
|
200
|
+
...fillStateOps(style.fill, ctx.alphaState(parseHexColor(style.fill).alpha)),
|
|
201
|
+
...pathOps,
|
|
202
|
+
fill(),
|
|
203
|
+
popGraphicsState(),
|
|
204
|
+
]
|
|
205
|
+
: [];
|
|
206
|
+
switch (kind) {
|
|
207
|
+
case 'rect': {
|
|
208
|
+
const [a, b] = pts;
|
|
209
|
+
if (!a || !b)
|
|
210
|
+
return [];
|
|
211
|
+
const x = Math.min(a.x, b.x);
|
|
212
|
+
const y = Math.min(a.y, b.y);
|
|
213
|
+
const w = Math.abs(b.x - a.x);
|
|
214
|
+
const h = Math.abs(b.y - a.y);
|
|
215
|
+
return [
|
|
216
|
+
...fillWith(rectPathOps(x, y, w, h)),
|
|
217
|
+
...strokeWith(rectPathOps(x, y, w, h), 'miter'),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
case 'ellipse': {
|
|
221
|
+
// Rendered as a CIRCLE: centre = midpoint, r = half the larger extent.
|
|
222
|
+
const [a, b] = pts;
|
|
223
|
+
if (!a || !b)
|
|
224
|
+
return [];
|
|
225
|
+
const cx = (a.x + b.x) / 2;
|
|
226
|
+
const cy = (a.y + b.y) / 2;
|
|
227
|
+
const r = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) / 2;
|
|
228
|
+
return [
|
|
229
|
+
...fillWith(circlePathOps(cx, cy, r)),
|
|
230
|
+
...strokeWith(circlePathOps(cx, cy, r), 'miter'),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
case 'line':
|
|
234
|
+
case 'arrow': {
|
|
235
|
+
const [a, b] = pts;
|
|
236
|
+
if (!a || !b)
|
|
237
|
+
return [];
|
|
238
|
+
const ops = strokeWith(polylinePathOps([a, b]), 'miter', style.cap);
|
|
239
|
+
if (style.startCap === 'arrow')
|
|
240
|
+
ops.push(...arrowheadOps(a, b, strokeWidth, strokeColor, gs));
|
|
241
|
+
if (kind === 'arrow' || style.cap === 'arrow') {
|
|
242
|
+
ops.push(...arrowheadOps(b, a, strokeWidth, strokeColor, gs));
|
|
243
|
+
}
|
|
244
|
+
return ops;
|
|
245
|
+
}
|
|
246
|
+
case 'polygon': {
|
|
247
|
+
if (pts.length < 2)
|
|
248
|
+
return [];
|
|
249
|
+
const ops = [];
|
|
250
|
+
if (closed)
|
|
251
|
+
ops.push(...fillWith(polylinePathOps(pts, true)));
|
|
252
|
+
ops.push(...strokeWith(polylinePathOps(pts, closed), 'round', style.cap));
|
|
253
|
+
if (!closed) {
|
|
254
|
+
const n = pts.length;
|
|
255
|
+
if (style.startCap === 'arrow')
|
|
256
|
+
ops.push(...arrowheadOps(pts[0], pts[1], strokeWidth, strokeColor, gs));
|
|
257
|
+
if (style.cap === 'arrow')
|
|
258
|
+
ops.push(...arrowheadOps(pts[n - 1], pts[n - 2], strokeWidth, strokeColor, gs));
|
|
259
|
+
}
|
|
260
|
+
return ops;
|
|
261
|
+
}
|
|
262
|
+
default:
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
/* ------------------------------------------------------------------ *
|
|
267
|
+
* Measurement annotation line / rectangle (the tile itself is in tiles.ts)
|
|
268
|
+
* ------------------------------------------------------------------ */
|
|
269
|
+
export const measurementMarkBounds = (placed) => {
|
|
270
|
+
const placement = placementOf(placed);
|
|
271
|
+
if (placement === 'rectangle' && placed.rect) {
|
|
272
|
+
const n = normalizeRect(placed.rect);
|
|
273
|
+
const w = placed.lineWidth ?? MEASUREMENT_RECT_WIDTH;
|
|
274
|
+
return boundsToRect(n, w);
|
|
275
|
+
}
|
|
276
|
+
if (placement === 'line' && placed.line) {
|
|
277
|
+
const { a, b } = placed.line;
|
|
278
|
+
const w = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
279
|
+
return boundsToRect({
|
|
280
|
+
minX: Math.min(a.x, b.x),
|
|
281
|
+
minY: Math.min(a.y, b.y),
|
|
282
|
+
maxX: Math.max(a.x, b.x),
|
|
283
|
+
maxY: Math.max(a.y, b.y),
|
|
284
|
+
}, Math.max(w * 2, 6));
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
};
|
|
288
|
+
export const measurementMarkOps = (placed, ctx) => {
|
|
289
|
+
const placement = placementOf(placed);
|
|
290
|
+
if (placement === 'rectangle' && placed.rect) {
|
|
291
|
+
const n = normalizeRect(placed.rect);
|
|
292
|
+
const color = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
293
|
+
const width = placed.lineWidth ?? MEASUREMENT_RECT_WIDTH;
|
|
294
|
+
return [
|
|
295
|
+
pushGraphicsState(),
|
|
296
|
+
...strokeStateOps({
|
|
297
|
+
color,
|
|
298
|
+
width,
|
|
299
|
+
join: 'miter',
|
|
300
|
+
dash: !!placed.lineDash,
|
|
301
|
+
graphicsState: ctx.alphaState(parseHexColor(color).alpha),
|
|
302
|
+
}),
|
|
303
|
+
...rectPathOps(n.minX, n.minY, n.maxX - n.minX, n.maxY - n.minY),
|
|
304
|
+
stroke(),
|
|
305
|
+
popGraphicsState(),
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
if (placement === 'line' && placed.line) {
|
|
309
|
+
const { a, b } = placed.line;
|
|
310
|
+
const color = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
311
|
+
const width = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
312
|
+
const gs = ctx.alphaState(parseHexColor(color).alpha);
|
|
313
|
+
const ops = [
|
|
314
|
+
pushGraphicsState(),
|
|
315
|
+
...strokeStateOps({
|
|
316
|
+
color,
|
|
317
|
+
width,
|
|
318
|
+
cap: placed.lineCap,
|
|
319
|
+
join: 'miter',
|
|
320
|
+
dash: !!placed.lineDash,
|
|
321
|
+
graphicsState: gs,
|
|
322
|
+
}),
|
|
323
|
+
...polylinePathOps([a, b]),
|
|
324
|
+
stroke(),
|
|
325
|
+
popGraphicsState(),
|
|
326
|
+
];
|
|
327
|
+
if (placed.lineStartCap === 'arrow')
|
|
328
|
+
ops.push(...arrowheadOps(a, b, width, color, gs));
|
|
329
|
+
if (placed.lineCap === 'arrow')
|
|
330
|
+
ops.push(...arrowheadOps(b, a, width, color, gs));
|
|
331
|
+
return ops;
|
|
332
|
+
}
|
|
333
|
+
return [];
|
|
334
|
+
};
|
|
335
|
+
// Re-exported so the renderer can size dash tests without a second import.
|
|
336
|
+
export { dashIntervals };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AnnotationPdfDocument, AnnotationPdfPageSize } from '../../types/annotation.js';
|
|
2
|
+
export interface DocRect {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/** A PDF page's box in unrotated user space (pdf-lib's getCropBox shape). */
|
|
9
|
+
export interface PdfBox {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
/** PDF `cm` matrix [a b c d e f]: (x, y) → (a·x + c·y + e, b·x + d·y + f). */
|
|
16
|
+
export type AffineMatrix = [
|
|
17
|
+
a: number,
|
|
18
|
+
b: number,
|
|
19
|
+
c: number,
|
|
20
|
+
d: number,
|
|
21
|
+
e: number,
|
|
22
|
+
f: number
|
|
23
|
+
];
|
|
24
|
+
export type PageRotation = 0 | 90 | 180 | 270;
|
|
25
|
+
export declare const normalizeRotation: (degrees: number) => PageRotation;
|
|
26
|
+
/** Doc-space rect of every page, index-aligned with `doc.pages`. */
|
|
27
|
+
export declare const pageRectsOf: (doc: AnnotationPdfDocument) => DocRect[];
|
|
28
|
+
/** The displayed (post-rotation) size a CropBox has under `rotation`. */
|
|
29
|
+
export declare const rotatedBoxSize: (box: PdfBox, rotation: PageRotation) => AnnotationPdfPageSize;
|
|
30
|
+
/**
|
|
31
|
+
* The matrix that maps a doc-space point (stack coordinates, y-down) onto the
|
|
32
|
+
* page's PDF user space (y-up, unrotated, CropBox origin). `pageRect` is the
|
|
33
|
+
* page's rect in the stack, `cropBox` its box in user space, `rotation` its
|
|
34
|
+
* normalized `/Rotate`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const docToPageMatrix: (pageRect: DocRect, cropBox: PdfBox, rotation: PageRotation) => AffineMatrix;
|
|
37
|
+
/** Apply an affine matrix to a point (for tests and bounds checks). */
|
|
38
|
+
export declare const applyMatrix: (m: AffineMatrix, x: number, y: number) => {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
|
42
|
+
export declare const rectsIntersect: (a: DocRect, b: DocRect) => boolean;
|
|
43
|
+
/** Axis-aligned bounds → DocRect, inflated by `pad` on every side. */
|
|
44
|
+
export declare const boundsToRect: (b: {
|
|
45
|
+
minX: number;
|
|
46
|
+
minY: number;
|
|
47
|
+
maxX: number;
|
|
48
|
+
maxY: number;
|
|
49
|
+
}, pad?: number) => DocRect;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Where each PDF page sits in canvas doc space, and the one affine matrix that
|
|
2
|
+
// carries a doc-space point onto that page's PDF user space.
|
|
3
|
+
//
|
|
4
|
+
// Doc space on a PDF-backed canvas IS PDF points, y-down, with the pages laid
|
|
5
|
+
// out by pdfPageDocRects (the continuous vertical filmstrip). PDF user space is
|
|
6
|
+
// y-up, unrotated, and its origin is wherever the CropBox says. The matrix
|
|
7
|
+
// below folds all three differences — the page's offset in the stack, the
|
|
8
|
+
// y-flip, and `/Rotate` — into a single `cm` so every mark is emitted in its
|
|
9
|
+
// stored doc coordinates unchanged.
|
|
10
|
+
//
|
|
11
|
+
// ROTATION CONVENTION (measured against PDFium in __tests__/pageMapping): a
|
|
12
|
+
// `/Rotate 90` page is displayed rotated 90° clockwise, so the unrotated
|
|
13
|
+
// page's top-left corner lands at the displayed page's top-RIGHT. The stored
|
|
14
|
+
// page sizes (AnnotationPdfDocument.pages) are post-rotation extents, i.e.
|
|
15
|
+
// exactly the displayed W×H the matrix maps from.
|
|
16
|
+
import { pdfPageDocRects } from '../../annotation/canvas/pdfDocument.js';
|
|
17
|
+
export const normalizeRotation = (degrees) => {
|
|
18
|
+
const r = (((Math.round(degrees / 90) * 90) % 360) + 360) % 360;
|
|
19
|
+
return r;
|
|
20
|
+
};
|
|
21
|
+
/** Doc-space rect of every page, index-aligned with `doc.pages`. */
|
|
22
|
+
export const pageRectsOf = (doc) => pdfPageDocRects(doc);
|
|
23
|
+
/** The displayed (post-rotation) size a CropBox has under `rotation`. */
|
|
24
|
+
export const rotatedBoxSize = (box, rotation) => rotation === 90 || rotation === 270
|
|
25
|
+
? { width: box.height, height: box.width }
|
|
26
|
+
: { width: box.width, height: box.height };
|
|
27
|
+
/**
|
|
28
|
+
* The matrix that maps a doc-space point (stack coordinates, y-down) onto the
|
|
29
|
+
* page's PDF user space (y-up, unrotated, CropBox origin). `pageRect` is the
|
|
30
|
+
* page's rect in the stack, `cropBox` its box in user space, `rotation` its
|
|
31
|
+
* normalized `/Rotate`.
|
|
32
|
+
*/
|
|
33
|
+
export const docToPageMatrix = (pageRect, cropBox, rotation) => {
|
|
34
|
+
const { x: rx, y: ry } = pageRect;
|
|
35
|
+
const { x: cx, y: cy, width: cw, height: ch } = cropBox;
|
|
36
|
+
switch (rotation) {
|
|
37
|
+
case 0:
|
|
38
|
+
// X = cx + (x - rx); Y = cy + ch - (y - ry)
|
|
39
|
+
return [1, 0, 0, -1, cx - rx, cy + ch + ry];
|
|
40
|
+
case 90:
|
|
41
|
+
// Displayed top-left is the unrotated bottom-left.
|
|
42
|
+
// X = cx + (y - ry); Y = cy + (x - rx)
|
|
43
|
+
return [0, 1, 1, 0, cx - ry, cy - rx];
|
|
44
|
+
case 180:
|
|
45
|
+
// X = cx + cw - (x - rx); Y = cy + (y - ry)
|
|
46
|
+
return [-1, 0, 0, 1, cx + cw + rx, cy - ry];
|
|
47
|
+
case 270:
|
|
48
|
+
// X = cx + cw - (y - ry); Y = cy + ch - (x - rx)
|
|
49
|
+
return [0, -1, -1, 0, cx + cw + ry, cy + ch + rx];
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** Apply an affine matrix to a point (for tests and bounds checks). */
|
|
53
|
+
export const applyMatrix = (m, x, y) => ({
|
|
54
|
+
x: m[0] * x + m[2] * y + m[4],
|
|
55
|
+
y: m[1] * x + m[3] * y + m[5],
|
|
56
|
+
});
|
|
57
|
+
export const rectsIntersect = (a, b) => a.x < b.x + b.width &&
|
|
58
|
+
a.x + a.width > b.x &&
|
|
59
|
+
a.y < b.y + b.height &&
|
|
60
|
+
a.y + a.height > b.y;
|
|
61
|
+
/** Axis-aligned bounds → DocRect, inflated by `pad` on every side. */
|
|
62
|
+
export const boundsToRect = (b, pad = 0) => ({
|
|
63
|
+
x: b.minX - pad,
|
|
64
|
+
y: b.minY - pad,
|
|
65
|
+
width: b.maxX - b.minX + 2 * pad,
|
|
66
|
+
height: b.maxY - b.minY + 2 * pad,
|
|
67
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The canvas → PDF renderer: opens the original document, and appends the
|
|
2
|
+
// canvas's layers, marks and tiles to each page's content stream under one
|
|
3
|
+
// matrix per page. The original page content and its own /Annots are never
|
|
4
|
+
// touched, so the source stays vector, searchable and selectable.
|
|
5
|
+
import { EncryptedPDFError, PDFDocument, } from 'pdf-lib';
|
|
6
|
+
import { backgroundLayersOf } from '../../annotation/canvas/backgroundLayers.js';
|
|
7
|
+
import { fitRectToScreen } from '../../annotation/canvas/viewport.js';
|
|
8
|
+
import { STAMP_TILE_SIZE } from '../../annotation/canvas/stampLayout.js';
|
|
9
|
+
import { clipRectOps, withMatrix } from './emit.js';
|
|
10
|
+
import { PdfFonts } from './fonts.js';
|
|
11
|
+
import { layerOps, layerVisibleRect } from './layers.js';
|
|
12
|
+
import { measurementMarkBounds, measurementMarkOps, shapeBounds, shapeOps, strokeBounds, strokeOps, } from './marks.js';
|
|
13
|
+
import { docToPageMatrix, normalizeRotation, pageRectsOf, rectsIntersect, rotatedBoxSize, } from './pageMapping.js';
|
|
14
|
+
import { designBoxFor, tileFootprint, tileOps, } from './tiles.js';
|
|
15
|
+
import { PdfExportError, } from './types.js';
|
|
16
|
+
/** Stored page sizes may differ from the file's boxes by rounding only. */
|
|
17
|
+
const PAGE_SIZE_TOLERANCE_PT = 1.5;
|
|
18
|
+
/**
|
|
19
|
+
* Reference view for the calibration fallback: the doc-space edge a scale-1
|
|
20
|
+
* tile covers when the first page is fitted into a landscape tablet-ish view.
|
|
21
|
+
*/
|
|
22
|
+
const FALLBACK_VIEW = { width: 1024, height: 768 };
|
|
23
|
+
const intersectBoxes = (a, b) => {
|
|
24
|
+
const x = Math.max(a.x, b.x);
|
|
25
|
+
const y = Math.max(a.y, b.y);
|
|
26
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
27
|
+
const top = Math.min(a.y + a.height, b.y + b.height);
|
|
28
|
+
if (right <= x || top <= y)
|
|
29
|
+
return a;
|
|
30
|
+
return { x, y, width: right - x, height: top - y };
|
|
31
|
+
};
|
|
32
|
+
/** PDFium's page box: CropBox ∩ MediaBox, falling back to the MediaBox. */
|
|
33
|
+
const pageBox = (page) => {
|
|
34
|
+
const media = page.getMediaBox();
|
|
35
|
+
const crop = page.node.CropBox() ? page.getCropBox() : media;
|
|
36
|
+
return intersectBoxes(crop, media);
|
|
37
|
+
};
|
|
38
|
+
const embedImage = (doc, img) => img.mime === 'image/png' ? doc.embedPng(img.bytes) : doc.embedJpg(img.bytes);
|
|
39
|
+
const loadSource = async (bytes) => {
|
|
40
|
+
try {
|
|
41
|
+
return await PDFDocument.load(bytes, { updateMetadata: false });
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (e instanceof EncryptedPDFError) {
|
|
45
|
+
throw new PdfExportError('encrypted', 'The source PDF is encrypted and cannot be exported');
|
|
46
|
+
}
|
|
47
|
+
throw new PdfExportError('corrupt', `The source PDF could not be parsed: ${e instanceof Error ? e.message : String(e)}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export const renderCanvasToPdf = async (input) => {
|
|
51
|
+
const { canvas } = input;
|
|
52
|
+
const pdfDoc = canvas.viewport.pdfDocument;
|
|
53
|
+
if (!pdfDoc) {
|
|
54
|
+
throw new PdfExportError('no-pdf-document', 'This canvas has no PDF document to export');
|
|
55
|
+
}
|
|
56
|
+
const doc = await loadSource(input.sourcePdf);
|
|
57
|
+
const pages = doc.getPages();
|
|
58
|
+
if (pages.length !== pdfDoc.pageCount ||
|
|
59
|
+
pdfDoc.pages.length !== pages.length) {
|
|
60
|
+
throw new PdfExportError('page-count-mismatch', `The canvas records ${pdfDoc.pages.length} pages but the PDF has ${pages.length}`);
|
|
61
|
+
}
|
|
62
|
+
const pageRects = pageRectsOf(pdfDoc);
|
|
63
|
+
const skipped = [];
|
|
64
|
+
// Per-page geometry, validated against what the canvas was laid out from.
|
|
65
|
+
const geometry = pages.map((page, i) => {
|
|
66
|
+
const box = pageBox(page);
|
|
67
|
+
const rotation = normalizeRotation(page.getRotation().angle);
|
|
68
|
+
const size = rotatedBoxSize(box, rotation);
|
|
69
|
+
const stored = pdfDoc.pages[i];
|
|
70
|
+
if (Math.abs(size.width - stored.width) > PAGE_SIZE_TOLERANCE_PT ||
|
|
71
|
+
Math.abs(size.height - stored.height) > PAGE_SIZE_TOLERANCE_PT) {
|
|
72
|
+
throw new PdfExportError('page-geometry-mismatch', `Page ${i + 1} is ${size.width}×${size.height} pt in the PDF but ${stored.width}×${stored.height} pt on the canvas`);
|
|
73
|
+
}
|
|
74
|
+
return { page, box, rotation, rect: pageRects[i] };
|
|
75
|
+
});
|
|
76
|
+
const fonts = await PdfFonts.create(doc, input.primaryFont);
|
|
77
|
+
// Background layers: embed once, draw on every page they overlap.
|
|
78
|
+
const layers = backgroundLayersOf(canvas.viewport);
|
|
79
|
+
const layerImages = new Map();
|
|
80
|
+
for (const layer of layers) {
|
|
81
|
+
if (layer.format === 'svg') {
|
|
82
|
+
skipped.push({
|
|
83
|
+
id: layer.id,
|
|
84
|
+
kind: 'layer',
|
|
85
|
+
reason: 'SVG background layers are not exported yet',
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const bytes = input.layerImages[layer.id];
|
|
90
|
+
if (!bytes) {
|
|
91
|
+
skipped.push({
|
|
92
|
+
id: layer.id,
|
|
93
|
+
kind: 'layer',
|
|
94
|
+
reason: 'Layer image bytes were not provided',
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
layerImages.set(layer.id, await embedImage(doc, bytes));
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
skipped.push({
|
|
103
|
+
id: layer.id,
|
|
104
|
+
kind: 'layer',
|
|
105
|
+
reason: `Layer image could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Tiles: resolve content once; embed object thumbnails once.
|
|
110
|
+
const tiles = canvas.placedMeasurements.map((placed) => {
|
|
111
|
+
const content = input.resolveTile(placed) ?? {
|
|
112
|
+
kind: 'empty',
|
|
113
|
+
};
|
|
114
|
+
return { placed, content };
|
|
115
|
+
});
|
|
116
|
+
const thumbnails = new Map();
|
|
117
|
+
for (const { placed, content } of tiles) {
|
|
118
|
+
if (content.kind === 'object' && content.thumbnail) {
|
|
119
|
+
try {
|
|
120
|
+
thumbnails.set(placed.id, await embedImage(doc, content.thumbnail));
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
skipped.push({
|
|
124
|
+
id: placed.id,
|
|
125
|
+
kind: 'tile',
|
|
126
|
+
reason: `Object thumbnail could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const firstRect = pageRects[0];
|
|
132
|
+
const fallbackDocEdge = STAMP_TILE_SIZE /
|
|
133
|
+
Math.max(fitRectToScreen(firstRect, FALLBACK_VIEW.width, FALLBACK_VIEW.height)
|
|
134
|
+
.zoom, 1e-6);
|
|
135
|
+
const hiddenLayerIds = new Set(canvas.layers.filter((l) => l.visible === false).map((l) => l.id));
|
|
136
|
+
const visible = (layerId) => !hiddenLayerIds.has(layerId);
|
|
137
|
+
for (const { page, box, rotation, rect } of geometry) {
|
|
138
|
+
// Per-page resource registries.
|
|
139
|
+
const alphaStates = new Map();
|
|
140
|
+
const alphaState = (alpha) => {
|
|
141
|
+
const a = Math.round(Math.min(1, Math.max(0, alpha)) * 1000) / 1000;
|
|
142
|
+
if (a >= 1)
|
|
143
|
+
return undefined;
|
|
144
|
+
let name = alphaStates.get(a);
|
|
145
|
+
if (!name) {
|
|
146
|
+
name = page.node.newExtGState('GSa', doc.context.obj({ Type: 'ExtGState', CA: a, ca: a }));
|
|
147
|
+
alphaStates.set(a, name);
|
|
148
|
+
}
|
|
149
|
+
return name;
|
|
150
|
+
};
|
|
151
|
+
const registerImage = (img) => ({
|
|
152
|
+
name: page.node.newXObject('Im', img.ref),
|
|
153
|
+
width: img.width,
|
|
154
|
+
height: img.height,
|
|
155
|
+
});
|
|
156
|
+
const ctx = { page, fonts, alphaState, skipped };
|
|
157
|
+
const onPage = (b) => b !== null && rectsIntersect(b, rect);
|
|
158
|
+
const ops = [];
|
|
159
|
+
// 1. Background image layers, array order.
|
|
160
|
+
for (const layer of layers) {
|
|
161
|
+
const img = layerImages.get(layer.id);
|
|
162
|
+
if (!img || !onPage(layerVisibleRect(layer)))
|
|
163
|
+
continue;
|
|
164
|
+
ops.push(...layerOps(layer, registerImage(img).name));
|
|
165
|
+
}
|
|
166
|
+
// 2. Strokes, then shapes, then measurement lines / rects — the Skia order.
|
|
167
|
+
for (const s of canvas.strokes) {
|
|
168
|
+
if (!visible(s.layerId) || !onPage(strokeBounds(s)))
|
|
169
|
+
continue;
|
|
170
|
+
ops.push(...strokeOps(s, ctx));
|
|
171
|
+
}
|
|
172
|
+
for (const sh of canvas.shapes) {
|
|
173
|
+
if (!visible(sh.layerId) || !onPage(shapeBounds(sh)))
|
|
174
|
+
continue;
|
|
175
|
+
ops.push(...shapeOps(sh, ctx));
|
|
176
|
+
}
|
|
177
|
+
for (const { placed } of tiles) {
|
|
178
|
+
if (!visible(placed.layerId) || !onPage(measurementMarkBounds(placed)))
|
|
179
|
+
continue;
|
|
180
|
+
ops.push(...measurementMarkOps(placed, ctx));
|
|
181
|
+
}
|
|
182
|
+
// 3. Tiles on top, like the overlay.
|
|
183
|
+
for (const { placed, content } of tiles) {
|
|
184
|
+
if (!visible(placed.layerId))
|
|
185
|
+
continue;
|
|
186
|
+
const footprint = tileFootprint(placed, canvas, input.calibration, fallbackDocEdge, designBoxFor(placed, content));
|
|
187
|
+
if (!onPage(footprint))
|
|
188
|
+
continue;
|
|
189
|
+
const thumb = thumbnails.get(placed.id);
|
|
190
|
+
ops.push(...tileOps({ page, fonts, thumbnail: thumb ? registerImage(thumb) : undefined }, content, footprint));
|
|
191
|
+
}
|
|
192
|
+
if (ops.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
page.pushOperators(...withMatrix(docToPageMatrix(rect, box, rotation), [
|
|
195
|
+
...clipRectOps(rect.x, rect.y, rect.width, rect.height),
|
|
196
|
+
...ops,
|
|
197
|
+
]));
|
|
198
|
+
}
|
|
199
|
+
const pdf = await doc.save({ useObjectStreams: false });
|
|
200
|
+
return { pdf, pageCount: pages.length, skipped };
|
|
201
|
+
};
|