@embedpdf/plugin-measurement 3.0.0-next.14 → 3.0.0-next.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,469 +1,623 @@
1
- import { t as MeasurementToken } from "./contract-DO0emN-T.js";
2
- import { definePlugin } from "@embedpdf/core";
1
+ import { a as defaultMeasure, c as withUnit, i as MeasurementToken, n as NO_PAGE_SCALE, o as withAreaUnit, r as NO_READOUT, s as withPrecision, t as MEASUREMENT_DEFAULTS } from "./contract-DyqqYfyE.js";
2
+ import { PluginError, composeApi, definePlugin, defineState, memoByKey, toPageRef, toPluginError } from "@embedpdf/core";
3
3
  import { AnnotationToken } from "@embedpdf/plugin-annotation/contract/host";
4
4
  import { InteractionToken } from "@embedpdf/plugin-interaction/contract";
5
- import { AnnotationToken as AnnotationToken$1 } from "@embedpdf/plugin-annotation/contract";
6
- import { EngineError, EngineErrorCode, METRES, PermissionDenied, areaUnitLabel, assertWritableMeasure, measureFromKnownLength, measureFromRatio, measurementPoint, measurementReadout, serializeError, squareMetres, squareOf, viewportForPoint } from "@embedpdf/engine-core/runtime";
7
- //#region src/scale.ts
8
- const DEFAULT_PRESETS = [
9
- ...[
10
- 1,
11
- 10,
12
- 20,
13
- 50,
14
- 100,
15
- 200
16
- ].map((real) => ({
17
- id: `metric-${real}`,
18
- label: `1:${real}`,
19
- paper: 1,
20
- real,
21
- unit: "m"
22
- })),
23
- {
24
- id: "imperial-quarter",
25
- label: "1/4 in = 1 ft",
26
- paper: 1,
27
- real: 48,
28
- unit: "ft"
29
- },
30
- {
31
- id: "imperial-eighth",
32
- label: "1/8 in = 1 ft",
33
- paper: 1,
34
- real: 96,
35
- unit: "ft"
36
- }
5
+ import { METRES, assertWritableMeasure, measureFromKnownLength, measureFromRatio, measurementPoint, measurementReadout, pageRefsEqual, serializeError, squareOf, viewportForPoint } from "@embedpdf/engine-core/runtime";
6
+ //#region src/read/scale.ts
7
+ const UNITS = Object.keys(METRES);
8
+ const AREA_UNITS = [
9
+ ...UNITS.map(squareOf),
10
+ "ha",
11
+ "acre"
37
12
  ];
38
- const defaultMeasure = (config, userUnit = 1) => typeof config.defaultScale === "object" ? config.defaultScale : measureFromRatio(1, 1, config.defaultScale === "imperial" ? "ft" : "m", userUnit);
39
- function selectPageScale(viewports, crop, fallback, persistent) {
40
- const selected = viewports.find((v) => v.owned) ?? viewportForPoint(viewports, {
41
- x: (crop.left + crop.right) / 2,
42
- y: (crop.top + crop.bottom) / 2
13
+ function createScaleReads(ctx, { siblings }, { scaleOf }) {
14
+ const { annotation } = siblings;
15
+ const settings = ctx.settings();
16
+ const state = () => ctx.state.get();
17
+ const readouts = /* @__PURE__ */ new WeakMap();
18
+ const getReadout = (ref) => {
19
+ const raw = annotation.get(ref);
20
+ if (!raw) return NO_READOUT;
21
+ let readout = readouts.get(raw);
22
+ if (!readout) {
23
+ readout = measurementReadout(raw);
24
+ readouts.set(raw, readout);
25
+ }
26
+ return readout;
27
+ };
28
+ const measureDistance = (page, from, to) => measurementReadout({
29
+ subtype: "line",
30
+ intent: "line-dimension",
31
+ measure: scaleOf(page).measure,
32
+ linePoints: {
33
+ start: from,
34
+ end: to
35
+ }
43
36
  });
37
+ const measureArea = (page, vertices) => measurementReadout({
38
+ subtype: "polygon",
39
+ intent: "polygon-dimension",
40
+ measure: scaleOf(page).measure,
41
+ vertices: [...vertices]
42
+ });
43
+ return { api: {
44
+ canMeasure: (page) => ctx.allows("annotations:create") && scaleOf(page).ready,
45
+ getPageScale: scaleOf,
46
+ isBusy: () => state().pending > 0,
47
+ listLastReports: () => state().reports,
48
+ listPresets: () => settings.get().presets,
49
+ listUnits: () => UNITS,
50
+ listAreaUnits: () => AREA_UNITS,
51
+ getReadout,
52
+ measureDistance,
53
+ measureArea,
54
+ getCalibrationRequest: () => state().calibration
55
+ } };
56
+ }
57
+ //#endregion
58
+ //#region src/services/events.ts
59
+ function createEvents(ctx) {
44
60
  return {
45
- measure: selected ? selected.measure ?? null : fallback,
46
- source: selected ? selected.owned ? "owned" : "foreign" : "default",
47
- ready: true,
48
- persistent
61
+ scaleChanged: ctx.events.source(),
62
+ calibrationRequested: ctx.events.source(),
63
+ calibrationCompleted: ctx.events.source(),
64
+ calibrationDismissed: ctx.events.source()
49
65
  };
50
66
  }
51
- function withUnit(m, unit, area = squareOf(unit)) {
52
- const basis = m.x[0]?.unit.trim();
53
- if (!(basis in METRES)) throw new RangeError("This scale has an unknown base unit; calibrate it first");
54
- const previous = m.distance[m.distance.length - 1];
55
- const precision = previous?.fraction === "fraction" ? 100 : previous?.precision ?? 100;
67
+ //#endregion
68
+ //#region src/services/siblings.ts
69
+ /** The sibling plugins: annotation (host lens) and the interaction hub. */
70
+ function resolveSiblings(ctx) {
56
71
  return {
57
- ...m,
58
- distance: [{
59
- unit,
60
- conversion: METRES[basis] / METRES[unit],
61
- precision,
62
- fraction: "decimal"
63
- }],
64
- area: [{
65
- unit: areaUnitLabel(area),
66
- conversion: METRES[basis] ** 2 / squareMetres(area),
67
- precision,
68
- fraction: "decimal"
69
- }]
72
+ annotation: ctx.get(AnnotationToken),
73
+ interaction: ctx.get(InteractionToken)
70
74
  };
71
75
  }
72
- function withPrecision(m, precision) {
73
- if (!Number.isInteger(Math.log10(precision)) || precision < 1 || precision > 1e9) throw new RangeError("Invalid decimal precision");
74
- const update = (formats) => formats.map((f, i) => i === formats.length - 1 ? {
75
- ...f,
76
- precision,
77
- fraction: "decimal"
78
- } : f);
76
+ //#endregion
77
+ //#region src/services/store.ts
78
+ /** Changing a page's scale rewrites its measurement annotations. */
79
+ const SCALE_PERMISSION = "doc.annotate.modify";
80
+ function createStore(ctx) {
81
+ /** A page argument of a verb, as a ref or an index: the page, or `not-found`. */
82
+ const requirePage = (page) => ctx.pageOf(page);
83
+ const canCalibrate = () => ctx.allows(SCALE_PERMISSION);
84
+ /** The pages a verb changes, each resolved: a page that isn't there refuses the whole call. */
85
+ const targets = (pages) => {
86
+ if (pages === "all") return (ctx.document()?.pages ?? []).map((layout) => layout.ref);
87
+ return (Array.isArray(pages) ? pages : [pages]).map((page) => ctx.pageOf(page).ref);
88
+ };
79
89
  return {
80
- ...m,
81
- distance: update(m.distance),
82
- area: update(m.area)
90
+ requirePage,
91
+ canCalibrate,
92
+ targets
83
93
  };
84
94
  }
85
- /** Change the area display unit while preserving imported distance formatting. */
86
- function withAreaUnit(measure, unit) {
87
- const basis = measure.x[0]?.unit.trim();
88
- if (!(basis in METRES)) throw new RangeError("This scale has an unknown base unit; calibrate it first");
89
- const previous = measure.area[measure.area.length - 1];
90
- const precision = previous?.fraction === "fraction" ? 100 : previous?.precision ?? 100;
95
+ //#endregion
96
+ //#region src/services/index.ts
97
+ function createServices(ctx) {
91
98
  return {
92
- ...measure,
93
- area: [{
94
- unit: areaUnitLabel(unit),
95
- conversion: METRES[basis] ** 2 / squareMetres(unit),
96
- precision,
97
- fraction: "decimal"
98
- }]
99
+ events: createEvents(ctx),
100
+ store: createStore(ctx),
101
+ siblings: resolveSiblings(ctx)
99
102
  };
100
103
  }
101
104
  //#endregion
102
- //#region src/capability.ts
103
- const LOADING = {
105
+ //#region src/model.ts
106
+ /**
107
+ * The measurement session: scale changes in flight, the pending calibration,
108
+ * the last change's reports, why a page's viewport read failed, and the
109
+ * session-only viewports of engines that cannot persist a scale. The pages'
110
+ * viewports themselves are engine data held by a page mirror. Every
111
+ * transition below is pure; the controller applies them with
112
+ * `ctx.state.update`. `selectPageScale` and `pageScaleOf` are the pure
113
+ * projections from viewports to the public {@link PageScale}.
114
+ */
115
+ /** No reports yet: the same array every time. */
116
+ const NO_REPORTS = Object.freeze([]);
117
+ const initialMeasurementState = () => ({
118
+ pending: 0,
119
+ calibration: null,
120
+ reports: NO_REPORTS,
121
+ loadErrors: {},
122
+ localViewports: {}
123
+ });
124
+ const beginScaleChange = (state) => ({
125
+ ...state,
126
+ pending: state.pending + 1
127
+ });
128
+ const endScaleChange = (state) => state.pending === 0 ? state : {
129
+ ...state,
130
+ pending: state.pending - 1
131
+ };
132
+ const setCalibration = (state, calibration) => state.calibration === calibration ? state : {
133
+ ...state,
134
+ calibration
135
+ };
136
+ const setReports = (state, reports) => state.reports === reports ? state : {
137
+ ...state,
138
+ reports
139
+ };
140
+ const recordLoadError = (state, pageObjectNumber, error) => ({
141
+ ...state,
142
+ loadErrors: {
143
+ ...state.loadErrors,
144
+ [pageObjectNumber]: error
145
+ }
146
+ });
147
+ const clearLoadError = (state, pageObjectNumber) => {
148
+ if (!(pageObjectNumber in state.loadErrors)) return state;
149
+ const { [pageObjectNumber]: _cleared, ...loadErrors } = state.loadErrors;
150
+ return {
151
+ ...state,
152
+ loadErrors
153
+ };
154
+ };
155
+ const setLocalViewports = (state, pageObjectNumber, viewports) => ({
156
+ ...state,
157
+ localViewports: {
158
+ ...state.localViewports,
159
+ [pageObjectNumber]: viewports
160
+ }
161
+ });
162
+ /**
163
+ * The scale a page's viewports resolve to: the owned viewport, else the one
164
+ * containing the page's center, else the fallback.
165
+ */
166
+ function selectPageScale(viewports, size, fallback, persistent) {
167
+ const selected = viewports.find((viewport) => viewport.owned) ?? viewportForPoint(viewports, {
168
+ x: size.width / 2,
169
+ y: size.height / 2
170
+ });
171
+ return {
172
+ measure: selected ? selected.measure ?? null : fallback,
173
+ source: selected ? selected.owned ? "owned" : "foreign" : "default",
174
+ ready: true,
175
+ persistent
176
+ };
177
+ }
178
+ /**
179
+ * The public page scale. A page is ready once its viewports were read; a
180
+ * failed re-read keeps the last confirmed viewports in effect and reports
181
+ * its error alongside them.
182
+ */
183
+ function pageScaleOf(inputs) {
184
+ const error = inputs.status === "error" && inputs.error ? { error: inputs.error } : {};
185
+ if (!inputs.viewports || !inputs.size) return {
186
+ measure: null,
187
+ source: "default",
188
+ ready: false,
189
+ persistent: inputs.persistent,
190
+ ...error
191
+ };
192
+ return {
193
+ ...selectPageScale(inputs.viewports, inputs.size, inputs.fallback, inputs.persistent),
194
+ ...error
195
+ };
196
+ }
197
+ //#endregion
198
+ //#region src/sync/viewports.ts
199
+ /**
200
+ * The pages' viewports: a page mirror re-read whenever a confirmed
201
+ * `pages.scaleSet` names a loaded page, from this session or another
202
+ * (stream gaps and version moves re-read every loaded page). Its `changed`
203
+ * callback tells the annotation plugin a page's viewports, and a new
204
+ * `defaultScale` setting tells it again. `connect` loads every page in the
205
+ * registry, including pages inserted later.
206
+ */
207
+ /** The scale of a page index past the end: none, and never ready. */
208
+ const NO_PAGE = Object.freeze({
104
209
  measure: null,
105
210
  source: "default",
106
211
  ready: false,
107
212
  persistent: false
108
- };
109
- const UNITS = Object.keys(METRES);
110
- function createMeasurementCapability(ctx, config, effects) {
111
- const anno = ctx.get(AnnotationToken$1);
112
- const interaction = ctx.get(InteractionToken);
113
- const canCalibrate = () => ctx.doc?.security.allows("doc.annotate.modify") ?? false;
114
- const pons = (pon, opts) => pon === "all" || opts?.allPages ? (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber) : [pon];
115
- const scaleOf = (pon) => {
116
- const scale = ctx.getState().pages[pon]?.scale.measure;
117
- if (!scale || scale.subtype !== "RL") throw new RangeError("Calibrate this page before changing its units or precision");
118
- return scale;
119
- };
120
- const presets = config.presets ?? DEFAULT_PRESETS;
121
- return {
122
- canCalibrate,
123
- canMeasure: (pon) => anno.canCreate() && !!ctx.getState().pages[pon]?.scale.ready,
124
- pageScale: (pon) => ctx.getState().pages[pon]?.scale ?? LOADING,
125
- isBusy: () => ctx.getState().pending > 0,
126
- lastReports: () => ctx.getState().reports,
127
- prepare: effects.prepare,
128
- setPageScale: (pon, measure, opts = {}) => effects.change(pons(pon, opts), () => measure, opts),
129
- calibrate: (pon, from, to, real, opts = {}) => {
130
- const a = measurementPoint(from);
131
- const b = measurementPoint(to);
132
- const scale = measureFromKnownLength(Math.hypot(b.x - a.x, b.y - a.y), real);
133
- return effects.change(pons(pon, opts), () => scale, opts);
134
- },
135
- setUnit: (pon, unit, areaUnit, opts = {}) => effects.change(pons(pon, opts), (p) => withUnit(scaleOf(p), unit, areaUnit), {
136
- ...opts,
137
- allPages: pon === "all" || opts.allPages
138
- }),
139
- setPrecision: (pon, precision, opts = {}) => effects.change(pons(pon, opts), (p) => withPrecision(scaleOf(p), precision), {
140
- ...opts,
141
- allPages: pon === "all" || opts.allPages
142
- }),
143
- setPreset: (pon, id, opts = {}) => {
144
- const preset = presets.find((p) => p.id === id);
145
- if (!preset) return Promise.reject(/* @__PURE__ */ new RangeError("Unknown scale preset"));
146
- return effects.change(pons(pon, opts), (p) => measureFromRatio(preset.paper, preset.real, preset.unit, ctx.document()?.pages.find((page) => page.pageObjectNumber === p)?.userUnit ?? 1), opts);
147
- },
148
- presets: () => presets,
149
- units: () => UNITS,
150
- areaUnits: () => [
151
- ...UNITS.map(squareOf),
152
- "ha",
153
- "acre"
154
- ],
155
- setAreaUnit: (pon, unit, opts = {}) => effects.change(pons(pon, opts), (p) => withAreaUnit(scaleOf(p), unit), opts),
156
- readout: (ref) => {
157
- const dto = anno.get(ref);
158
- return dto ? measurementReadout(dto) : { unavailable: "not-dimension" };
159
- },
160
- startCalibration: () => {
161
- if (canCalibrate()) {
162
- ctx.dispatch({
163
- type: "CALIBRATION",
164
- request: null
165
- });
166
- interaction.activateTool("calibrate");
213
+ });
214
+ function createViewportSync(ctx, { siblings }) {
215
+ const { annotation } = siblings;
216
+ const settings = ctx.settings();
217
+ /** The scale of a page without one: the `defaultScale` setting, in the page's user unit. */
218
+ const fallbackOf = (layout) => defaultMeasure(settings.get().defaultScale, layout?.userUnit ?? 1);
219
+ const viewports = ctx.pageMirror({
220
+ name: "viewports",
221
+ load: async (doc, page, signal) => {
222
+ ctx.assertPageRef(page);
223
+ const service = doc.page(page).measure;
224
+ if (!service) return ctx.state.get().localViewports[page.objectNumber] ?? [];
225
+ try {
226
+ return (await service.listViewports()).viewports;
227
+ } catch (error) {
228
+ if (!signal.aborted) ctx.state.update(recordLoadError, page.objectNumber, serializeError(error));
229
+ throw error;
167
230
  }
168
231
  },
169
- calibrationRequest: () => ctx.getState().calibration,
170
- dismissCalibration: () => ctx.dispatch({
171
- type: "CALIBRATION",
172
- request: null
173
- }),
174
- onCalibrationRequested: (cb) => {
175
- effects.calibrationListeners.add(cb);
176
- return () => {
177
- effects.calibrationListeners.delete(cb);
178
- };
179
- },
180
- onScaleChanged: (cb) => {
181
- effects.scaleListeners.add(cb);
182
- return () => {
183
- effects.scaleListeners.delete(cb);
184
- };
232
+ affected: (event) => event.type === "pages.scaleSet" ? [event.page] : null,
233
+ changed: ({ page, cause, next }) => {
234
+ if (cause === "drop" || next === void 0) return;
235
+ ctx.state.update(clearLoadError, page.objectNumber);
236
+ const layout = ctx.getPage(page);
237
+ if (layout) annotation.setPageViewports(page, [...next], fallbackOf(layout));
185
238
  }
239
+ });
240
+ /** The engine can persist a scale into the document. */
241
+ const isPersistent = (page) => ctx.doc.page(page).measure !== void 0;
242
+ /** The page's public scale, the same object until its viewports, read state or layout change. */
243
+ const scaleOfPage = memoByKey((pageObjectNumber) => {
244
+ const page = toPageRef(pageObjectNumber);
245
+ const layout = ctx.getPage(page);
246
+ return [
247
+ viewports.get(page),
248
+ viewports.getStatus(page),
249
+ ctx.state.get().loadErrors[pageObjectNumber],
250
+ layout,
251
+ layout ? isPersistent(page) : false,
252
+ settings.get().defaultScale
253
+ ];
254
+ }, (_pageObjectNumber, pageViewports, status, error, layout, persistent) => pageScaleOf({
255
+ viewports: pageViewports,
256
+ status,
257
+ error,
258
+ size: layout?.size,
259
+ fallback: fallbackOf(layout),
260
+ persistent
261
+ }));
262
+ /** A page's scale, by ref or index; a page that isn't there has none, and isn't ready. */
263
+ const scaleOf = (page) => {
264
+ if (typeof page !== "number") return scaleOfPage(page.objectNumber);
265
+ const layout = ctx.getPage(page);
266
+ return layout ? scaleOfPage(layout.ref.objectNumber) : NO_PAGE;
267
+ };
268
+ const ensureLoaded = (page, options) => {
269
+ const layout = ctx.getPage(page);
270
+ if (!layout) return Promise.reject(new PluginError("not-found", "measurement", "no such page"));
271
+ return ctx.cancellable(options?.signal, viewports.ensureLoaded(layout.ref));
272
+ };
273
+ const connect = () => {
274
+ const hydrate = (layouts) => {
275
+ for (const layout of layouts ?? []) {
276
+ if (viewports.getStatus(layout.ref) !== "idle") continue;
277
+ viewports.ensureLoaded(layout.ref).catch(() => {});
278
+ }
279
+ };
280
+ hydrate(ctx.document()?.pages);
281
+ ctx.watch(() => ctx.document()?.pages, hydrate);
282
+ ctx.listen(settings.api.onSettingsChanged, ({ changed }) => {
283
+ if (!changed.includes("defaultScale")) return;
284
+ for (const layout of ctx.document()?.pages ?? []) {
285
+ const known = viewports.get(layout.ref);
286
+ if (known) annotation.setPageViewports(layout.ref, [...known], fallbackOf(layout));
287
+ }
288
+ });
289
+ };
290
+ return {
291
+ ensureLoaded,
292
+ scaleOf,
293
+ /** Re-read one page; for session-only scales, which no engine event announces. */
294
+ refresh: (page) => viewports.refresh(page),
295
+ connect
186
296
  };
187
297
  }
188
298
  //#endregion
189
- //#region src/effects.ts
190
- /** All engine IO and cross-plugin lifecycle work lives here. Calls serialize
191
- * per page; refresh epochs prevent an old read from replacing a newer scale. */
192
- function createMeasurementEffects(ctx, config) {
193
- const anno = ctx.get(AnnotationToken);
194
- const interaction = ctx.get(InteractionToken);
195
- let disposed = false;
196
- const epochs = /* @__PURE__ */ new Map();
197
- const reading = /* @__PURE__ */ new Map();
198
- const queues = /* @__PURE__ */ new Map();
199
- const calibrationListeners = /* @__PURE__ */ new Set();
200
- const scaleListeners = /* @__PURE__ */ new Set();
201
- const meta = (pon) => ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);
202
- const live = () => {
203
- if (disposed || !ctx.doc) throw new EngineError(EngineErrorCode.DocNotOpen);
299
+ //#region src/write/calibration.ts
300
+ /** The calibrate flow: arm the tool, turn a captured distance draft into a
301
+ * request (page space), and dismiss it. */
302
+ function createCalibration(ctx, { events, store, siblings }) {
303
+ const { calibrationRequested, calibrationDismissed } = events;
304
+ const { canCalibrate } = store;
305
+ const { annotation, interaction } = siblings;
306
+ const startCalibration = () => {
307
+ ctx.assertAllowed(SCALE_PERMISSION, "measurement.startCalibration");
308
+ ctx.state.update(setCalibration, null);
309
+ interaction.activateTool("calibrate");
204
310
  };
205
- const assertAllowed = () => {
206
- live();
207
- if (!ctx.doc.security.allows("doc.annotate.modify")) throw new PermissionDenied("doc.annotate.modify", "measurement");
311
+ const dismissCalibration = () => {
312
+ if (!ctx.state.get().calibration) return;
313
+ ctx.state.update(setCalibration, null);
314
+ calibrationDismissed.emit({});
208
315
  };
209
- const publish = (pon, viewports) => {
210
- if (disposed) return;
211
- const page = meta(pon);
212
- if (!page) return;
213
- const fallback = defaultMeasure(config, page.userUnit);
214
- anno.setPageViewports(pon, viewports, fallback);
215
- ctx.dispatch({
216
- type: "PAGE_SCALE",
217
- pon,
218
- viewports,
219
- scale: selectPageScale(viewports, page.boxes.crop, fallback, !!ctx.doc?.page(pon).measure)
316
+ /** A distance draft from the calibrate tool becomes the pending request. */
317
+ const connect = () => {
318
+ ctx.listen(annotation.onDraftCaptured, (draft) => {
319
+ if (draft.tool !== "calibrate" || !canCalibrate()) return;
320
+ const from = measurementPoint(draft.from);
321
+ const to = measurementPoint(draft.to);
322
+ const userSpaceLength = Math.hypot(to.x - from.x, to.y - from.y);
323
+ if (!(userSpaceLength > 0)) return;
324
+ const request = {
325
+ page: draft.page,
326
+ from,
327
+ to,
328
+ userSpaceLength
329
+ };
330
+ interaction.activateTool("pointer");
331
+ ctx.state.update(setCalibration, request);
332
+ calibrationRequested.emit({ request });
220
333
  });
221
334
  };
222
- const refresh = (pon) => {
223
- live();
224
- const page = meta(pon);
225
- if (!page) return Promise.reject(new EngineError(EngineErrorCode.NotFound, "Page not found"));
226
- const epoch = (epochs.get(pon) ?? 0) + 1;
227
- epochs.set(pon, epoch);
228
- anno.setPageViewports(pon, void 0, defaultMeasure(config, page.userUnit));
229
- const previous = ctx.getState().pages[pon];
230
- ctx.dispatch({
231
- type: "PAGE_SCALE",
232
- pon,
233
- viewports: previous?.viewports ?? [],
234
- scale: {
235
- ...previous?.scale ?? {
236
- measure: null,
237
- source: "default",
238
- persistent: !!ctx.doc.page(pon).measure
239
- },
240
- ready: false
241
- }
242
- });
243
- const service = ctx.doc.page(pon).measure;
244
- const request = (async () => {
245
- try {
246
- const viewports = service ? await service.viewports() : ctx.getState().pages[pon]?.viewports ?? [];
247
- if (!disposed && epochs.get(pon) === epoch) publish(pon, viewports);
248
- } catch (error) {
249
- if (!disposed && epochs.get(pon) === epoch) ctx.dispatch({
250
- type: "PAGE_SCALE",
251
- pon,
252
- viewports: [],
253
- scale: {
254
- measure: null,
255
- source: "default",
256
- ready: false,
257
- persistent: !!service,
258
- error: serializeError(error)
259
- }
260
- });
261
- throw error;
262
- } finally {
263
- if (epochs.get(pon) === epoch) reading.delete(pon);
335
+ return {
336
+ connect,
337
+ api: {
338
+ startCalibration,
339
+ dismissCalibration
340
+ }
341
+ };
342
+ }
343
+ //#endregion
344
+ //#region src/write/create.ts
345
+ /** Measurement creation: a measurement annotation with the page's scale, through the annotation plugin. */
346
+ function createMeasuring(ctx, { siblings }, { scaleOf }) {
347
+ const { annotation } = siblings;
348
+ /**
349
+ * The measurement's engine draft: the tool's current defaults (style,
350
+ * caption and leader), the dimension intent, and the page's scale. The
351
+ * engine works out the label from the points and the scale.
352
+ */
353
+ const draftOf = (input, page, tool) => {
354
+ const style = annotation.tools.getDefaults(tool);
355
+ const shared = {
356
+ color: style.color,
357
+ strokeWidth: style.strokeWidth,
358
+ opacity: style.opacity,
359
+ measure: scaleOf(page).measure,
360
+ captionEnabled: style.captionEnabled ?? true
361
+ };
362
+ switch (input.kind) {
363
+ case "distance": {
364
+ const [from, to] = input.points;
365
+ if (!from || !to || input.points.length !== 2) return new PluginError("invalid-input", "measurement", "a distance needs exactly two points");
366
+ return {
367
+ subtype: "line",
368
+ intent: "line-dimension",
369
+ linePoints: {
370
+ start: from,
371
+ end: to
372
+ },
373
+ lineEndings: style.lineEndings,
374
+ interiorColor: style.interiorColor ?? style.color,
375
+ ...shared,
376
+ captionPosition: style.captionPosition ?? "inline",
377
+ ...style.leader ? { leader: style.leader } : {}
378
+ };
264
379
  }
265
- })();
266
- reading.set(pon, request);
267
- return request;
380
+ case "perimeter": return {
381
+ subtype: "polyline",
382
+ intent: "polyline-dimension",
383
+ vertices: input.points,
384
+ lineEndings: style.lineEndings,
385
+ ...shared
386
+ };
387
+ case "area": return {
388
+ subtype: "polygon",
389
+ intent: "polygon-dimension",
390
+ vertices: input.points,
391
+ interiorColor: style.interiorColor,
392
+ ...shared
393
+ };
394
+ }
395
+ };
396
+ const createMeasurement = async (input, options) => {
397
+ ctx.assertAllowed("annotations:create", "measurement.createMeasurement");
398
+ const page = ctx.pageOf(input.page).ref;
399
+ if (!scaleOf(page).ready) throw new PluginError("not-ready", "measurement", "the page scale is not known yet");
400
+ const draft = draftOf(input, page, input.tool ?? input.kind);
401
+ if (draft instanceof Error) throw draft;
402
+ return annotation.create(page, draft, void 0, { signal: options?.signal });
268
403
  };
269
- const prepare = (pon) => reading.get(pon) ?? (ctx.getState().pages[pon]?.scale.ready ? Promise.resolve() : refresh(pon));
270
- const enqueue = (pon, action) => {
271
- const result = (queues.get(pon) ?? Promise.resolve()).catch(() => {}).then(action);
272
- queues.set(pon, result);
273
- result.finally(() => {
274
- if (queues.get(pon) === result) queues.delete(pon);
275
- }).catch(() => {});
276
- return result;
404
+ return { api: { createMeasurement } };
405
+ }
406
+ //#endregion
407
+ //#region src/write/scale.ts
408
+ /**
409
+ * Scale writes. Every change serializes per page, loads the page first, and
410
+ * re-measures the page's measurement annotations unless told not to. A
411
+ * multi-page change reports per-page failures; a single-page change rejects.
412
+ */
413
+ function createScaleWrites(ctx, { events, store, siblings }, { ensureLoaded, refresh, scaleOf }) {
414
+ const { scaleChanged, calibrationCompleted } = events;
415
+ const { requirePage, targets } = store;
416
+ const { annotation } = siblings;
417
+ const settings = ctx.settings();
418
+ /** One write queue per page, so a page's scale changes land in order. */
419
+ const pageQueues = /* @__PURE__ */ new Map();
420
+ const queueOf = (page) => {
421
+ let queue = pageQueues.get(page.objectNumber);
422
+ if (!queue) {
423
+ queue = ctx.serialQueue(`scale:${page.objectNumber}`);
424
+ pageQueues.set(page.objectNumber, queue);
425
+ }
426
+ return queue;
277
427
  };
278
- const save = async (pon, scale, opts) => {
279
- assertAllowed();
280
- await prepare(pon);
281
- assertAllowed();
282
- const service = ctx.doc.page(pon).measure;
428
+ ctx.onSettle(async () => {
429
+ await Promise.all([...pageQueues.values()].map((queue) => queue.idle()));
430
+ });
431
+ /** Write one page's scale (`null` = back to the default) and re-measure. */
432
+ const save = async (page, scale, options) => {
433
+ const { signal } = options;
434
+ ctx.assertAllowed(SCALE_PERMISSION, "changing a scale");
435
+ await ensureLoaded(page, { signal });
436
+ ctx.assertAllowed(SCALE_PERMISSION, "changing a scale");
437
+ const service = ctx.doc.page(page).measure;
283
438
  if (service) {
284
- await service.setScale(scale);
285
- live();
286
- await refresh(pon);
439
+ await ctx.cancellable(signal, service.setScale(scale));
440
+ await ensureLoaded(page);
287
441
  } else {
288
- const page = meta(pon);
289
- const old = ctx.getState().pages[pon]?.viewports ?? [];
290
- publish(pon, [...old.filter((v) => !v.owned), {
291
- bbox: page.boxes.crop,
442
+ const layout = requirePage(page);
443
+ const kept = (ctx.state.get().localViewports[page.objectNumber] ?? []).filter((viewport) => !viewport.owned);
444
+ ctx.state.update(setLocalViewports, page.objectNumber, [...kept, ...scale ? [{
445
+ bbox: {
446
+ x: 0,
447
+ y: 0,
448
+ ...layout.size
449
+ },
292
450
  name: "EmbedPDF",
293
451
  owned: true,
294
452
  measure: scale
295
- }]);
453
+ }] : []]);
454
+ await refresh(page);
296
455
  }
297
- live();
298
- const report = opts.recalculate === false ? {
299
- pon,
300
- scale,
456
+ const effective = scale ?? defaultMeasure(settings.get().defaultScale, requirePage(page).userUnit);
457
+ const report = options.recalculate === false ? {
458
+ page,
459
+ scale: effective,
301
460
  updated: [],
302
461
  skipped: [],
303
462
  failed: []
304
- } : await anno.remeasurePage(pon, scale);
305
- live();
306
- for (const cb of scaleListeners) try {
307
- cb({
308
- pon,
309
- report
310
- });
311
- } catch {}
463
+ } : await ctx.cancellable(signal, annotation.remeasurePage(page, effective));
464
+ scaleChanged.emit({
465
+ page,
466
+ report
467
+ });
312
468
  return report;
313
469
  };
314
- const change = async (pons, measure, opts = {}) => {
315
- assertAllowed();
316
- ctx.dispatch({
317
- type: "PENDING",
318
- delta: 1
319
- });
470
+ const change = async (pages, measure, options = {}) => {
471
+ ctx.assertAllowed(SCALE_PERMISSION, "changing a scale");
472
+ const target = targets(pages);
473
+ if (options.signal?.aborted) throw new PluginError("operation-cancelled", "measurement", "operation cancelled", { cause: options.signal.reason });
474
+ const tolerant = pages === "all" || target.length > 1;
475
+ ctx.state.update(beginScaleChange);
320
476
  try {
321
- const reports = await Promise.all(pons.map((pon) => enqueue(pon, async () => {
477
+ const reports = await Promise.all(target.map((page) => queueOf(page)(async () => {
322
478
  try {
323
- await prepare(pon);
324
- const scale = measure(pon);
325
- assertWritableMeasure(scale);
326
- return await save(pon, scale, opts);
479
+ await ensureLoaded(page, { signal: options.signal });
480
+ const scale = measure(page);
481
+ if (scale) assertWritableMeasure(scale);
482
+ return await save(page, scale, options);
327
483
  } catch (error) {
328
- if (!opts.allPages) throw error;
484
+ const refused = toPluginError("measurement", error);
485
+ if (!tolerant || refused.code === "operation-cancelled") throw refused;
329
486
  return {
330
- pon,
487
+ page,
331
488
  updated: [],
332
489
  skipped: [],
333
490
  failed: [],
334
491
  scaleError: serializeError(error)
335
492
  };
336
493
  }
337
- })));
338
- live();
339
- ctx.dispatch({
340
- type: "REPORTS",
341
- reports
342
- });
494
+ }, options)));
495
+ ctx.state.update(setReports, reports);
343
496
  return reports;
344
497
  } finally {
345
- if (!disposed) ctx.dispatch({
346
- type: "PENDING",
347
- delta: -1
348
- });
498
+ ctx.state.update(endScaleChange);
349
499
  }
350
500
  };
351
- const start = () => {
352
- const doc = ctx.doc;
353
- if (!doc) return;
354
- ctx.cleanup(() => {
355
- disposed = true;
356
- epochs.clear();
357
- queues.clear();
358
- calibrationListeners.clear();
359
- scaleListeners.clear();
360
- });
361
- ctx.cleanup(anno.onDraftCaptured((draft) => {
362
- if (draft.tool !== "calibrate" || !doc.security.allows("doc.annotate.modify")) return;
363
- const a = measurementPoint(draft.from);
364
- const b = measurementPoint(draft.to);
365
- const userSpaceLength = Math.hypot(b.x - a.x, b.y - a.y);
366
- if (!(userSpaceLength > 0)) return;
367
- const request = {
368
- pon: draft.pon,
369
- from: a,
370
- to: b,
371
- userSpaceLength
372
- };
373
- interaction.activateTool("pointer");
374
- ctx.dispatch({
375
- type: "CALIBRATION",
376
- request
377
- });
378
- for (const cb of calibrationListeners) try {
379
- cb(request);
380
- } catch {}
381
- }));
382
- const hydrate = () => {
383
- for (const page of ctx.document()?.pages ?? []) refresh(page.pageObjectNumber).catch(() => {});
384
- };
385
- ctx.cleanup(doc.events.subscribe((event) => {
386
- if (event.type === "page.viewportsChanged") refresh(event.pageObjectNumber).catch(() => {});
387
- else if (event.type === "stream.desynced" || event.type === "document.versioned") hydrate();
388
- }));
389
- let pages = "";
390
- const reconcile = () => {
391
- const next = (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber).join(",");
392
- if (next === pages) return;
393
- pages = next;
394
- for (const page of ctx.document()?.pages ?? []) if (!ctx.getState().pages[page.pageObjectNumber]) prepare(page.pageObjectNumber).catch(() => {});
395
- };
396
- ctx.cleanup(ctx.subscribe(reconcile));
397
- reconcile();
501
+ /** The page's current rectilinear scale, for unit and precision edits. */
502
+ const rectilinearOf = (page) => {
503
+ const scale = scaleOf(page).measure;
504
+ if (!scale || scale.subtype !== "rectilinear") throw new PluginError("not-ready", "measurement", "calibrate this page before changing its units or precision");
505
+ return scale;
398
506
  };
399
- return {
400
- start,
401
- prepare,
402
- change,
403
- calibrationListeners,
404
- scaleListeners
507
+ const calibrate = async (input, options = {}) => {
508
+ const start = measurementPoint(input.from);
509
+ const end = measurementPoint(input.to);
510
+ let scale;
511
+ try {
512
+ scale = measureFromKnownLength(Math.hypot(end.x - start.x, end.y - start.y), input.distance);
513
+ } catch (error) {
514
+ throw new PluginError("invalid-input", "measurement", String(error.message ?? error));
515
+ }
516
+ const { applyTo, ...rest } = options;
517
+ const page = requirePage(input.page).ref;
518
+ const reports = await change(applyTo ?? page, () => scale, rest);
519
+ const request = ctx.state.get().calibration;
520
+ if (request && pageRefsEqual(request.page, page)) ctx.state.update(setCalibration, null);
521
+ calibrationCompleted.emit({ page });
522
+ return reports;
405
523
  };
524
+ return { api: {
525
+ setScale: (pages, measure, options) => change(pages, () => measure, options),
526
+ calibrate,
527
+ setUnit: (pages, unit, options) => change(pages, (page) => withUnit(rectilinearOf(page), unit), options),
528
+ setAreaUnit: (pages, unit, options) => change(pages, (page) => withAreaUnit(rectilinearOf(page), unit), options),
529
+ setPrecision: (pages, precision, options) => change(pages, (page) => withPrecision(rectilinearOf(page), precision), options),
530
+ setPreset: (pages, presetId, options) => {
531
+ const preset = settings.get().presets.find((candidate) => candidate.id === presetId);
532
+ if (!preset) return Promise.reject(new PluginError("not-found", "measurement", `unknown scale preset '${presetId}'`));
533
+ return change(pages, (page) => measureFromRatio(preset.paper, preset.real, preset.unit, { userUnit: ctx.getPage(page)?.userUnit ?? 1 }), options);
534
+ },
535
+ clearScale: (pages, options) => change(pages, () => null, options)
536
+ } };
406
537
  }
407
538
  //#endregion
408
- //#region src/reducer.ts
409
- const initialMeasurementState = {
410
- pages: {},
411
- pending: 0,
412
- calibration: null,
413
- reports: []
414
- };
415
- function measurementReducer(s, a) {
416
- switch (a.type) {
417
- case "PAGE_SCALE": return {
418
- ...s,
419
- pages: {
420
- ...s.pages,
421
- [a.pon]: {
422
- viewports: a.viewports,
423
- scale: a.scale
424
- }
539
+ //#region src/controller.ts
540
+ /**
541
+ * The measurement controller: the composition root. It builds the plugin's
542
+ * services once, wires each area with the services it declares, and
543
+ * assembles the capability from the areas' API slices. No behavior lives
544
+ * here: every verb and read has a home in `read/`, `write/` or `sync/`.
545
+ */
546
+ function createMeasurementController(ctx) {
547
+ const services = createServices(ctx);
548
+ const { events, store } = services;
549
+ const settings = ctx.settings();
550
+ const viewports = createViewportSync(ctx, services);
551
+ const reads = createScaleReads(ctx, services, viewports);
552
+ const scale = createScaleWrites(ctx, services, viewports);
553
+ const calibration = createCalibration(ctx, services);
554
+ const measuring = createMeasuring(ctx, services, viewports);
555
+ return {
556
+ api: composeApi("measurement", [
557
+ settings.api,
558
+ reads.api,
559
+ scale.api,
560
+ calibration.api,
561
+ measuring.api,
562
+ {
563
+ canCalibrate: store.canCalibrate,
564
+ ensureLoaded: viewports.ensureLoaded,
565
+ onScaleChanged: events.scaleChanged.on,
566
+ onCalibrationRequested: events.calibrationRequested.on,
567
+ onCalibrationCompleted: events.calibrationCompleted.on,
568
+ onCalibrationDismissed: events.calibrationDismissed.on
425
569
  }
426
- };
427
- case "PENDING": return {
428
- ...s,
429
- pending: Math.max(0, s.pending + a.delta)
430
- };
431
- case "CALIBRATION": return {
432
- ...s,
433
- calibration: a.request
434
- };
435
- case "REPORTS": return {
436
- ...s,
437
- reports: a.reports
438
- };
439
- }
570
+ ]),
571
+ connect() {
572
+ calibration.connect();
573
+ viewports.connect();
574
+ }
575
+ };
440
576
  }
441
577
  //#endregion
442
578
  //#region src/measurement.plugin.ts
443
- const measurementPlugin = (config = {}) => {
444
- const drivers = /* @__PURE__ */ new Map();
445
- return definePlugin({
446
- id: "measurement",
447
- scope: "document",
448
- token: MeasurementToken,
449
- requires: [AnnotationToken, InteractionToken],
450
- initialState: initialMeasurementState,
451
- reduce: measurementReducer,
452
- capability: (ctx) => {
453
- const effects = createMeasurementEffects(ctx, config);
454
- drivers.set(ctx.documentId, effects);
455
- ctx.cleanup(() => {
456
- drivers.delete(ctx.documentId);
457
- });
458
- return createMeasurementCapability(ctx, config, effects);
459
- },
460
- effects: (ctx) => {
461
- ctx.get(MeasurementToken);
462
- drivers.get(ctx.documentId).start();
463
- }
464
- });
465
- };
579
+ /**
580
+ * Page scale, calibration and measurement readouts, document-scoped. The
581
+ * annotation plugin owns the measurement annotations; this plugin owns the
582
+ * page's viewports (the scale), keeps the annotation plugin's measure in step,
583
+ * and turns the calibrate tool's drafts into scale requests. `config` is the
584
+ * settings the app registers, over {@link MEASUREMENT_DEFAULTS}.
585
+ */
586
+ const measurementPlugin = (config) => definePlugin({
587
+ id: "measurement",
588
+ scope: "document",
589
+ token: MeasurementToken,
590
+ requires: [AnnotationToken, InteractionToken],
591
+ state: initialMeasurementState,
592
+ settings: {
593
+ defaults: MEASUREMENT_DEFAULTS,
594
+ registered: config,
595
+ whole: ["defaultScale"]
596
+ },
597
+ create: createMeasurementController
598
+ });
599
+ //#endregion
600
+ //#region src/state.ts
601
+ /**
602
+ * The measurements page's State table as code: what `useMeasurementState()`
603
+ * returns, and the same fields in every other framework. A page's scale and a
604
+ * measurement's readout are not here: `usePageScale(page)` and
605
+ * `useMeasurementReadout(ref)` read them, so a component re-renders only for
606
+ * its own page or measurement.
607
+ */
608
+ const measurementState = defineState(MeasurementToken, {
609
+ read: (measurement) => ({
610
+ busy: measurement.isBusy(),
611
+ calibrationRequest: measurement.getCalibrationRequest(),
612
+ lastReports: measurement.listLastReports()
613
+ }),
614
+ empty: {
615
+ busy: false,
616
+ calibrationRequest: null,
617
+ lastReports: NO_REPORTS
618
+ }
619
+ });
466
620
  //#endregion
467
- export { MeasurementToken, measurementPlugin };
621
+ export { MEASUREMENT_DEFAULTS, MeasurementToken, NO_PAGE_SCALE, NO_READOUT, measurementPlugin, measurementState };
468
622
 
469
623
  //# sourceMappingURL=index.js.map