@beyondwork/docx-react-component 1.0.35 → 1.0.37
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/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +84 -1
- package/src/core/commands/index.ts +19 -2
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +178 -16
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/session-capabilities.ts +7 -4
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout invalidation — determines what must be recomputed after a mutation.
|
|
3
|
+
*
|
|
4
|
+
* Instead of recomputing the entire document on every change, this module
|
|
5
|
+
* classifies invalidation reasons and determines the minimal dirty range.
|
|
6
|
+
* The classification is conservative-correct: when any doubt exists (section
|
|
7
|
+
* boundary inside the dirty range, style change, numbering change, etc.) we
|
|
8
|
+
* fall back to a full rebuild. The `scope` field declares which path the
|
|
9
|
+
* engine should follow.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
|
|
13
|
+
import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
|
|
14
|
+
import type { ResolvedDocumentSection } from "../document-layout.ts";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scope of the invalidation:
|
|
22
|
+
* - "full" — rebuild the entire page graph
|
|
23
|
+
* - "bounded" — retain unaffected pages, rebuild only the dirty tail
|
|
24
|
+
* - "field-only" — no layout rebuild; field display values refresh
|
|
25
|
+
*/
|
|
26
|
+
export type InvalidationScope = "full" | "bounded" | "field-only";
|
|
27
|
+
|
|
28
|
+
export interface InvalidationResult {
|
|
29
|
+
/** Scope declaring which path the engine should follow. */
|
|
30
|
+
scope: InvalidationScope;
|
|
31
|
+
/**
|
|
32
|
+
* Whether a full recompute is needed. Kept for backward compatibility;
|
|
33
|
+
* equivalent to `scope === "full"`.
|
|
34
|
+
*/
|
|
35
|
+
requiresFullRecompute: boolean;
|
|
36
|
+
/** If partial invalidation is possible, the dirty section range. */
|
|
37
|
+
dirtySectionRange?: { from: number; to: number } | null;
|
|
38
|
+
/**
|
|
39
|
+
* If partial invalidation is possible, the dirty page range.
|
|
40
|
+
* Uses `firstPageIndex` / `lastPageIndex` rather than `from` / `to` to
|
|
41
|
+
* avoid confusion with offset-based ranges elsewhere in the engine.
|
|
42
|
+
*/
|
|
43
|
+
dirtyPageRange?: { firstPageIndex: number; lastPageIndex: number } | null;
|
|
44
|
+
/** Field families that need refresh after this layout change. */
|
|
45
|
+
dirtyFieldFamilies: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Invalidation analysis
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Analyze an invalidation reason against the current page graph to
|
|
54
|
+
* determine the minimal recomputation scope.
|
|
55
|
+
*/
|
|
56
|
+
export function analyzeInvalidation(
|
|
57
|
+
reason: LayoutInvalidationReason,
|
|
58
|
+
graph: RuntimePageGraph | null,
|
|
59
|
+
): InvalidationResult {
|
|
60
|
+
// No existing graph — classify by kind alone; for "field-refresh" we do
|
|
61
|
+
// not force a layout rebuild even without a graph because only display
|
|
62
|
+
// values change.
|
|
63
|
+
if (!graph) {
|
|
64
|
+
if (reason.kind === "field-refresh") {
|
|
65
|
+
return {
|
|
66
|
+
scope: "field-only",
|
|
67
|
+
requiresFullRecompute: false,
|
|
68
|
+
dirtyFieldFamilies: reason.family ? [reason.family] : [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
scope: "full",
|
|
73
|
+
requiresFullRecompute: true,
|
|
74
|
+
dirtyFieldFamilies: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (reason.kind) {
|
|
79
|
+
case "full":
|
|
80
|
+
case "styles-change":
|
|
81
|
+
case "theme-change":
|
|
82
|
+
// These affect the entire document
|
|
83
|
+
return {
|
|
84
|
+
scope: "full",
|
|
85
|
+
requiresFullRecompute: true,
|
|
86
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
case "content-edit":
|
|
90
|
+
return analyzeContentEdit(reason, graph);
|
|
91
|
+
|
|
92
|
+
case "section-change":
|
|
93
|
+
return analyzeSectionChange(reason, graph);
|
|
94
|
+
|
|
95
|
+
case "numbering-change":
|
|
96
|
+
// Numbering changes can affect indentation and spacing globally,
|
|
97
|
+
// but could be bounded to sections using that numbering instance.
|
|
98
|
+
// For now, full recompute.
|
|
99
|
+
return {
|
|
100
|
+
scope: "full",
|
|
101
|
+
requiresFullRecompute: true,
|
|
102
|
+
dirtyFieldFamilies: [],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
case "field-refresh":
|
|
106
|
+
// Field refresh doesn't change layout, just field display values
|
|
107
|
+
return {
|
|
108
|
+
scope: "field-only",
|
|
109
|
+
requiresFullRecompute: false,
|
|
110
|
+
dirtyFieldFamilies: reason.family ? [reason.family] : [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* After layout recomputation, determine which page-sensitive field families
|
|
117
|
+
* need to be refreshed based on what changed.
|
|
118
|
+
*/
|
|
119
|
+
export function computeFieldDirtiness(
|
|
120
|
+
oldGraph: RuntimePageGraph | null,
|
|
121
|
+
newGraph: RuntimePageGraph,
|
|
122
|
+
): string[] {
|
|
123
|
+
if (!oldGraph) {
|
|
124
|
+
return ["PAGE", "NUMPAGES", "SECTIONPAGES"];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const dirtyFamilies: string[] = [];
|
|
128
|
+
|
|
129
|
+
// Page count changed => NUMPAGES is dirty
|
|
130
|
+
if (oldGraph.contentPageCount !== newGraph.contentPageCount) {
|
|
131
|
+
dirtyFamilies.push("NUMPAGES");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Page boundaries changed => PAGE fields may show different values
|
|
135
|
+
const oldOffsets = oldGraph.pages.map((p) => p.endOffset).join(",");
|
|
136
|
+
const newOffsets = newGraph.pages.map((p) => p.endOffset).join(",");
|
|
137
|
+
if (oldOffsets !== newOffsets) {
|
|
138
|
+
dirtyFamilies.push("PAGE");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Section structure changed
|
|
142
|
+
if (oldGraph.sections.length !== newGraph.sections.length) {
|
|
143
|
+
dirtyFamilies.push("SECTIONPAGES");
|
|
144
|
+
} else {
|
|
145
|
+
for (let i = 0; i < oldGraph.sections.length; i++) {
|
|
146
|
+
const oldSection = oldGraph.sections[i]!;
|
|
147
|
+
const newSection = newGraph.sections[i]!;
|
|
148
|
+
if (
|
|
149
|
+
oldSection.start !== newSection.start ||
|
|
150
|
+
oldSection.end !== newSection.end
|
|
151
|
+
) {
|
|
152
|
+
dirtyFamilies.push("SECTIONPAGES");
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return dirtyFamilies;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Internals
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function analyzeContentEdit(
|
|
166
|
+
reason: Extract<LayoutInvalidationReason, { kind: "content-edit" }>,
|
|
167
|
+
graph: RuntimePageGraph,
|
|
168
|
+
): InvalidationResult {
|
|
169
|
+
// Find the first page whose endOffset > reason.from. That's the first
|
|
170
|
+
// page whose content could have been affected by the edit.
|
|
171
|
+
// (We scan linearly; page counts are small in practice.)
|
|
172
|
+
let firstDirty = -1;
|
|
173
|
+
for (let i = 0; i < graph.pages.length; i += 1) {
|
|
174
|
+
const page = graph.pages[i]!;
|
|
175
|
+
if (page.isBlankFiller) continue;
|
|
176
|
+
if (page.endOffset > reason.from) {
|
|
177
|
+
firstDirty = i;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (firstDirty < 0) {
|
|
183
|
+
// Edit is past all pages — need a full recompute to re-paginate the tail.
|
|
184
|
+
return {
|
|
185
|
+
scope: "full",
|
|
186
|
+
requiresFullRecompute: true,
|
|
187
|
+
dirtyPageRange: null,
|
|
188
|
+
dirtySectionRange: null,
|
|
189
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES"],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Find the last page whose startOffset <= reason.to. That's the last
|
|
194
|
+
// page whose content could have been affected by the edit. If the edit
|
|
195
|
+
// crosses page boundaries, every page from firstDirty to lastDirty is
|
|
196
|
+
// potentially dirty.
|
|
197
|
+
let lastDirty = firstDirty;
|
|
198
|
+
for (let i = graph.pages.length - 1; i >= 0; i -= 1) {
|
|
199
|
+
const page = graph.pages[i]!;
|
|
200
|
+
if (page.isBlankFiller) continue;
|
|
201
|
+
if (page.startOffset <= reason.to) {
|
|
202
|
+
lastDirty = i;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (lastDirty < firstDirty) {
|
|
207
|
+
lastDirty = firstDirty;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Safety: if the dirty range straddles a section boundary, fall back to
|
|
211
|
+
// full recompute. Section-break handling (nextPage/evenPage/oddPage blank
|
|
212
|
+
// fillers, continuous merges) is easier to prove correct when the whole
|
|
213
|
+
// document re-paginates.
|
|
214
|
+
const firstSection = graph.pages[firstDirty]!.sectionIndex;
|
|
215
|
+
const lastSection = graph.pages[lastDirty]!.sectionIndex;
|
|
216
|
+
if (firstSection !== lastSection) {
|
|
217
|
+
return {
|
|
218
|
+
scope: "full",
|
|
219
|
+
requiresFullRecompute: true,
|
|
220
|
+
dirtyPageRange: null,
|
|
221
|
+
dirtySectionRange: null,
|
|
222
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Bounded: we can retain pages [0..firstDirty-1] from the prior graph and
|
|
227
|
+
// rebuild [firstDirty..end]. Rebuilding to the end (not just lastDirty)
|
|
228
|
+
// is required because paragraph reflow past the edit can shift subsequent
|
|
229
|
+
// pages. The engine's splice helper uses this range.
|
|
230
|
+
return {
|
|
231
|
+
scope: "bounded",
|
|
232
|
+
requiresFullRecompute: false,
|
|
233
|
+
dirtyPageRange: {
|
|
234
|
+
firstPageIndex: firstDirty,
|
|
235
|
+
lastPageIndex: graph.pages.length - 1,
|
|
236
|
+
},
|
|
237
|
+
dirtySectionRange: null,
|
|
238
|
+
dirtyFieldFamilies: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function analyzeSectionChange(
|
|
243
|
+
reason: Extract<LayoutInvalidationReason, { kind: "section-change" }>,
|
|
244
|
+
graph: RuntimePageGraph,
|
|
245
|
+
): InvalidationResult {
|
|
246
|
+
// Section property changes affect from that section onward; conservative
|
|
247
|
+
// fallback is a full recompute.
|
|
248
|
+
return {
|
|
249
|
+
scope: "full",
|
|
250
|
+
requiresFullRecompute: true,
|
|
251
|
+
dirtySectionRange: {
|
|
252
|
+
from: reason.sectionIndex,
|
|
253
|
+
to: graph.sections.length - 1,
|
|
254
|
+
},
|
|
255
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LayoutMeasurementProvider — the abstraction the engine uses to measure
|
|
3
|
+
* text runs, inline objects, and table blocks.
|
|
4
|
+
*
|
|
5
|
+
* Per the architecture spec (runtime-owned-paginated-layout-engine.md §3),
|
|
6
|
+
* the engine must never call the DOM directly and never pretend browser flow
|
|
7
|
+
* is the source of truth. Instead it asks a provider that has two backends:
|
|
8
|
+
*
|
|
9
|
+
* - `empirical` (SSR-safe, default) — character-width tables
|
|
10
|
+
* - `canvas` (browser only) — Canvas2D measureText
|
|
11
|
+
*
|
|
12
|
+
* Selection is automatic: when the runtime detects a browser environment and
|
|
13
|
+
* the host opts in, the canvas backend is used; otherwise empirical stays the
|
|
14
|
+
* fallback. This keeps the gRPC/word-review-api path deterministic.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { SurfaceBlockSnapshot } from "../../api/public-types";
|
|
18
|
+
import type { ResolvedParagraphFormatting } from "./resolved-formatting-state.ts";
|
|
19
|
+
import type { ResolvedRunFormatting } from "./resolved-formatting-document.ts";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Provider contract
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type MeasurementFidelity =
|
|
26
|
+
| "empirical"
|
|
27
|
+
| "canvas"
|
|
28
|
+
| "canvas-with-font-loading";
|
|
29
|
+
|
|
30
|
+
export interface MeasureLineFragmentsInput {
|
|
31
|
+
/** Paragraph block to measure. */
|
|
32
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
|
|
33
|
+
/** Paragraph-level resolved formatting (spacing, indent, font size). */
|
|
34
|
+
formatting: ResolvedParagraphFormatting;
|
|
35
|
+
/** Per-run resolved formatting for each text segment, keyed by runId. */
|
|
36
|
+
runs: ReadonlyMap<string, ResolvedRunFormatting>;
|
|
37
|
+
/** Available column width in twips. */
|
|
38
|
+
columnWidth: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MeasuredLineFragments {
|
|
42
|
+
/** Number of line boxes produced for this paragraph. */
|
|
43
|
+
lineCount: number;
|
|
44
|
+
/** Total inline width of the longest line (twips). Used for widow logic. */
|
|
45
|
+
maxLineWidth: number;
|
|
46
|
+
/** Estimated line heights per line (twips). Length === lineCount. */
|
|
47
|
+
lineHeights: number[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MeasureInlineObjectInput {
|
|
51
|
+
/** Width of the embedded object in twips. */
|
|
52
|
+
widthTwips: number;
|
|
53
|
+
/** Height of the embedded object in twips. */
|
|
54
|
+
heightTwips: number;
|
|
55
|
+
/** Whether the object is floating or inline. */
|
|
56
|
+
display: "inline" | "floating";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MeasuredInlineObject {
|
|
60
|
+
widthTwips: number;
|
|
61
|
+
heightTwips: number;
|
|
62
|
+
/** How many text lines this object displaces. */
|
|
63
|
+
displacedLineCount: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MeasureTableBlockInput {
|
|
67
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
|
|
68
|
+
columnWidth: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MeasuredTableBlock {
|
|
72
|
+
totalHeightTwips: number;
|
|
73
|
+
/** Per-row measured heights (twips). */
|
|
74
|
+
rowHeights: number[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface LayoutMeasurementProvider {
|
|
78
|
+
readonly fidelity: MeasurementFidelity;
|
|
79
|
+
/**
|
|
80
|
+
* Resolve when the provider is ready to produce accurate measurements.
|
|
81
|
+
* Empirical resolves immediately. Canvas resolves once fonts are available.
|
|
82
|
+
*/
|
|
83
|
+
whenReady(): Promise<void>;
|
|
84
|
+
|
|
85
|
+
measureLineFragments(input: MeasureLineFragmentsInput): MeasuredLineFragments;
|
|
86
|
+
measureInlineObject(input: MeasureInlineObjectInput): MeasuredInlineObject;
|
|
87
|
+
measureTableBlock(input: MeasureTableBlockInput): MeasuredTableBlock;
|
|
88
|
+
|
|
89
|
+
/** Clear internal caches. Call after font registration changes. */
|
|
90
|
+
invalidateCache(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Provider selection
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export type MeasurementBackendPreference = "auto" | "empirical" | "canvas";
|
|
98
|
+
|
|
99
|
+
export interface CreateMeasurementProviderOptions {
|
|
100
|
+
preference?: MeasurementBackendPreference;
|
|
101
|
+
/**
|
|
102
|
+
* When the canvas backend is selected and the host passes a font loader,
|
|
103
|
+
* the provider will await `loader.whenReady()` before its own `whenReady`
|
|
104
|
+
* resolves.
|
|
105
|
+
*/
|
|
106
|
+
fontLoader?: { whenReady(): Promise<void>; isSupported(): boolean };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create the default measurement provider for a runtime.
|
|
111
|
+
*
|
|
112
|
+
* The factory lives here so that callers only ever import this module and the
|
|
113
|
+
* backend modules are lazy-loaded (canvas backend is not pulled into SSR
|
|
114
|
+
* bundles).
|
|
115
|
+
*/
|
|
116
|
+
export async function createMeasurementProvider(
|
|
117
|
+
options: CreateMeasurementProviderOptions = {},
|
|
118
|
+
): Promise<LayoutMeasurementProvider> {
|
|
119
|
+
const preference = options.preference ?? "auto";
|
|
120
|
+
const canvasSupported = canUseCanvas();
|
|
121
|
+
|
|
122
|
+
if (preference === "canvas" && !canvasSupported) {
|
|
123
|
+
// Host requested canvas but we cannot satisfy it; fall back to empirical.
|
|
124
|
+
return createEmpiricalProvider();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (preference === "empirical" || !canvasSupported) {
|
|
128
|
+
return createEmpiricalProvider();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// auto or canvas with canvas support
|
|
132
|
+
return createCanvasProvider(options.fontLoader);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Synchronous variant that always returns the empirical backend.
|
|
137
|
+
*
|
|
138
|
+
* Used in contexts where the runtime cannot `await` provider construction
|
|
139
|
+
* (e.g., inside an existing sync factory). The runtime can later swap to the
|
|
140
|
+
* canvas backend once it resolves via `createMeasurementProvider`.
|
|
141
|
+
*/
|
|
142
|
+
export function createEmpiricalMeasurementProvider(): LayoutMeasurementProvider {
|
|
143
|
+
return createEmpiricalProvider();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function canUseCanvas(): boolean {
|
|
147
|
+
if (typeof document === "undefined") return false;
|
|
148
|
+
try {
|
|
149
|
+
const canvas = document.createElement("canvas");
|
|
150
|
+
return typeof canvas.getContext === "function";
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The backend implementations live in peer modules. We keep the empirical
|
|
157
|
+
// backend statically imported so it is available in every runtime (SSR, Node,
|
|
158
|
+
// tests). The canvas backend uses dynamic import so SSR bundles do not pull
|
|
159
|
+
// it in.
|
|
160
|
+
import { createEmpiricalBackend } from "./measurement-backend-empirical.ts";
|
|
161
|
+
|
|
162
|
+
function createEmpiricalProvider(): LayoutMeasurementProvider {
|
|
163
|
+
return createEmpiricalBackend();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function createCanvasProvider(
|
|
167
|
+
fontLoader?: CreateMeasurementProviderOptions["fontLoader"],
|
|
168
|
+
): Promise<LayoutMeasurementProvider> {
|
|
169
|
+
try {
|
|
170
|
+
const mod = await import("./measurement-backend-canvas.ts");
|
|
171
|
+
return mod.createCanvasBackend(fontLoader);
|
|
172
|
+
} catch {
|
|
173
|
+
return createEmpiricalProvider();
|
|
174
|
+
}
|
|
175
|
+
}
|