@reekon-tools/boldr-utils 1.21.0 → 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.
@@ -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
+ }
@@ -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;