@reekon-tools/boldr-utils 1.21.1 → 1.23.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 (104) 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/annots.d.ts +23 -0
  5. package/dist/export/pdf/annots.js +135 -0
  6. package/dist/export/pdf/color.d.ts +7 -0
  7. package/dist/export/pdf/color.js +39 -0
  8. package/dist/export/pdf/decrypt.d.ts +21 -0
  9. package/dist/export/pdf/decrypt.js +121 -0
  10. package/dist/export/pdf/emit.d.ts +37 -0
  11. package/dist/export/pdf/emit.js +129 -0
  12. package/dist/export/pdf/fonts.d.ts +37 -0
  13. package/dist/export/pdf/fonts.js +85 -0
  14. package/dist/export/pdf/index.d.ts +7 -0
  15. package/dist/export/pdf/index.js +7 -0
  16. package/dist/export/pdf/layers.d.ts +5 -0
  17. package/dist/export/pdf/layers.js +19 -0
  18. package/dist/export/pdf/marks.d.ts +81 -0
  19. package/dist/export/pdf/marks.js +479 -0
  20. package/dist/export/pdf/pageMapping.d.ts +51 -0
  21. package/dist/export/pdf/pageMapping.js +77 -0
  22. package/dist/export/pdf/renderCanvasToPdf.d.ts +2 -0
  23. package/dist/export/pdf/renderCanvasToPdf.js +288 -0
  24. package/dist/export/pdf/resources.d.ts +35 -0
  25. package/dist/export/pdf/resources.js +95 -0
  26. package/dist/export/pdf/tileContent.d.ts +39 -0
  27. package/dist/export/pdf/tileContent.js +218 -0
  28. package/dist/export/pdf/tiles.d.ts +39 -0
  29. package/dist/export/pdf/tiles.js +392 -0
  30. package/dist/export/pdf/types.d.ts +112 -0
  31. package/dist/export/pdf/types.js +10 -0
  32. package/dist/exports.d.ts +1 -1
  33. package/dist/exports.js +1 -1
  34. package/dist/formulas/calculateFormula.d.ts +1 -1
  35. package/dist/formulas/calculateFormula.js +23 -10
  36. package/dist/formulas/evaluateFormula.d.ts +0 -10
  37. package/dist/formulas/evaluateFormula.js +1 -12
  38. package/dist/formulas/groupFormula.d.ts +43 -0
  39. package/dist/formulas/groupFormula.js +168 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/server.d.ts +4 -1
  43. package/dist/server.js +6 -1
  44. package/package.json +9 -1
  45. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  46. package/dist/canvas/AnnotationCanvas.js +0 -10
  47. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  48. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  49. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  50. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  51. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  52. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  53. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  54. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  55. package/dist/canvas/Tool.d.ts +0 -38
  56. package/dist/canvas/Tool.js +0 -1
  57. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  58. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  59. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  60. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  61. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  62. package/dist/canvas/elements/ShapeElement.js +0 -62
  63. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  64. package/dist/canvas/elements/StrokeElement.js +0 -18
  65. package/dist/canvas/measurementPicker.d.ts +0 -10
  66. package/dist/canvas/measurementPicker.js +0 -1
  67. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  68. package/dist/canvas/measurementStampOverlay.js +0 -1
  69. package/dist/canvas/pointerAdapter.d.ts +0 -3
  70. package/dist/canvas/pointerAdapter.js +0 -19
  71. package/dist/canvas/stampLayout.d.ts +0 -5
  72. package/dist/canvas/stampLayout.js +0 -14
  73. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  74. package/dist/canvas/tools/measurementStampTool.js +0 -37
  75. package/dist/canvas/tools/panTool.d.ts +0 -5
  76. package/dist/canvas/tools/panTool.js +0 -25
  77. package/dist/canvas/tools/penTool.d.ts +0 -13
  78. package/dist/canvas/tools/penTool.js +0 -68
  79. package/dist/canvas/tools/selectTool.d.ts +0 -2
  80. package/dist/canvas/tools/selectTool.js +0 -182
  81. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  82. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  83. package/dist/canvas/viewport.d.ts +0 -16
  84. package/dist/canvas/viewport.js +0 -54
  85. package/dist/data/AnnotationDataContext.d.ts +0 -8
  86. package/dist/data/AnnotationDataContext.js +0 -11
  87. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  88. package/dist/data/AnnotationDataProvider.js +0 -4
  89. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  90. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  91. package/dist/data/canvasPersistence.d.ts +0 -3
  92. package/dist/data/canvasPersistence.js +0 -26
  93. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  94. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  95. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  96. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  97. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  98. package/dist/data/hooks/useAnnotationList.js +0 -26
  99. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  100. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  101. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  102. package/dist/hooks/useParseMeasurement.js +0 -14
  103. package/dist/utils/evaluateFormula.d.ts +0 -20
  104. package/dist/utils/evaluateFormula.js +0 -31
@@ -0,0 +1,218 @@
1
+ // Resolve what each placed tile SHOWS, from the documents the caller loaded.
2
+ // A pure mirror of the mobile canvas's renderMeasurementStamp branches
3
+ // (skia-annotation.tsx) and the tile components' own rules (measurementTileFill,
4
+ // convertMicrometers, the position labels), so a server render and the screen
5
+ // agree on value, label and fill. No I/O: the caller fetches the docs.
6
+ import { ColumnType, DecimalTolerance, FractionalTolerance, MeasurementType, convertUnitsToReadable, } from '../../types/firestore.js';
7
+ import { describeGroupHeader } from '../../annotation/canvas/groupHeaderLayout.js';
8
+ import { planeDimensionValueUm, planeLengthForPlaced, } from '../../annotation/canvas/planeGeometry.js';
9
+ import { convertMicrometers } from '../../utils/micrometersToUnit.js';
10
+ import { formGroupInputLabel, listGroupMeasurementLabel, } from '../../utils/indexLabels.js';
11
+ import { calculateFormula } from '../../formulas/calculateFormula.js';
12
+ import { applyGroupFormulaUm } from '../../formulas/groupFormula.js';
13
+ import { FormulaColors, GeneralColors, SecondaryColors, TileColors, } from '../../theme/colors.js';
14
+ /** Fill for a FORM formula-column tile (the job page's purple). */
15
+ export const FORMULA_COLUMN_TILE_FILL = '#7B4783';
16
+ /** Fill for a plane-computed length tile (calibration teal). */
17
+ export const PLANE_COMPUTED_TILE_FILL = '#0E7490';
18
+ const isScalarType = (type) => (type ?? MeasurementType.Length) !== MeasurementType.Length;
19
+ /**
20
+ * The tile fill, in measurementTileFill's precedence: unsaved scratch grey-blue,
21
+ * completed grey, an explicit colour, formula magenta, else tile blue.
22
+ */
23
+ export const measurementTileFillFor = ({ internal, isCompleted, groupFormula, color, }) => {
24
+ if (internal)
25
+ return SecondaryColors.accentBlue3;
26
+ if (isCompleted)
27
+ return GeneralColors.grey3;
28
+ if (color)
29
+ return color;
30
+ if (groupFormula)
31
+ return FormulaColors.purple;
32
+ return TileColors.activeBlue;
33
+ };
34
+ const columnOf = (ctx, placed) => {
35
+ const group = ctx.groups.find((g) => g.id === placed.groupId);
36
+ const section = ctx.sections.find((s) => s.id === group?.sectionId);
37
+ const columnIndex = section?.tableConfig.findIndex((c) => c.id === placed.columnId) ?? -1;
38
+ const column = columnIndex >= 0 ? section?.tableConfig[columnIndex] : undefined;
39
+ return { group, section, column, columnIndex };
40
+ };
41
+ const columnTileLabels = (ctx, placed) => {
42
+ const { group, column, columnIndex } = columnOf(ctx, placed);
43
+ return {
44
+ topLeftLabel: formGroupInputLabel(columnIndex, group?.groupIndex ?? 0),
45
+ topRightLabel: convertUnitsToReadable(ctx.units) ?? '',
46
+ bottomLabel: column?.name ?? 'Measurement',
47
+ };
48
+ };
49
+ const tileGroup = (ctx, placed) => ctx.groups.find((g) => g.id === (placed.groupId ?? ctx.hostGroupId));
50
+ const convert = (ctx, um, unit, device) => convertMicrometers(um, unit, ctx.fractionalTolerance ?? FractionalTolerance.Sixteenth, ctx.decimalTolerance ?? DecimalTolerance.Hundredth, device);
51
+ const measurementContent = (ctx, placed, m, column, columnIndex) => {
52
+ const group = tileGroup(ctx, placed);
53
+ const groupIndex = group?.groupIndex ?? 0;
54
+ const positionLabel = placed.columnId
55
+ ? formGroupInputLabel(columnIndex, groupIndex)
56
+ : listGroupMeasurementLabel(m.deviceMeasurementNumber, groupIndex);
57
+ // MeasurementTile shows the measurement's own label first, then the host's
58
+ // `label` prop (the override, else the column name).
59
+ const label = m.label || placed.labelOverride || column?.name;
60
+ const fill = measurementTileFillFor({
61
+ internal: m.fileId != null,
62
+ isCompleted: !!m.isCompleted,
63
+ groupFormula: group?.formula,
64
+ });
65
+ const formulaBar = fill === FormulaColors.purple;
66
+ // Angles and unitless numbers are stored as typed — never converted.
67
+ const scalar = column?.type === ColumnType.Angle || (!column && isScalarType(m.type));
68
+ if (scalar) {
69
+ const suffix = column?.type === ColumnType.Angle || m.type === MeasurementType.Angle
70
+ ? '°'
71
+ : '';
72
+ return {
73
+ kind: 'measurement',
74
+ value: `${m.value}${suffix}`,
75
+ unit: '',
76
+ positionLabel,
77
+ label,
78
+ fill,
79
+ formulaBar,
80
+ };
81
+ }
82
+ const groupFormula = group?.formula;
83
+ const unit = placed.unitOverride ?? ctx.units;
84
+ const formulaUm = groupFormula
85
+ ? applyGroupFormulaUm(groupFormula, m.value, unit)
86
+ : null;
87
+ const um = formulaUm ?? m.value;
88
+ const converted = convert(ctx, Math.abs(um), unit, formulaUm != null ? undefined : m.device);
89
+ return {
90
+ kind: 'measurement',
91
+ value: converted.value,
92
+ unit: converted.unit,
93
+ positionLabel,
94
+ label,
95
+ fill,
96
+ formulaBar,
97
+ negative: um < 0,
98
+ };
99
+ };
100
+ /** What a placed tile shows, from the documents in `ctx`. */
101
+ export const resolveTileContent = (placed, ctx) => {
102
+ // Object tile: the placed file's preview.
103
+ if (placed.fileId) {
104
+ const file = ctx.filesById?.get(placed.fileId);
105
+ return file
106
+ ? {
107
+ kind: 'object',
108
+ name: file.name,
109
+ fileType: file.type,
110
+ thumbnail: file.thumbnail,
111
+ }
112
+ : { kind: 'object', name: 'Object removed' };
113
+ }
114
+ // Group-header chip.
115
+ if (placed.isGroupHeader) {
116
+ const group = ctx.groups.find((g) => g.id === placed.groupId);
117
+ const section = ctx.sections.find((s) => s.id === group?.sectionId);
118
+ return {
119
+ kind: 'header',
120
+ descriptor: describeGroupHeader({
121
+ group,
122
+ section,
123
+ collapsed: !!placed.collapsed,
124
+ }),
125
+ };
126
+ }
127
+ const { group, section, column, columnIndex } = columnOf(ctx, placed);
128
+ const columnType = column?.type;
129
+ // Plane-calibration dimension tile.
130
+ if (placed.planeEdge) {
131
+ const plane = ctx.canvas.planes?.find((p) => p.id === placed.planeId);
132
+ const dimUm = plane ? planeDimensionValueUm(plane, placed.planeEdge) : null;
133
+ if (dimUm != null) {
134
+ const c = convert(ctx, dimUm, ctx.units);
135
+ return { kind: 'planeDimension', value: c.value, unit: c.unit };
136
+ }
137
+ return { kind: 'empty' };
138
+ }
139
+ if (columnType === ColumnType.Instructions) {
140
+ const data = (column?.columnData ?? {});
141
+ return {
142
+ kind: 'instructions',
143
+ text: data.text ?? '',
144
+ textColor: data.textColor,
145
+ backgroundColor: data.backgroundColor,
146
+ };
147
+ }
148
+ // FORM formula column: the computed output, or the empty tile.
149
+ if (columnType === ColumnType.Formula) {
150
+ const formula = ctx.formulas?.find((f) => f.id ===
151
+ column?.columnData?.formulaId);
152
+ let result = null;
153
+ if (group && section && formula) {
154
+ try {
155
+ result = calculateFormula(formula, ctx.formulas ?? [], group.columns, section.tableConfig, Array.from(ctx.measurementsById.values()), ctx.units, ctx.fractionalTolerance ?? FractionalTolerance.Sixteenth, ctx.decimalTolerance ?? DecimalTolerance.Tenth);
156
+ }
157
+ catch {
158
+ result = null;
159
+ }
160
+ }
161
+ if (result !== null && result !== '' && Number.isFinite(Number(result))) {
162
+ const converted = convert(ctx, Math.abs(Number(result)), ctx.units);
163
+ return {
164
+ kind: 'measurement',
165
+ value: converted.value,
166
+ unit: converted.unit,
167
+ positionLabel: formGroupInputLabel(columnIndex, group?.groupIndex ?? 0),
168
+ label: placed.labelOverride ?? column?.name,
169
+ fill: FORMULA_COLUMN_TILE_FILL,
170
+ negative: Number(result) < 0,
171
+ };
172
+ }
173
+ return { kind: 'empty', ...columnTileLabels(ctx, placed) };
174
+ }
175
+ // Every other non-measurement FORM column stores a literal string.
176
+ // (Formula and Instructions columns returned above.)
177
+ const isNonMeasurementColumn = columnType != null &&
178
+ columnType !== ColumnType.Measurement &&
179
+ columnType !== ColumnType.Angle;
180
+ if (isNonMeasurementColumn) {
181
+ const raw = placed.columnId ? group?.columns?.[placed.columnId] : undefined;
182
+ return {
183
+ kind: 'literal',
184
+ value: raw != null && raw !== '' ? String(raw) : '',
185
+ ...columnTileLabels(ctx, placed),
186
+ };
187
+ }
188
+ // Bound measurement: by id, else through the FORM group's column slot.
189
+ let resolved;
190
+ if (placed.measurementId)
191
+ resolved = ctx.measurementsById.get(placed.measurementId);
192
+ if (!resolved && placed.columnId && group) {
193
+ const mid = group.columns?.[placed.columnId];
194
+ if (mid)
195
+ resolved = ctx.measurementsById.get(mid);
196
+ }
197
+ if (resolved)
198
+ return measurementContent(ctx, placed, resolved, column, columnIndex);
199
+ // Plane-computed length on a calibrated canvas.
200
+ if (placed.valueSource === 'plane') {
201
+ const um = planeLengthForPlaced(ctx.canvas.planes, placed);
202
+ if (um != null) {
203
+ const c = convert(ctx, Math.abs(um), placed.unitOverride ?? ctx.units);
204
+ return {
205
+ kind: 'measurement',
206
+ value: c.value,
207
+ unit: c.unit,
208
+ positionLabel: '',
209
+ label: placed.labelOverride,
210
+ fill: PLANE_COMPUTED_TILE_FILL,
211
+ negative: um < 0,
212
+ };
213
+ }
214
+ }
215
+ return placed.columnId
216
+ ? { kind: 'empty', ...columnTileLabels(ctx, placed) }
217
+ : { kind: 'empty' };
218
+ };
@@ -0,0 +1,39 @@
1
+ import { type PDFImage, type PDFOperator } from 'pdf-lib';
2
+ import type { AnnotationCanvasState, PlacedMeasurementRef } from '../../types/annotation.js';
3
+ import type { PdfFonts } from './fonts.js';
4
+ import type { ResourceScope } from './resources.js';
5
+ import type { AffineMatrix, DocRect } from './pageMapping.js';
6
+ import type { PdfExportCalibration, ResolvedTileContent } from './types.js';
7
+ export interface TilePaintContext {
8
+ /** Where fonts, images and opacity states get registered. */
9
+ scope: ResourceScope;
10
+ fonts: PdfFonts;
11
+ /** Object-tile thumbnail, when the content carries one and it embedded. */
12
+ thumbnail?: PDFImage;
13
+ }
14
+ export interface TileFootprint extends DocRect {
15
+ /** Design-space box the painter lays out in. */
16
+ design: {
17
+ width: number;
18
+ height: number;
19
+ };
20
+ }
21
+ /**
22
+ * Doc-space footprint of a placed tile. `fallbackDocEdge` is the doc-space
23
+ * edge a scale-1 measurement tile covers when neither the request nor the
24
+ * document carries a calibration.
25
+ */
26
+ export declare const tileFootprint: (ref: PlacedMeasurementRef, canvas: Pick<AnnotationCanvasState, "tileDocSize">, calibration: PdfExportCalibration | undefined, fallbackDocEdge: number, design: {
27
+ width: number;
28
+ height: number;
29
+ }) => TileFootprint;
30
+ export declare const designBoxFor: (ref: PlacedMeasurementRef, content: ResolvedTileContent) => {
31
+ width: number;
32
+ height: number;
33
+ };
34
+ /** Doc ← design matrix for a footprint. */
35
+ export declare const designToDocMatrix: (f: TileFootprint) => AffineMatrix;
36
+ /** Operators for a tile in DESIGN space. */
37
+ export declare const tileDesignOps: (ctx: TilePaintContext, content: ResolvedTileContent) => PDFOperator[];
38
+ /** Operators for a tile in DOC space: design ops under the footprint matrix. */
39
+ export declare const tileOps: (ctx: TilePaintContext, content: ResolvedTileContent, footprint: TileFootprint) => PDFOperator[];
@@ -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.scope, 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.scope, 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.scope, t.fraction.den, fracFs, color, scx, cy + fracFs * 0.85, { align: 'center' }));
143
+ if (t.fraction.suffix) {
144
+ ops.push(...ctx.fonts.textOps(ctx.scope, 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.scope, 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.scope, 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.scope, 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.scope, c.topLeftLabel, 10, ink, 8, 18));
190
+ if (c.topRightLabel) {
191
+ ops.push(...ctx.fonts.textOps(ctx.scope, 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.scope, 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.scope, c.topLeftLabel, 10, ink, 8, 18));
220
+ ops.push(...ctx.fonts.textOps(ctx.scope, 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.scope, 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.scope, 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.scope, 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.scope, 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.scope, 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.scope, 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(ctx.scope.imageKey(thumb), 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.scope, 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.scope, 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));