@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.
Files changed (98) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasSkia.js +3 -11
  2. package/dist/annotation/canvas/measurementLineStyle.d.ts +3 -0
  3. package/dist/annotation/canvas/measurementLineStyle.js +14 -0
  4. package/dist/export/pdf/color.d.ts +7 -0
  5. package/dist/export/pdf/color.js +39 -0
  6. package/dist/export/pdf/emit.d.ts +37 -0
  7. package/dist/export/pdf/emit.js +129 -0
  8. package/dist/export/pdf/fonts.d.ts +38 -0
  9. package/dist/export/pdf/fonts.js +97 -0
  10. package/dist/export/pdf/index.d.ts +5 -0
  11. package/dist/export/pdf/index.js +5 -0
  12. package/dist/export/pdf/layers.d.ts +5 -0
  13. package/dist/export/pdf/layers.js +19 -0
  14. package/dist/export/pdf/marks.d.ts +20 -0
  15. package/dist/export/pdf/marks.js +336 -0
  16. package/dist/export/pdf/pageMapping.d.ts +49 -0
  17. package/dist/export/pdf/pageMapping.js +67 -0
  18. package/dist/export/pdf/renderCanvasToPdf.d.ts +2 -0
  19. package/dist/export/pdf/renderCanvasToPdf.js +201 -0
  20. package/dist/export/pdf/tileContent.d.ts +39 -0
  21. package/dist/export/pdf/tileContent.js +218 -0
  22. package/dist/export/pdf/tiles.d.ts +43 -0
  23. package/dist/export/pdf/tiles.js +392 -0
  24. package/dist/export/pdf/types.d.ts +101 -0
  25. package/dist/export/pdf/types.js +10 -0
  26. package/dist/exports.d.ts +1 -1
  27. package/dist/exports.js +1 -1
  28. package/dist/formulas/calculateFormula.d.ts +1 -1
  29. package/dist/formulas/calculateFormula.js +23 -10
  30. package/dist/formulas/evaluateFormula.d.ts +0 -10
  31. package/dist/formulas/evaluateFormula.js +1 -12
  32. package/dist/formulas/groupFormula.d.ts +43 -0
  33. package/dist/formulas/groupFormula.js +168 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/server.d.ts +4 -1
  37. package/dist/server.js +6 -1
  38. package/package.json +4 -1
  39. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  40. package/dist/canvas/AnnotationCanvas.js +0 -10
  41. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  42. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  43. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  44. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  45. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  46. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  47. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  48. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  49. package/dist/canvas/Tool.d.ts +0 -38
  50. package/dist/canvas/Tool.js +0 -1
  51. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  52. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  53. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  54. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  55. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  56. package/dist/canvas/elements/ShapeElement.js +0 -62
  57. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  58. package/dist/canvas/elements/StrokeElement.js +0 -18
  59. package/dist/canvas/measurementPicker.d.ts +0 -10
  60. package/dist/canvas/measurementPicker.js +0 -1
  61. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  62. package/dist/canvas/measurementStampOverlay.js +0 -1
  63. package/dist/canvas/pointerAdapter.d.ts +0 -3
  64. package/dist/canvas/pointerAdapter.js +0 -19
  65. package/dist/canvas/stampLayout.d.ts +0 -5
  66. package/dist/canvas/stampLayout.js +0 -14
  67. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  68. package/dist/canvas/tools/measurementStampTool.js +0 -37
  69. package/dist/canvas/tools/panTool.d.ts +0 -5
  70. package/dist/canvas/tools/panTool.js +0 -25
  71. package/dist/canvas/tools/penTool.d.ts +0 -13
  72. package/dist/canvas/tools/penTool.js +0 -68
  73. package/dist/canvas/tools/selectTool.d.ts +0 -2
  74. package/dist/canvas/tools/selectTool.js +0 -182
  75. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  76. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  77. package/dist/canvas/viewport.d.ts +0 -16
  78. package/dist/canvas/viewport.js +0 -54
  79. package/dist/data/AnnotationDataContext.d.ts +0 -8
  80. package/dist/data/AnnotationDataContext.js +0 -11
  81. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  82. package/dist/data/AnnotationDataProvider.js +0 -4
  83. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  84. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  85. package/dist/data/canvasPersistence.d.ts +0 -3
  86. package/dist/data/canvasPersistence.js +0 -26
  87. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  88. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  89. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  90. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  91. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  92. package/dist/data/hooks/useAnnotationList.js +0 -26
  93. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  94. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  95. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  96. package/dist/hooks/useParseMeasurement.js +0 -14
  97. package/dist/utils/evaluateFormula.d.ts +0 -20
  98. package/dist/utils/evaluateFormula.js +0 -31
@@ -0,0 +1,392 @@
1
+ // Tile chrome: measurement tiles, header chips, object tiles, plane dimension
2
+ // chips and the blank input, drawn as PDF vectors. Each painter works in the
3
+ // tile's DESIGN space (the 96-unit STAMP_TILE_SIZE square, or a header's
4
+ // 200×56 / 90×65 box — the same natural-size-then-scale pattern the RN and
5
+ // DOM tiles use), and the renderer scales that box into the tile's doc-space
6
+ // footprint centred on the placed anchor.
7
+ //
8
+ // These are deliberately PDF-native renditions of the app's tiles — the same
9
+ // fills, type hierarchy and layout — not pixel captures of them.
10
+ import { clip, endPath, fill, popGraphicsState, pushGraphicsState, rectangle, stroke, } from 'pdf-lib';
11
+ import { FileUploadType } from '../../types/firestore.js';
12
+ import { STAMP_TILE_SIZE, stampTileDims, } from '../../annotation/canvas/stampLayout.js';
13
+ import { GROUP_HEADER_STYLE, } from '../../annotation/canvas/groupHeaderLayout.js';
14
+ import { FormColors, FormulaColors, GeneralColors, TileColors, } from '../../theme/colors.js';
15
+ import { circlePathOps, fillStateOps, imageOps, polylinePathOps, roundedRectPathOps, strokeStateOps, withMatrix, } from './emit.js';
16
+ const tileKind = (ref) => ref.fileId ? 'file' : ref.isGroupHeader ? 'header' : 'measurement';
17
+ /**
18
+ * Doc-space footprint of a placed tile. `fallbackDocEdge` is the doc-space
19
+ * edge a scale-1 measurement tile covers when neither the request nor the
20
+ * document carries a calibration.
21
+ */
22
+ export const tileFootprint = (ref, canvas, calibration, fallbackDocEdge, design) => {
23
+ const kind = tileKind(ref);
24
+ const measurementEdge = calibration?.tileDocSize ?? canvas.tileDocSize ?? fallbackDocEdge;
25
+ const edge = kind === 'header'
26
+ ? (calibration?.headerTileDocSize ?? measurementEdge)
27
+ : kind === 'file'
28
+ ? (calibration?.fileTileDocSize ?? measurementEdge)
29
+ : measurementEdge;
30
+ // stampTileDims at unit factors and unit zoom = the base footprint × the
31
+ // tile's own `scale`; the calibration edge then converts base units to doc.
32
+ const base = stampTileDims(ref, 1, 1, 1, 1);
33
+ const k = edge / STAMP_TILE_SIZE;
34
+ const width = base.width * k;
35
+ const height = base.height * k;
36
+ return {
37
+ x: ref.anchor.x - width / 2,
38
+ y: ref.anchor.y - height / 2,
39
+ width,
40
+ height,
41
+ design,
42
+ };
43
+ };
44
+ export const designBoxFor = (ref, content) => content.kind === 'header'
45
+ ? content.descriptor.design
46
+ : ref.isGroupHeader
47
+ ? { width: 200, height: 56 }
48
+ : { width: STAMP_TILE_SIZE, height: STAMP_TILE_SIZE };
49
+ /** Doc ← design matrix for a footprint. */
50
+ export const designToDocMatrix = (f) => {
51
+ const k = f.width / f.design.width;
52
+ return [k, 0, 0, k, f.x, f.y];
53
+ };
54
+ /* ------------------------------------------------------------------ *
55
+ * Shared bits
56
+ * ------------------------------------------------------------------ */
57
+ const SQUARE = STAMP_TILE_SIZE;
58
+ const CARD_RADIUS = 6;
59
+ const filledRoundedRect = (x, y, w, h, r, color) => [
60
+ pushGraphicsState(),
61
+ ...fillStateOps(color),
62
+ ...roundedRectPathOps(x, y, w, h, r),
63
+ fill(),
64
+ popGraphicsState(),
65
+ ];
66
+ const strokedRoundedRect = (x, y, w, h, r, color, width) => [
67
+ pushGraphicsState(),
68
+ ...strokeStateOps({ color, width, join: 'miter' }),
69
+ ...roundedRectPathOps(x + width / 2, y + width / 2, w - width, h - width, r),
70
+ stroke(),
71
+ popGraphicsState(),
72
+ ];
73
+ /** Truncate to `maxWidth` with an ellipsis, like numberOfLines={1}. */
74
+ const truncate = (fonts, text, size, maxWidth) => {
75
+ if (fonts.width(text, size) <= maxWidth)
76
+ return text;
77
+ const ell = '…';
78
+ let t = text;
79
+ while (t.length > 0 && fonts.width(t + ell, size) > maxWidth)
80
+ t = t.slice(0, -1);
81
+ return t.length > 0 ? t + ell : '';
82
+ };
83
+ /* ------------------------------------------------------------------ *
84
+ * Measurement value row: tokens with stacked fractions
85
+ * ------------------------------------------------------------------ */
86
+ // Mirror of the mobile tileValueFont model at a 96-unit tile.
87
+ const DIGIT_ADVANCE_EM = 0.68;
88
+ const FRACTION_STACK_CHARS = 1.4;
89
+ const FIT_K = 0.9 / DIGIT_ADVANCE_EM;
90
+ const TILE_VALUE_MAX_TILE_SCALE = 0.8 / 3;
91
+ const UNIT_FONT = 12; // clamp(96 × 0.1, 12, 16)
92
+ const UNIT_RESERVE = Math.round(UNIT_FONT * (2 * 0.854 + 0.2));
93
+ const tokenize = (value) => value
94
+ .trim()
95
+ .split(/\s+/)
96
+ .filter(Boolean)
97
+ .map((t) => {
98
+ if (!t.includes('/'))
99
+ return { text: t };
100
+ const [num, rest = ''] = t.split('/');
101
+ const den = rest.replace(/[^0-9]/g, '');
102
+ const suffix = rest.replace(/[0-9]/g, '');
103
+ return { text: t, fraction: { num: num ?? '', den, suffix } };
104
+ });
105
+ const effectiveLength = (tokens) => tokens.reduce((n, t, i) => n +
106
+ (i > 0 ? 1 : 0) +
107
+ (t.fraction
108
+ ? FRACTION_STACK_CHARS + t.fraction.suffix.length
109
+ : t.text.length), 0);
110
+ /**
111
+ * Draw a converted value string centred at (cx, cy) in design units with
112
+ * whole parts large and fractions stacked over a rule. Returns the ops.
113
+ */
114
+ const valueRowOps = (ctx, value, cx, cy, color, availableWidth, tileWidth) => {
115
+ const tokens = tokenize(value);
116
+ if (tokens.length === 0)
117
+ return [];
118
+ const chars = Math.max(effectiveLength(tokens), 1);
119
+ const fs = Math.min(TILE_VALUE_MAX_TILE_SCALE * tileWidth, (FIT_K / chars) * availableWidth);
120
+ const fracFs = fs * 0.8;
121
+ const gap = fs * 0.15;
122
+ // Measure the row.
123
+ const widths = tokens.map((t) => {
124
+ if (!t.fraction)
125
+ return ctx.fonts.width(t.text, fs);
126
+ const stack = Math.max(ctx.fonts.width(t.fraction.num, fracFs), ctx.fonts.width(t.fraction.den, fracFs));
127
+ return (stack + (t.fraction.suffix ? ctx.fonts.width(t.fraction.suffix, fs) : 0));
128
+ });
129
+ const total = widths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
130
+ let x = cx - total / 2;
131
+ const ops = [];
132
+ const baseline = cy + fs * 0.35; // cap-height centred on cy
133
+ tokens.forEach((t, i) => {
134
+ const w = widths[i];
135
+ if (!t.fraction) {
136
+ ops.push(...ctx.fonts.textOps(ctx.page, t.text, fs, color, x, baseline));
137
+ }
138
+ else {
139
+ const stackW = Math.max(ctx.fonts.width(t.fraction.num, fracFs), ctx.fonts.width(t.fraction.den, fracFs));
140
+ const scx = x + stackW / 2;
141
+ // Numerator above the rule, denominator below; rule at the row centre.
142
+ ops.push(...ctx.fonts.textOps(ctx.page, t.fraction.num, fracFs, color, scx, cy - fracFs * 0.15, { align: 'center' }), pushGraphicsState(), ...fillStateOps(color), rectangle(x, cy - fracFs * 0.05, stackW, Math.max(fracFs * 0.08, 0.8)), fill(), popGraphicsState(), ...ctx.fonts.textOps(ctx.page, t.fraction.den, fracFs, color, scx, cy + fracFs * 0.85, { align: 'center' }));
143
+ if (t.fraction.suffix) {
144
+ ops.push(...ctx.fonts.textOps(ctx.page, t.fraction.suffix, fs, color, x + stackW, baseline));
145
+ }
146
+ }
147
+ x += w + gap;
148
+ });
149
+ return ops;
150
+ };
151
+ /* ------------------------------------------------------------------ *
152
+ * Painters (design space, y-down)
153
+ * ------------------------------------------------------------------ */
154
+ const measurementTileOps = (ctx, c) => {
155
+ const ops = filledRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, c.fill);
156
+ const white = GeneralColors.white;
157
+ // Header row: position label, bold, top-left.
158
+ if (c.positionLabel) {
159
+ ops.push(...ctx.fonts.textOps(ctx.page, c.positionLabel, 12, white, 6, 14));
160
+ }
161
+ // Value row, centred on the tile, between the two unit slots.
162
+ const hasUnit = !!c.unit;
163
+ const available = hasUnit ? SQUARE - 2 * UNIT_RESERVE : SQUARE - 8;
164
+ const valueText = (c.negative ? '-' : '') + c.value;
165
+ ops.push(...valueRowOps(ctx, valueText, SQUARE / 2, 48, white, available, SQUARE));
166
+ if (hasUnit) {
167
+ ops.push(...ctx.fonts.textOps(ctx.page, c.unit, UNIT_FONT, white, SQUARE - UNIT_RESERVE / 2, 52, {
168
+ align: 'center',
169
+ }));
170
+ }
171
+ // Bottom label: bare white ink, or on the formula tile's dark-magenta bar.
172
+ if (c.label) {
173
+ const labelSize = 11;
174
+ if (c.formulaBar) {
175
+ ops.push(...filledRoundedRect(4, SQUARE - 20, SQUARE - 8, 16, 3, FormulaColors.darkPurple), ...strokedRoundedRect(4, SQUARE - 20, SQUARE - 8, 16, 3, FormulaColors.purple, 1));
176
+ }
177
+ const text = truncate(ctx.fonts, c.label, labelSize, SQUARE - 12);
178
+ ops.push(...ctx.fonts.textOps(ctx.page, text, labelSize, white, SQUARE / 2, SQUARE - 8, { align: 'center' }));
179
+ }
180
+ return ops;
181
+ };
182
+ const emptyTileOps = (ctx, c) => {
183
+ const ink = FormColors.lightGrey1;
184
+ const ops = [
185
+ ...filledRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, GeneralColors.grey2),
186
+ ...strokedRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, GeneralColors.grey3, 1),
187
+ ];
188
+ if (c.topLeftLabel)
189
+ ops.push(...ctx.fonts.textOps(ctx.page, c.topLeftLabel, 10, ink, 8, 18));
190
+ if (c.topRightLabel) {
191
+ ops.push(...ctx.fonts.textOps(ctx.page, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
192
+ align: 'right',
193
+ }));
194
+ }
195
+ // The "+" well.
196
+ ops.push(...filledRoundedRect(8, 28, SQUARE - 16, 40, 4, GeneralColors.grey3));
197
+ const cx = SQUARE / 2;
198
+ const cy = 48;
199
+ ops.push(pushGraphicsState(), ...strokeStateOps({ color: ink, width: 2.5, cap: 'round' }), ...polylinePathOps([
200
+ { x: cx - 8, y: cy },
201
+ { x: cx + 8, y: cy },
202
+ ]), stroke(), ...polylinePathOps([
203
+ { x: cx, y: cy - 8 },
204
+ { x: cx, y: cy + 8 },
205
+ ]), stroke(), popGraphicsState());
206
+ if (c.bottomLabel) {
207
+ const text = truncate(ctx.fonts, c.bottomLabel, 10, SQUARE - 16);
208
+ ops.push(...ctx.fonts.textOps(ctx.page, text, 10, ink, cx, SQUARE - 10, {
209
+ align: 'center',
210
+ }));
211
+ }
212
+ return ops;
213
+ };
214
+ const literalTileOps = (ctx, c) => {
215
+ const filled = c.value !== '';
216
+ const fillColor = filled ? TileColors.activeBlue : GeneralColors.grey2;
217
+ const ink = filled ? GeneralColors.white : GeneralColors.grey4;
218
+ const ops = filledRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, fillColor);
219
+ ops.push(...ctx.fonts.textOps(ctx.page, c.topLeftLabel, 10, ink, 8, 18));
220
+ ops.push(...ctx.fonts.textOps(ctx.page, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
221
+ align: 'right',
222
+ }));
223
+ ops.push(...filledRoundedRect(8, 28, SQUARE - 16, 40, 4, filled ? TileColors.activeBlue : GeneralColors.grey3));
224
+ if (filled) {
225
+ const size = ctx.fonts.fitSize(c.value, SQUARE - 24, 16, 6);
226
+ ops.push(...ctx.fonts.textOps(ctx.page, c.value, size, ink, SQUARE / 2, 48 + size * 0.35, { align: 'center' }));
227
+ }
228
+ const bottom = truncate(ctx.fonts, c.bottomLabel, 10, SQUARE - 16);
229
+ ops.push(...ctx.fonts.textOps(ctx.page, bottom, 10, ink, SQUARE / 2, SQUARE - 10, {
230
+ align: 'center',
231
+ }));
232
+ return ops;
233
+ };
234
+ const globeOps = (cx, cy, size, color) => {
235
+ const r = size / 2 - 1;
236
+ return [
237
+ pushGraphicsState(),
238
+ ...strokeStateOps({ color, width: 1.75, join: 'round' }),
239
+ ...circlePathOps(cx, cy, r),
240
+ stroke(),
241
+ ...polylinePathOps([
242
+ { x: cx - r, y: cy },
243
+ { x: cx + r, y: cy },
244
+ ]),
245
+ stroke(),
246
+ // Meridian as a narrow ellipse: scale x by 0.45 around cx.
247
+ ...withMatrix([0.45, 0, 0, 1, cx * (1 - 0.45), 0], [...circlePathOps(cx, cy, r), stroke()]),
248
+ popGraphicsState(),
249
+ ];
250
+ };
251
+ const headerTileOps = (ctx, c) => {
252
+ const d = c.descriptor;
253
+ const S = GROUP_HEADER_STYLE;
254
+ const W = d.design.width;
255
+ const H = d.design.height;
256
+ const ops = filledRoundedRect(0, 0, W, H, S.radius, S.background);
257
+ const inner = W - 2 * S.padding;
258
+ const nameBlock = d.name !== null ? S.nameGap + S.nameFontSize * 1.2 : 0;
259
+ const badgesBlock = d.collapsed
260
+ ? d.badges.length * S.badgeHeight +
261
+ Math.max(0, d.badges.length - 1) * S.badgeGapCollapsed
262
+ : d.badges.length > 0
263
+ ? S.badgeHeight
264
+ : 0;
265
+ const contentH = badgesBlock + nameBlock;
266
+ let y = (H - contentH) / 2;
267
+ if (d.badges.length > 0) {
268
+ if (d.collapsed) {
269
+ d.badges.forEach((b, i) => {
270
+ const by = y + i * (S.badgeHeight + S.badgeGapCollapsed);
271
+ ops.push(...filledRoundedRect(S.padding, by, inner, S.badgeHeight, S.badgeRadius, b.background));
272
+ if (d.showGlobe)
273
+ ops.push(...globeOps(W / 2, by + S.badgeHeight / 2, S.globeSize, S.globeColor));
274
+ else {
275
+ const text = truncate(ctx.fonts, b.text, S.badgeFontSize, inner - 10);
276
+ ops.push(...ctx.fonts.textOps(ctx.page, text, S.badgeFontSize, S.badgeTextColor, W / 2, by + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
277
+ }
278
+ });
279
+ }
280
+ else {
281
+ const n = d.badges.length;
282
+ const bw = (inner - S.badgeGapExpanded * (n - 1)) / n;
283
+ d.badges.forEach((b, i) => {
284
+ const bx = S.padding + i * (bw + S.badgeGapExpanded);
285
+ ops.push(...filledRoundedRect(bx, y, bw, S.badgeHeight, S.badgeRadius, b.background));
286
+ if (d.showGlobe)
287
+ ops.push(...globeOps(bx + bw / 2, y + S.badgeHeight / 2, S.globeSize, S.globeColor));
288
+ else {
289
+ const text = truncate(ctx.fonts, b.text, S.badgeFontSize, bw - 10);
290
+ ops.push(...ctx.fonts.textOps(ctx.page, text, S.badgeFontSize, S.badgeTextColor, bx + bw / 2, y + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
291
+ }
292
+ });
293
+ }
294
+ y += badgesBlock;
295
+ }
296
+ if (d.name !== null) {
297
+ const text = truncate(ctx.fonts, d.name, S.nameFontSize, inner);
298
+ ops.push(...ctx.fonts.textOps(ctx.page, text, S.nameFontSize, S.nameColor, W / 2, y + S.nameGap + S.nameFontSize * 0.95, { align: 'center' }));
299
+ }
300
+ return ops;
301
+ };
302
+ const OBJECT_INSET = 4;
303
+ const OBJECT_BAR_HEIGHT = 20;
304
+ const objectTileOps = (ctx, c) => {
305
+ const x = OBJECT_INSET;
306
+ const y = OBJECT_INSET;
307
+ const w = SQUARE - 2 * OBJECT_INSET;
308
+ const h = SQUARE - 2 * OBJECT_INSET;
309
+ const ops = [];
310
+ const thumb = ctx.thumbnail;
311
+ if (!thumb) {
312
+ ops.push(...filledRoundedRect(x, y, w, h, CARD_RADIUS, GeneralColors.grey2));
313
+ const name = truncate(ctx.fonts, c.name || 'Object removed', 9, w - 8);
314
+ ops.push(...ctx.fonts.textOps(ctx.page, name, 9, GeneralColors.grey4, SQUARE / 2, SQUARE / 2 + 3, { align: 'center' }));
315
+ return ops;
316
+ }
317
+ // Thumbnail, cover-fit and clipped to the rounded card.
318
+ const scale = Math.max(w / thumb.width, h / thumb.height);
319
+ const iw = thumb.width * scale;
320
+ const ih = thumb.height * scale;
321
+ ops.push(pushGraphicsState(), ...roundedRectPathOps(x, y, w, h, CARD_RADIUS), clip(), endPath(), ...imageOps(thumb.name, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih), popGraphicsState());
322
+ // Footer bar by object type: a layout group gets the magenta bar with white
323
+ // ink, a plain photo/background none, every document a white bar with dark ink.
324
+ const type = c.fileType;
325
+ const bar = type === FileUploadType.LayoutGroup
326
+ ? { fill: FormulaColors.darkPurple, ink: GeneralColors.white }
327
+ : type === FileUploadType.Background || type === undefined
328
+ ? null
329
+ : { fill: GeneralColors.white, ink: GeneralColors.darkGrey };
330
+ if (bar) {
331
+ ops.push(pushGraphicsState(), ...roundedRectPathOps(x, y, w, h, CARD_RADIUS), clip(), endPath(), ...fillStateOps(bar.fill), rectangle(x, y + h - OBJECT_BAR_HEIGHT, w, OBJECT_BAR_HEIGHT), fill(), popGraphicsState());
332
+ const name = truncate(ctx.fonts, c.name, 9, w - 8);
333
+ ops.push(...ctx.fonts.textOps(ctx.page, name, 9, bar.ink, SQUARE / 2, y + h - OBJECT_BAR_HEIGHT / 2 + 3, { align: 'center' }));
334
+ }
335
+ return ops;
336
+ };
337
+ const planeChipOps = (ctx, c) => {
338
+ const ops = filledRoundedRect(0, 0, SQUARE, SQUARE, 8, TileColors.activeBlue);
339
+ const usable = SQUARE - 22;
340
+ const text = c.unit ? `${c.value} ${c.unit}` : c.value;
341
+ ops.push(...valueRowOps(ctx, text, SQUARE / 2, SQUARE / 2, GeneralColors.white, usable, SQUARE));
342
+ return ops;
343
+ };
344
+ const instructionsTileOps = (ctx, c) => {
345
+ const ops = filledRoundedRect(0, 0, SQUARE, SQUARE, 8, c.backgroundColor || '#EDEDED');
346
+ const color = c.textColor || '#2D3843';
347
+ const size = 11;
348
+ const maxW = SQUARE - 16;
349
+ // Word-wrap to at most four lines, like numberOfLines={4}.
350
+ const lines = [];
351
+ let current = '';
352
+ for (const word of c.text.split(/\s+/).filter(Boolean)) {
353
+ const next = current ? `${current} ${word}` : word;
354
+ if (ctx.fonts.width(next, size) <= maxW || !current)
355
+ current = next;
356
+ else {
357
+ lines.push(current);
358
+ current = word;
359
+ }
360
+ if (lines.length === 4)
361
+ break;
362
+ }
363
+ if (current && lines.length < 4)
364
+ lines.push(current);
365
+ const lh = size * 1.25;
366
+ const top = SQUARE / 2 - (lines.length * lh) / 2;
367
+ lines.forEach((line, i) => {
368
+ ops.push(...ctx.fonts.textOps(ctx.page, truncate(ctx.fonts, line, size, maxW), size, color, SQUARE / 2, top + i * lh + size * 0.9, { align: 'center' }));
369
+ });
370
+ return ops;
371
+ };
372
+ /** Operators for a tile in DESIGN space. */
373
+ export const tileDesignOps = (ctx, content) => {
374
+ switch (content.kind) {
375
+ case 'measurement':
376
+ return measurementTileOps(ctx, content);
377
+ case 'empty':
378
+ return emptyTileOps(ctx, content);
379
+ case 'literal':
380
+ return literalTileOps(ctx, content);
381
+ case 'header':
382
+ return headerTileOps(ctx, content);
383
+ case 'object':
384
+ return objectTileOps(ctx, content);
385
+ case 'planeDimension':
386
+ return planeChipOps(ctx, content);
387
+ case 'instructions':
388
+ return instructionsTileOps(ctx, content);
389
+ }
390
+ };
391
+ /** Operators for a tile in DOC space: design ops under the footprint matrix. */
392
+ export const tileOps = (ctx, content, footprint) => withMatrix(designToDocMatrix(footprint), tileDesignOps(ctx, content));
@@ -0,0 +1,101 @@
1
+ import type { GroupHeaderDescriptor } from '../../annotation/canvas/groupHeaderLayout.js';
2
+ import type { AnnotationCanvasState, PlacedMeasurementRef } from '../../types/annotation.js';
3
+ export interface EmbeddedImage {
4
+ bytes: Uint8Array;
5
+ mime: 'image/png' | 'image/jpeg';
6
+ }
7
+ /**
8
+ * Doc-space edge length (PDF points) a scale-1 measurement tile covers, and
9
+ * the same for header chips and object tiles. Tile footprints on screen are
10
+ * zoom-relative, so only the client that is exporting knows how large its
11
+ * tiles are against the drawing; it computes these with
12
+ * `tileDocSizeForFactor(resolvedFactor, baseFitZoom)` (stampLayout.ts) and
13
+ * sends them along. Absent fields fall back to the persisted `tileDocSize`,
14
+ * then to a default derived from the first page.
15
+ */
16
+ export interface PdfExportCalibration {
17
+ tileDocSize?: number;
18
+ headerTileDocSize?: number;
19
+ fileTileDocSize?: number;
20
+ }
21
+ /** What a measurement / angle / formula tile displays. */
22
+ export interface MeasurementTileContent {
23
+ kind: 'measurement';
24
+ /** Converted display string, e.g. "12 3/16", "5' 6 1/2\"", "45.2°". */
25
+ value: string;
26
+ /** Unit column text ("in", "mm", "ft"); empty for scalars. */
27
+ unit: string;
28
+ /** Header-row identifier ("A3", "B12"); empty hides it. */
29
+ positionLabel?: string;
30
+ /** Bottom label (measurement label / column name / override). */
31
+ label?: string;
32
+ /** Card fill (measurementTileFill precedence already applied). */
33
+ fill: string;
34
+ /** Formula tiles carry their label on a dark-magenta bar. */
35
+ formulaBar?: boolean;
36
+ negative?: boolean;
37
+ }
38
+ /** A FORM column holding a literal (text / number / select / boolean). */
39
+ export interface LiteralTileContent {
40
+ kind: 'literal';
41
+ value: string;
42
+ topLeftLabel: string;
43
+ topRightLabel: string;
44
+ bottomLabel: string;
45
+ }
46
+ /** The blank "+" input tile. */
47
+ export interface EmptyTileContent {
48
+ kind: 'empty';
49
+ topLeftLabel?: string;
50
+ topRightLabel?: string;
51
+ bottomLabel?: string;
52
+ }
53
+ export interface HeaderTileContent {
54
+ kind: 'header';
55
+ descriptor: GroupHeaderDescriptor;
56
+ }
57
+ export interface ObjectTileContent {
58
+ kind: 'object';
59
+ name: string;
60
+ /** FileUploadType value; decides the footer bar treatment. */
61
+ fileType?: string;
62
+ thumbnail?: EmbeddedImage;
63
+ }
64
+ export interface PlaneDimensionTileContent {
65
+ kind: 'planeDimension';
66
+ value: string;
67
+ unit: string;
68
+ }
69
+ export interface InstructionsTileContent {
70
+ kind: 'instructions';
71
+ text: string;
72
+ textColor?: string;
73
+ backgroundColor?: string;
74
+ }
75
+ export type ResolvedTileContent = MeasurementTileContent | LiteralTileContent | EmptyTileContent | HeaderTileContent | ObjectTileContent | PlaneDimensionTileContent | InstructionsTileContent;
76
+ export interface SkippedItem {
77
+ id: string;
78
+ kind: 'layer' | 'tile' | 'shape' | 'stroke' | 'text';
79
+ reason: string;
80
+ }
81
+ export interface RenderCanvasToPdfInput {
82
+ /** The original PDF the canvas is drawn over. */
83
+ sourcePdf: Uint8Array;
84
+ canvas: AnnotationCanvasState;
85
+ /** Content for each placed tile; `undefined` renders the blank tile. */
86
+ resolveTile: (ref: PlacedMeasurementRef) => ResolvedTileContent | undefined;
87
+ /** Decoded bytes per raster background layer id. Missing → skipped. */
88
+ layerImages: Record<string, EmbeddedImage>;
89
+ calibration?: PdfExportCalibration;
90
+ /** TTF/OTF bytes of the primary font (Metropolis Bold in the apps). */
91
+ primaryFont: Uint8Array;
92
+ }
93
+ export interface RenderCanvasToPdfResult {
94
+ pdf: Uint8Array;
95
+ pageCount: number;
96
+ skipped: SkippedItem[];
97
+ }
98
+ export declare class PdfExportError extends Error {
99
+ readonly code: 'no-pdf-document' | 'page-count-mismatch' | 'page-geometry-mismatch' | 'encrypted' | 'corrupt';
100
+ constructor(code: PdfExportError['code'], message: string);
101
+ }
@@ -0,0 +1,10 @@
1
+ // Public types of the canvas → PDF exporter. The renderer is PURE: it never
2
+ // touches Firestore or Storage. Whoever calls it (the Cloud Function) resolves
3
+ // every tile's content and every layer's bytes first and hands them in.
4
+ export class PdfExportError extends Error {
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = 'PdfExportError';
8
+ this.code = code;
9
+ }
10
+ }
package/dist/exports.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
1
+ export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
2
2
  export { calculateFormula } from './formulas/calculateFormula.js';
3
3
  export { convertMicrometers } from './utils/micrometersToUnit.js';
4
4
  export { parseMeasurement } from './utils/parseMeasurement.js';
package/dist/exports.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // platform-specific `AnnotationCanvas` wrapper lives here. The two index
3
3
  // files (index.ts, index.native.ts) re-export from this and add their own
4
4
  // AnnotationCanvas import.
5
- export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, } from './formulas/evaluateFormula.js';
5
+ export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, } from './formulas/evaluateFormula.js';
6
6
  export { calculateFormula } from './formulas/calculateFormula.js';
7
7
  export { convertMicrometers } from './utils/micrometersToUnit.js';
8
8
  export { parseMeasurement } from './utils/parseMeasurement.js';
@@ -1,2 +1,2 @@
1
1
  import { ColumnConfig, DecimalTolerance, FractionalTolerance, Formula, Measurement, Units } from '../types/firestore.js';
2
- export declare const calculateFormula: (formula: Formula, formulas: Formula[], columns: Record<string, string>, tableConfig: ColumnConfig[], measurements: Measurement[], unit: Units, _fractionalTolerance?: FractionalTolerance, _decimalTolerance?: DecimalTolerance) => string | number | null;
2
+ export declare const calculateFormula: (formula: Formula, formulas: Formula[], columns: Record<string, string>, tableConfig: ColumnConfig[], measurements: Measurement[], unit: Units, fractionalTolerance: FractionalTolerance, decimalTolerance: DecimalTolerance) => string | number | null;
@@ -1,9 +1,25 @@
1
1
  import { ColumnType, } from '../types/firestore.js';
2
- import { evaluateFormula, micrometersToUnitNumber, } from './evaluateFormula.js';
3
- export const calculateFormula = (formula, formulas, columns, tableConfig, measurements, unit,
4
- // Unused since measurement values stopped round-tripping through the display
5
- // string — kept so the 8-arg call sites in the apps don't break.
6
- _fractionalTolerance, _decimalTolerance) => {
2
+ import { evaluateFormula } from './evaluateFormula.js';
3
+ import { convertMicrometers } from '../utils/micrometersToUnit.js';
4
+ function parseMixedNumber(str) {
5
+ const parts = str.trim().split(' ');
6
+ let whole = 0;
7
+ let fraction = 0;
8
+ if (parts.length === 2) {
9
+ whole = parseFloat(parts[0]);
10
+ const [num, den] = parts[1].split('/').map(Number);
11
+ fraction = num / den;
12
+ }
13
+ else if (parts[0].includes('/')) {
14
+ const [num, den] = parts[0].split('/').map(Number);
15
+ fraction = num / den;
16
+ }
17
+ else {
18
+ whole = parseFloat(parts[0]);
19
+ }
20
+ return whole + fraction;
21
+ }
22
+ export const calculateFormula = (formula, formulas, columns, tableConfig, measurements, unit, fractionalTolerance, decimalTolerance) => {
7
23
  // Convert Firestore Map to plain object if necessary
8
24
  let variableToColumnMap = {};
9
25
  if (formula.variableToColumnMap) {
@@ -29,11 +45,8 @@ _fractionalTolerance, _decimalTolerance) => {
29
45
  const measurement = measurements.find((m) => m.id === measurementId);
30
46
  if (!measurement)
31
47
  continue;
32
- // Straight um -> display-unit number. The old path rendered the display
33
- // STRING (convertMicrometers) and re-parsed it, which rounded the value
34
- // to the display tolerance and returned NaN / dropped the inches for the
35
- // composite feet-inches formats (`5' 3 1/2"`).
36
- valueMap[columnId] = micrometersToUnitNumber(measurement.value, unit);
48
+ const { value, unit: displayUnit } = convertMicrometers(measurement.value, unit, fractionalTolerance, decimalTolerance);
49
+ valueMap[columnId] = parseMixedNumber(value);
37
50
  }
38
51
  else if (column.type === ColumnType.Angle) {
39
52
  const measurement = measurements.find((m) => m.id === measurementId);
@@ -17,16 +17,6 @@ export interface FormulaEvaluationOptions {
17
17
  formulas?: FormulaDefinition[];
18
18
  scope?: Record<string, number>;
19
19
  }
20
- export declare function normalizeUnitForMathJS(unit: Units | string): string;
21
- /**
22
- * Convert a stored micrometer value to the number the formula scope expects
23
- * for `unit` — the same conversion nested-formula results go through. This is
24
- * the ONLY sanctioned way to put a measurement into a valueMap: converting via
25
- * the display STRING (convertMicrometers + a string parser) both rounds the
26
- * value to the display tolerance and falls apart on composite formats like
27
- * `5' 3 1/2"`.
28
- */
29
- export declare function micrometersToUnitNumber(micrometers: number, unit: Units): number;
30
20
  export declare function evaluateFormula({ expression, mappings, valueMap, unit, formulas, scope, }: FormulaEvaluationOptions): number | string | null;
31
21
  export declare function createFormulaScope({ mappings, valueMap, unit, }: {
32
22
  mappings: Record<string, string>;
@@ -7,7 +7,7 @@ const compile = math.compile.bind(math);
7
7
  const mathUnit = math.unit.bind(math);
8
8
  // Cache for evaluated formulas to improve performance
9
9
  const formulaCache = new Map();
10
- export function normalizeUnitForMathJS(unit) {
10
+ function normalizeUnitForMathJS(unit) {
11
11
  switch (unit) {
12
12
  case Units.Centimeters:
13
13
  case 'cm':
@@ -30,17 +30,6 @@ export function normalizeUnitForMathJS(unit) {
30
30
  return 'mm'; // fallback
31
31
  }
32
32
  }
33
- /**
34
- * Convert a stored micrometer value to the number the formula scope expects
35
- * for `unit` — the same conversion nested-formula results go through. This is
36
- * the ONLY sanctioned way to put a measurement into a valueMap: converting via
37
- * the display STRING (convertMicrometers + a string parser) both rounds the
38
- * value to the display tolerance and falls apart on composite formats like
39
- * `5' 3 1/2"`.
40
- */
41
- export function micrometersToUnitNumber(micrometers, unit) {
42
- return mathUnit(micrometers, 'um').toNumber(normalizeUnitForMathJS(unit));
43
- }
44
33
  // Normalize mapping to EnhancedMapping format
45
34
  function normalizeMapping(mapping) {
46
35
  if (typeof mapping === 'string') {
@@ -0,0 +1,43 @@
1
+ import { Units } from '../types/firestore.js';
2
+ /** Frame types of the stack-expression formula the apps persist. */
3
+ export declare enum GroupFormulaFrameType {
4
+ Operator = "operator",
5
+ Number = "number",
6
+ Paren = "paren",
7
+ Fraction = "fraction",
8
+ FeetInches = "feetInches",
9
+ MeasurementObj = "measurement",
10
+ InspectionInput = "InspectionInput",
11
+ Func = "func",
12
+ Output = "output"
13
+ }
14
+ export interface GroupFormulaFraction {
15
+ int: string;
16
+ numerator: string;
17
+ denominator: string;
18
+ }
19
+ export interface GroupFormulaFeetInches {
20
+ feet: string;
21
+ int: string;
22
+ numerator: string;
23
+ denominator: string;
24
+ }
25
+ export interface GroupFormulaFrame {
26
+ type: GroupFormulaFrameType | string;
27
+ value: unknown;
28
+ measurement?: {
29
+ value: number;
30
+ };
31
+ func?: string;
32
+ }
33
+ export declare const micrometersToUnitNumber: (um: number, unit: Units) => number;
34
+ export declare const unitNumberToMicrometers: (value: number, unit: Units) => number;
35
+ /** The mathjs expression string a stack formula evaluates to, for `valueUm`. */
36
+ export declare const groupFormulaExpression: (frames: GroupFormulaFrame[], valueUm: number, unit: Units | undefined) => string;
37
+ /**
38
+ * Apply a group's stored `formula` to a measurement value (micrometers) and
39
+ * return the result in micrometers, or null when the group has no formula or
40
+ * the expression does not evaluate to a finite number (the caller falls back
41
+ * to the raw value, exactly like the tiles do).
42
+ */
43
+ export declare const applyGroupFormulaUm: (formula: unknown, valueUm: number, unit: Units | undefined) => number | null;