@reekon-tools/boldr-utils 1.23.0 → 1.24.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,25 @@
1
+ import { type AnnotationCanvasState, type AnnotationPdfDocument, type Vec2 } from '../../types/annotation.js';
2
+ import type { PdfCanvasData, PdfCanvasPage, PdfCanvasPageState, PdfCanvasRev, PdfPageRotation } from '../../types/pdfCanvas.js';
3
+ export interface PdfPageDisplaySize {
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export declare const normalizePdfRotation: (deg: number) => PdfPageRotation;
8
+ export declare const effectivePageRotation: (intrinsicDeg: number, user: PdfPageRotation) => PdfPageRotation;
9
+ export declare const pdfPageDisplaySize: (page: Pick<PdfCanvasPage, "width" | "height" | "rotation">) => PdfPageDisplaySize;
10
+ export declare const pdfCanvasPageDocId: (pageIndex: number) => string;
11
+ export declare const emptyPdfCanvasPageState: (pageIndex: number) => PdfCanvasPageState;
12
+ export declare const pdfCanvasPageDocument: (data: PdfCanvasData, pageIndex: number) => AnnotationPdfDocument;
13
+ type PdfCanvasKnobs = Pick<PdfCanvasData, 'tileScaleFactor' | 'tileScaleFactorMobile' | 'headerTileScaleFactor' | 'headerTileScaleFactorMobile' | 'tileDocSize'>;
14
+ export declare const pdfCanvasKnobsOf: (source: Partial<PdfCanvasKnobs>) => PdfCanvasKnobs;
15
+ export declare const pdfCanvasKnobsEqual: (a: Partial<PdfCanvasKnobs>, b: Partial<PdfCanvasKnobs>) => boolean;
16
+ export declare const pageStateToCanvasState: (data: PdfCanvasData, pageState: PdfCanvasPageState | null, pageIndex: number) => AnnotationCanvasState;
17
+ export declare const canvasStateToPageState: (canvas: AnnotationCanvasState, pageIndex: number, stamp: {
18
+ updatedAt: number;
19
+ rev?: PdfCanvasRev;
20
+ }) => PdfCanvasPageState;
21
+ export type PdfRotationDelta = 90 | -90 | 180;
22
+ export declare const rotatePointInPage: (p: Vec2, size: PdfPageDisplaySize, delta: PdfRotationDelta) => Vec2;
23
+ export declare const rotatePageState: <T extends Pick<PdfCanvasPageState, "strokes" | "shapes" | "placedMeasurements" | "planes">>(state: T, currentSize: PdfPageDisplaySize, delta: PdfRotationDelta) => T;
24
+ export declare const rotatePdfCanvasPage: (page: PdfCanvasPage, delta: PdfRotationDelta) => PdfCanvasPage;
25
+ export {};
@@ -0,0 +1,255 @@
1
+ // Pure helpers for PDF canvas files (see types/pdfCanvas.ts): page geometry
2
+ // under user rotation, the mapping between a persisted page doc and the
3
+ // AnnotationCanvasState the engine mounts, and the coordinate transform that
4
+ // turns a page's annotations with it when the page is rotated.
5
+ import { createDefaultLayer, createEmptyCanvasState, } from '../../types/annotation.js';
6
+ import { textShapeBounds } from './textGeometry.js';
7
+ // Snap any degree value onto the four legal rotations, modulo 360.
8
+ export const normalizePdfRotation = (deg) => {
9
+ const snapped = (((Math.round(deg / 90) * 90) % 360) + 360) % 360;
10
+ return snapped;
11
+ };
12
+ // The rotation a viewer must apply to the ORIGINAL page (its own /Rotate plus
13
+ // the user's) — what the export writes into /Rotate.
14
+ export const effectivePageRotation = (intrinsicDeg, user) => normalizePdfRotation(intrinsicDeg + user);
15
+ // A page's displayed extents: its stored size with width/height swapped at
16
+ // 90/270. This IS the page's canvas doc space.
17
+ export const pdfPageDisplaySize = (page) => page.rotation === 90 || page.rotation === 270
18
+ ? { width: page.height, height: page.width }
19
+ : { width: page.width, height: page.height };
20
+ // Firestore doc id of a page under `files/{fileId}/pages`. Plain index: the
21
+ // set of existing pages comes from `annotatedPages`, never from listing.
22
+ export const pdfCanvasPageDocId = (pageIndex) => String(pageIndex);
23
+ export const emptyPdfCanvasPageState = (pageIndex) => ({
24
+ schemaVersion: 1,
25
+ pageIndex,
26
+ layers: [createDefaultLayer()],
27
+ strokes: [],
28
+ shapes: [],
29
+ placedMeasurements: [],
30
+ updatedAt: 0,
31
+ });
32
+ const requirePage = (data, pageIndex) => {
33
+ const page = data.document.pages[pageIndex];
34
+ if (!page) {
35
+ throw new RangeError(`PDF canvas has ${data.document.pages.length} pages; asked for ${pageIndex}`);
36
+ }
37
+ return page;
38
+ };
39
+ // The one-page substrate descriptor a page canvas carries. pdfPageDocRects of
40
+ // it is exactly {0, 0, w, h}, so every consumer of `viewport.pdfDocument` (fit,
41
+ // zoom floor, tile-scale reference, export matrix) sees the displayed page.
42
+ export const pdfCanvasPageDocument = (data, pageIndex) => {
43
+ const size = pdfPageDisplaySize(requirePage(data, pageIndex));
44
+ return {
45
+ id: `pdf-page-${pageIndex}`,
46
+ storagePath: data.document.storagePath,
47
+ ...(data.document.fileName ? { fileName: data.document.fileName } : {}),
48
+ pageCount: 1,
49
+ pages: [size],
50
+ layout: 'continuousVertical',
51
+ gap: 0,
52
+ };
53
+ };
54
+ const KNOB_KEYS = [
55
+ 'tileScaleFactor',
56
+ 'tileScaleFactorMobile',
57
+ 'headerTileScaleFactor',
58
+ 'headerTileScaleFactorMobile',
59
+ 'tileDocSize',
60
+ ];
61
+ // The file-level tile-scale knobs present on a canvas (undefined keys omitted).
62
+ export const pdfCanvasKnobsOf = (source) => {
63
+ const out = {};
64
+ for (const key of KNOB_KEYS) {
65
+ const value = source[key];
66
+ if (value !== undefined)
67
+ out[key] = value;
68
+ }
69
+ return out;
70
+ };
71
+ export const pdfCanvasKnobsEqual = (a, b) => KNOB_KEYS.every((key) => a[key] === b[key]);
72
+ // The AnnotationCanvasState the editor mounts for one page. A null page state
73
+ // (never annotated) yields an empty canvas over the page.
74
+ export const pageStateToCanvasState = (data, pageState, pageIndex) => {
75
+ const doc = pdfCanvasPageDocument(data, pageIndex);
76
+ const size = doc.pages[0];
77
+ const base = createEmptyCanvasState({
78
+ width: size.width,
79
+ height: size.height,
80
+ pdfDocument: doc,
81
+ });
82
+ return {
83
+ ...base,
84
+ ...pdfCanvasKnobsOf(data),
85
+ layers: pageState && pageState.layers.length > 0 ? pageState.layers : base.layers,
86
+ strokes: pageState?.strokes ?? [],
87
+ shapes: pageState?.shapes ?? [],
88
+ placedMeasurements: pageState?.placedMeasurements ?? [],
89
+ ...(pageState?.planes !== undefined ? { planes: pageState.planes } : {}),
90
+ };
91
+ };
92
+ // Inverse of pageStateToCanvasState: the elements to persist for one page.
93
+ // Viewport and pdfDocument are derived and never stored on the page doc.
94
+ export const canvasStateToPageState = (canvas, pageIndex, stamp) => ({
95
+ schemaVersion: 1,
96
+ pageIndex,
97
+ layers: canvas.layers,
98
+ strokes: canvas.strokes,
99
+ shapes: canvas.shapes,
100
+ placedMeasurements: canvas.placedMeasurements,
101
+ ...(canvas.planes !== undefined ? { planes: canvas.planes } : {}),
102
+ updatedAt: stamp.updatedAt,
103
+ ...(stamp.rev ? { rev: stamp.rev } : {}),
104
+ });
105
+ export const rotatePointInPage = (p, size, delta) => {
106
+ switch (normalizePdfRotation(delta)) {
107
+ case 90:
108
+ return { x: size.height - p.y, y: p.x };
109
+ case 270:
110
+ return { x: p.y, y: size.width - p.x };
111
+ case 180:
112
+ return { x: size.width - p.x, y: size.height - p.y };
113
+ default:
114
+ return p;
115
+ }
116
+ };
117
+ const rotateFlatPoints = (points, size, delta) => {
118
+ const out = new Array(points.length);
119
+ for (let i = 0; i + 1 < points.length; i += 2) {
120
+ const p = rotatePointInPage({ x: points[i], y: points[i + 1] }, size, delta);
121
+ out[i] = p.x;
122
+ out[i + 1] = p.y;
123
+ }
124
+ return out;
125
+ };
126
+ const rotateStroke = (stroke, size, delta) => ({
127
+ ...stroke,
128
+ points: rotateFlatPoints(stroke.points, size, delta),
129
+ });
130
+ const rotateShape = (shape, size, delta) => {
131
+ if (shape.kind === 'text') {
132
+ const bounds = textShapeBounds(shape);
133
+ const origin = shape.geometry.points[0];
134
+ if (bounds && origin) {
135
+ const width = bounds.maxX - bounds.minX;
136
+ const height = bounds.maxY - bounds.minY;
137
+ const centre = rotatePointInPage({ x: bounds.minX + width / 2, y: bounds.minY + height / 2 }, size, delta);
138
+ return {
139
+ ...shape,
140
+ geometry: {
141
+ ...shape.geometry,
142
+ points: [
143
+ { x: centre.x - width / 2, y: centre.y - height / 2 },
144
+ ...shape.geometry.points.slice(1),
145
+ ],
146
+ },
147
+ };
148
+ }
149
+ }
150
+ return {
151
+ ...shape,
152
+ geometry: {
153
+ ...shape.geometry,
154
+ points: shape.geometry.points.map((p) => rotatePointInPage(p, size, delta)),
155
+ },
156
+ };
157
+ };
158
+ // Where each rectangle edge lands after the turn — used for the perspective
159
+ // plane's dimensioned-edge provenance and for the dimension tiles' slots.
160
+ const rotateEdge = (edge, delta) => {
161
+ if (edge === 'ref')
162
+ return edge;
163
+ const order = ['top', 'right', 'bottom', 'left'];
164
+ const steps = normalizePdfRotation(delta) / 90;
165
+ return order[(order.indexOf(edge) + steps) % 4];
166
+ };
167
+ const rotatePlaced = (placed, size, delta) => {
168
+ const r = (p) => rotatePointInPage(p, size, delta);
169
+ return {
170
+ ...placed,
171
+ anchor: r(placed.anchor),
172
+ ...(placed.line
173
+ ? { line: { a: r(placed.line.a), b: r(placed.line.b) } }
174
+ : {}),
175
+ ...(placed.rect
176
+ ? { rect: { a: r(placed.rect.a), b: r(placed.rect.b) } }
177
+ : {}),
178
+ ...(placed.leader
179
+ ? { leader: { from: r(placed.leader.from), to: r(placed.leader.to) } }
180
+ : {}),
181
+ ...(placed.planeEdge
182
+ ? { planeEdge: rotateEdge(placed.planeEdge, delta) }
183
+ : {}),
184
+ };
185
+ };
186
+ const rotatePlane = (plane, size, delta) => {
187
+ const r = (p) => rotatePointInPage(p, size, delta);
188
+ if (plane.mode === 'scale') {
189
+ return {
190
+ ...plane,
191
+ refLine: { a: r(plane.refLine.a), b: r(plane.refLine.b) },
192
+ };
193
+ }
194
+ // Corners stay in TL, TR, BR, BL order: after a clockwise quarter turn the
195
+ // old bottom-left corner is the new top-left, so the array rotates by one
196
+ // slot per quarter turn.
197
+ const steps = normalizePdfRotation(delta) / 90;
198
+ const turned = plane.corners.map(r);
199
+ const corners = [0, 1, 2, 3].map((i) => turned[(i - steps + 4) % 4]);
200
+ const quarter = steps % 2 === 1;
201
+ const widthEdge = plane.heightEdge
202
+ ? rotateEdge(plane.heightEdge, delta)
203
+ : undefined;
204
+ const heightEdge = plane.widthEdge
205
+ ? rotateEdge(plane.widthEdge, delta)
206
+ : undefined;
207
+ return {
208
+ ...plane,
209
+ corners,
210
+ // A quarter turn makes the horizontal dimension vertical and vice versa.
211
+ ...(quarter
212
+ ? {
213
+ ...(plane.heightUm !== undefined
214
+ ? { widthUm: plane.heightUm }
215
+ : { widthUm: undefined }),
216
+ ...(plane.widthUm !== undefined
217
+ ? { heightUm: plane.widthUm }
218
+ : { heightUm: undefined }),
219
+ ...(widthEdge === 'top' || widthEdge === 'bottom'
220
+ ? { widthEdge }
221
+ : { widthEdge: undefined }),
222
+ ...(heightEdge === 'left' || heightEdge === 'right'
223
+ ? { heightEdge }
224
+ : { heightEdge: undefined }),
225
+ }
226
+ : {
227
+ ...(plane.widthEdge
228
+ ? {
229
+ widthEdge: rotateEdge(plane.widthEdge, delta),
230
+ }
231
+ : {}),
232
+ ...(plane.heightEdge
233
+ ? {
234
+ heightEdge: rotateEdge(plane.heightEdge, delta),
235
+ }
236
+ : {}),
237
+ }),
238
+ };
239
+ };
240
+ // Rewrite every coordinate of a page's state from its current displayed size
241
+ // into the size it will have after turning by `delta`.
242
+ export const rotatePageState = (state, currentSize, delta) => ({
243
+ ...state,
244
+ strokes: state.strokes.map((s) => rotateStroke(s, currentSize, delta)),
245
+ shapes: state.shapes.map((s) => rotateShape(s, currentSize, delta)),
246
+ placedMeasurements: state.placedMeasurements.map((p) => rotatePlaced(p, currentSize, delta)),
247
+ ...(state.planes !== undefined
248
+ ? { planes: state.planes.map((p) => rotatePlane(p, currentSize, delta)) }
249
+ : {}),
250
+ });
251
+ // The page record after turning by `delta`.
252
+ export const rotatePdfCanvasPage = (page, delta) => ({
253
+ ...page,
254
+ rotation: normalizePdfRotation(page.rotation + delta),
255
+ });
@@ -1,4 +1,5 @@
1
1
  import type { FileUpload, FileUploadType, Measurement } from '../../types/firestore.js';
2
+ import type { PdfCanvasPageState } from '../../types/pdfCanvas.js';
2
3
  export interface JobScope {
3
4
  orgId: string;
4
5
  projectId: string;
@@ -56,6 +57,8 @@ export interface UploadedImageRef {
56
57
  }
57
58
  export type AnnotationFile = Extract<FileUpload, {
58
59
  type: FileUploadType.Canvas;
60
+ } | {
61
+ type: FileUploadType.PdfCanvas;
59
62
  }>;
60
63
  export interface AnnotationFileSummary {
61
64
  id: string;
@@ -78,4 +81,7 @@ export interface AnnotationDataProvider {
78
81
  uploadImage(scope: AnnotationScope, fileId: string, role: string, blob: ImageBlob): Promise<UploadedImageRef>;
79
82
  getImageUrl(scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
80
83
  deleteImage(scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
84
+ getPage(scope: AnnotationScope, fileId: string, pageIndex: number): Promise<PdfCanvasPageState | null>;
85
+ subscribePage(scope: AnnotationScope, fileId: string, pageIndex: number, onNext: (page: PdfCanvasPageState | null) => void, onError?: (err: Error) => void): Unsubscribe;
86
+ writePage(scope: AnnotationScope, fileId: string, pageIndex: number, state: PdfCanvasPageState): Promise<void>;
81
87
  }
@@ -1,4 +1,5 @@
1
1
  import { type Measurement } from '../../types/firestore.js';
2
+ import type { PdfCanvasPageState } from '../../types/pdfCanvas.js';
2
3
  import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type ImageBlob, type JobGroupScope, type JobScope, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
3
4
  export declare class InMemoryAnnotationProvider implements AnnotationDataProvider {
4
5
  private docs;
@@ -8,6 +9,8 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
8
9
  private listListeners;
9
10
  private groupMeasurementListeners;
10
11
  private jobMeasurementListeners;
12
+ private pages;
13
+ private pageListeners;
11
14
  private nextId;
12
15
  setMeasurements(scope: JobGroupScope, measurements: Measurement[]): void;
13
16
  create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
@@ -21,6 +24,9 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
21
24
  uploadImage(scope: AnnotationScope, fileId: string, role: string, blob: ImageBlob): Promise<UploadedImageRef>;
22
25
  getImageUrl(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
23
26
  deleteImage(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
27
+ getPage(scope: AnnotationScope, fileId: string, pageIndex: number): Promise<PdfCanvasPageState | null>;
28
+ subscribePage(scope: AnnotationScope, fileId: string, pageIndex: number, onNext: (page: PdfCanvasPageState | null) => void): Unsubscribe;
29
+ writePage(scope: AnnotationScope, fileId: string, pageIndex: number, state: PdfCanvasPageState): Promise<void>;
24
30
  private getBucket;
25
31
  private notifyDoc;
26
32
  private notifyList;
@@ -1,9 +1,11 @@
1
1
  import { FileUploadType } from '../../types/firestore.js';
2
+ import { pdfCanvasPageDocId } from '../canvas/pdfCanvasPages.js';
2
3
  import { annotationScopeKey, isFieldOp, } from './AnnotationDataProvider.js';
3
4
  const scopeKey = annotationScopeKey;
4
5
  // Prefix of annotationScopeKey for job scopes, so collectJobMeasurements can
5
6
  // match group keys by startsWith.
6
7
  const jobKey = (s) => `job/${s.orgId}/${s.projectId}/${s.jobId}`;
8
+ const pageKey = (s, fileId, pageIndex) => `${scopeKey(s)}/${fileId}/pages/${pdfCanvasPageDocId(pageIndex)}`;
7
9
  const summarize = (file) => ({
8
10
  id: file.id,
9
11
  name: file.name,
@@ -75,6 +77,8 @@ export class InMemoryAnnotationProvider {
75
77
  this.listListeners = new Map();
76
78
  this.groupMeasurementListeners = new Map();
77
79
  this.jobMeasurementListeners = new Map();
80
+ this.pages = new Map();
81
+ this.pageListeners = new Map();
78
82
  this.nextId = 1;
79
83
  }
80
84
  // Seed helpers for tests
@@ -182,6 +186,27 @@ export class InMemoryAnnotationProvider {
182
186
  async deleteImage(_scope, fileId, storagePath) {
183
187
  this.images.get(fileId)?.delete(storagePath);
184
188
  }
189
+ async getPage(scope, fileId, pageIndex) {
190
+ return this.pages.get(pageKey(scope, fileId, pageIndex)) ?? null;
191
+ }
192
+ subscribePage(scope, fileId, pageIndex, onNext) {
193
+ const key = pageKey(scope, fileId, pageIndex);
194
+ const set = this.pageListeners.get(key) ?? new Set();
195
+ set.add(onNext);
196
+ this.pageListeners.set(key, set);
197
+ queueMicrotask(() => onNext(this.pages.get(key) ?? null));
198
+ return () => {
199
+ set.delete(onNext);
200
+ };
201
+ }
202
+ async writePage(scope, fileId, pageIndex, state) {
203
+ const key = pageKey(scope, fileId, pageIndex);
204
+ // Same JSON round-trip as the real SDKs' effective behaviour: undefined
205
+ // keys vanish, object identity is not preserved across the write.
206
+ const stored = JSON.parse(JSON.stringify(state));
207
+ this.pages.set(key, stored);
208
+ this.pageListeners.get(key)?.forEach((cb) => cb(stored));
209
+ }
185
210
  getBucket(scope) {
186
211
  const key = scopeKey(scope);
187
212
  let bucket = this.docs.get(key);
@@ -0,0 +1,29 @@
1
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch } from '../../../types/annotation.js';
2
+ import type { PdfCanvasData, PdfCanvasPage } from '../../../types/pdfCanvas.js';
3
+ import { type PdfRotationDelta } from '../../canvas/pdfCanvasPages.js';
4
+ import { type AnnotationFile, type AnnotationScope, type ImageBlob } from '../AnnotationDataProvider.js';
5
+ import type { SaveStatus } from './useAnnotationCanvasDoc.js';
6
+ export interface UsePdfCanvasDocOptions {
7
+ scope: AnnotationScope | null;
8
+ fileId: string | null;
9
+ pageIndex: number;
10
+ onSaveError?: (error: unknown) => void;
11
+ captureThumbnail?: () => Promise<ImageBlob | null>;
12
+ debugLogging?: boolean;
13
+ }
14
+ export interface UsePdfCanvasDocResult {
15
+ file: AnnotationFile | null;
16
+ pdfCanvas: PdfCanvasData | null;
17
+ pageCount: number;
18
+ page: PdfCanvasPage | null;
19
+ canvas: AnnotationCanvasState;
20
+ onCommit: (patch: AnnotationDocumentPatch) => void;
21
+ loading: boolean;
22
+ error: Error | null;
23
+ saveStatus: SaveStatus;
24
+ hasUnsavedChanges: boolean;
25
+ save: () => Promise<void>;
26
+ rotatePage: (delta: PdfRotationDelta) => Promise<void>;
27
+ refreshThumbnail: () => Promise<void>;
28
+ }
29
+ export declare const usePdfCanvasDoc: (options: UsePdfCanvasDocOptions) => UsePdfCanvasDocResult;