@beyondwork/docx-react-component 1.0.43 → 1.0.46
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 +35 -1
- package/package.json +44 -32
- package/src/api/public-types.ts +156 -3
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +27 -2
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +16 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +21 -1
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/document-runtime.ts +351 -25
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/event-refresh-hints.ts +1 -0
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +46 -0
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/public-facet.ts +30 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +35 -2
- package/src/ui/WordReviewEditor.tsx +75 -192
- package/src/ui/editor-runtime-boundary.ts +5 -1
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout engine version marker — bump when **any** file under
|
|
3
|
+
* `src/runtime/layout/**` or `src/runtime/render/**` changes, or when
|
|
4
|
+
* the page-break widget DOM shape under `src/ui-tailwind/editor-surface/`
|
|
5
|
+
* changes in a way that affects cached render-frame diffs or persisted
|
|
6
|
+
* layout caches.
|
|
7
|
+
*
|
|
8
|
+
* Enforcement: `scripts/ci-check-layout-engine-version.mjs` inspects the
|
|
9
|
+
* PR diff; if any file in the watched trees is touched without this
|
|
10
|
+
* constant being co-touched, CI fails. See CLAUDE.md → *Performance
|
|
11
|
+
* Invariants* for the full contract.
|
|
12
|
+
*
|
|
13
|
+
* Persisted caches should key their stored snapshots on this version so a
|
|
14
|
+
* bump automatically invalidates stale entries — no corruption path
|
|
15
|
+
* exists because the version is the cache's top-level discriminator.
|
|
16
|
+
*
|
|
17
|
+
* History:
|
|
18
|
+
* 1 — initial materialization (L8 Phase B).
|
|
19
|
+
* 2 — L8 Phase B retired the page-posture widget's inline band-gap-band
|
|
20
|
+
* DOM in favor of a transparent spacer. Widget DOM shape changed;
|
|
21
|
+
* cached render frames from version 1 are incompatible.
|
|
22
|
+
* 3 — L7 Phase 2.5 Plan A. Adds `seedCachedGraph(graph, document)` to
|
|
23
|
+
* `LayoutEngineInstance` so prerender-cache consumers can seed the
|
|
24
|
+
* internal cachedGraph + cachedKey without triggering fullRebuild.
|
|
25
|
+
* Does not change geometry — but the public interface changed, so
|
|
26
|
+
* persisted envelopes MUST re-derive their cacheKey under 3.
|
|
27
|
+
*/
|
|
28
|
+
export const LAYOUT_ENGINE_VERSION = 3 as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serialization schema version for the LayCache payload (the cache envelope
|
|
32
|
+
* stored in IndexedDB, and — post Plan B — the customXml editor-state
|
|
33
|
+
* namespace). Bump independently of LAYOUT_ENGINE_VERSION when the
|
|
34
|
+
* envelope shape changes but the layout engine itself has not.
|
|
35
|
+
*
|
|
36
|
+
* History:
|
|
37
|
+
* 1 — initial envelope shape: { schemaVersion, engineVersion,
|
|
38
|
+
* fontFingerprint, structuralHash, graph, surface }. Ships with
|
|
39
|
+
* L7 Phase 2.5 Plan A.
|
|
40
|
+
*/
|
|
41
|
+
export const LAYCACHE_SCHEMA_VERSION = 1 as const;
|
|
@@ -564,6 +564,19 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
564
564
|
getDirtyFieldFamilies(): readonly string[];
|
|
565
565
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
566
566
|
|
|
567
|
+
// Viewport culling (L7 Phase 2) ----------------------------------------
|
|
568
|
+
/**
|
|
569
|
+
* Notifies the runtime that the visible block range changed. Delegates to
|
|
570
|
+
* `DocumentRuntime.setVisibleBlockRange`. Safe to call at any frequency;
|
|
571
|
+
* identical ranges are a no-op inside the runtime.
|
|
572
|
+
*/
|
|
573
|
+
setVisibleBlockRange(range: { start: number; end: number }): void;
|
|
574
|
+
/**
|
|
575
|
+
* Triggers a surface-only refresh applying the latest visible block range.
|
|
576
|
+
* Delegates to `DocumentRuntime.requestViewportRefresh`.
|
|
577
|
+
*/
|
|
578
|
+
requestViewportRefresh(): void;
|
|
579
|
+
|
|
567
580
|
// Events ---------------------------------------------------------------
|
|
568
581
|
subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
|
|
569
582
|
}
|
|
@@ -614,6 +627,14 @@ export interface CreateLayoutFacetInput {
|
|
|
614
627
|
| readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
|
|
615
628
|
| null
|
|
616
629
|
| undefined;
|
|
630
|
+
/**
|
|
631
|
+
* L7 Phase 2 — optional viewport culling hooks wired from the owning
|
|
632
|
+
* `DocumentRuntime`. When supplied, `facet.setVisibleBlockRange` /
|
|
633
|
+
* `facet.requestViewportRefresh` delegate directly to these; when omitted
|
|
634
|
+
* (e.g. tests, inert facet, standalone use) both methods are no-ops.
|
|
635
|
+
*/
|
|
636
|
+
setVisibleBlockRange?: (range: { start: number; end: number }) => void;
|
|
637
|
+
requestViewportRefresh?: () => void;
|
|
617
638
|
/**
|
|
618
639
|
* R3 — optional suggestions snapshot accessor. Used by
|
|
619
640
|
* `getAllScopeCardModels` to attach `SuggestionGroup` entries whose
|
|
@@ -1231,6 +1252,15 @@ export function createLayoutFacet(
|
|
|
1231
1252
|
};
|
|
1232
1253
|
},
|
|
1233
1254
|
|
|
1255
|
+
// L7 Phase 2 — viewport culling delegates
|
|
1256
|
+
setVisibleBlockRange(range) {
|
|
1257
|
+
input.setVisibleBlockRange?.(range);
|
|
1258
|
+
},
|
|
1259
|
+
|
|
1260
|
+
requestViewportRefresh() {
|
|
1261
|
+
input.requestViewportRefresh?.();
|
|
1262
|
+
},
|
|
1263
|
+
|
|
1234
1264
|
subscribe(listener) {
|
|
1235
1265
|
listeners.add(listener);
|
|
1236
1266
|
return () => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
|
+
import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* L7 Phase 2.5 Task 2.5.3 — prerender cache envelope shape.
|
|
6
|
+
*
|
|
7
|
+
* The envelope is the unit written to IndexedDB (Plan A) and — after Plan B
|
|
8
|
+
* ships — to the `laycache` customXml editor-state namespace. Two consumers
|
|
9
|
+
* must agree on this shape: the prerender pipeline that populates it, and
|
|
10
|
+
* the warm-path loader that rehydrates it.
|
|
11
|
+
*
|
|
12
|
+
* Load-time invariants checked by consumers before trusting the envelope:
|
|
13
|
+
* - `schemaVersion === LAYCACHE_SCHEMA_VERSION` — bump invalidates
|
|
14
|
+
* - `engineVersion === LAYOUT_ENGINE_VERSION` — bump invalidates
|
|
15
|
+
* - `graph.revision === 0` — canonical marker
|
|
16
|
+
*
|
|
17
|
+
* The envelope MUST be structured-clone-safe because IndexedDB and Plan B's
|
|
18
|
+
* customXml path both rely on structured-clone semantics. Keep fields as
|
|
19
|
+
* JSON-serializable primitives, plain objects, or arrays — no class
|
|
20
|
+
* instances, functions, or symbols.
|
|
21
|
+
*/
|
|
22
|
+
export interface CacheEnvelope {
|
|
23
|
+
readonly schemaVersion: number;
|
|
24
|
+
readonly engineVersion: number;
|
|
25
|
+
readonly fontFingerprint: string;
|
|
26
|
+
readonly structuralHash: string;
|
|
27
|
+
readonly graph: RuntimePageGraph;
|
|
28
|
+
readonly surface: EditorSurfaceSnapshot;
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.1 — prerender cache-key derivation.
|
|
3
|
+
*
|
|
4
|
+
* The cache key is the composite identity the IndexedDB (Plan A) and
|
|
5
|
+
* customXml (Plan B) backends index on. It has five inputs:
|
|
6
|
+
*
|
|
7
|
+
* 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
|
|
8
|
+
* Stable across text-only edits; changes on
|
|
9
|
+
* insert/delete/reorder because blockIds are
|
|
10
|
+
* kind-counter pairs (paragraph-5 stays 5
|
|
11
|
+
* under typing, shifts to paragraph-6 after
|
|
12
|
+
* an insert).
|
|
13
|
+
* 2. fontFingerprint — identifies the measurement-backend + font-
|
|
14
|
+
* metric source. "empirical-backend" in Plan A;
|
|
15
|
+
* a real font-derived string after Phase 8.
|
|
16
|
+
* 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
|
|
17
|
+
* layout/layout-engine-version.ts. Bumped by
|
|
18
|
+
* CI gate on any layout/render shape change.
|
|
19
|
+
* 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope format.
|
|
20
|
+
*
|
|
21
|
+
* Returns a 64-char lower-case hex digest. Uses the Web Crypto API
|
|
22
|
+
* (globalThis.crypto.subtle), available in Node 18+ and all target browsers —
|
|
23
|
+
* keeping src/** free of node: builtins so the shipped browser bundle stays
|
|
24
|
+
* clean.
|
|
25
|
+
*/
|
|
26
|
+
export interface CacheKeyBlock {
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
readonly blockId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CacheKeyInputs {
|
|
32
|
+
readonly blocks: readonly CacheKeyBlock[];
|
|
33
|
+
readonly fontFingerprint: string;
|
|
34
|
+
readonly engineVersion: string | number;
|
|
35
|
+
readonly schemaVersion: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BLOCK_SEPARATOR = "\u0000";
|
|
39
|
+
const FIELD_SEPARATOR = "|";
|
|
40
|
+
const textEncoder = new TextEncoder();
|
|
41
|
+
|
|
42
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
43
|
+
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
|
|
44
|
+
const bytes = new Uint8Array(digest);
|
|
45
|
+
let hex = "";
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
hex += bytes[i]!.toString(16).padStart(2, "0");
|
|
48
|
+
}
|
|
49
|
+
return hex;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function computeStructuralHash(blocks: readonly CacheKeyBlock[]): Promise<string> {
|
|
53
|
+
const payload = blocks.map((block) => `${block.kind}:${block.blockId}`).join(BLOCK_SEPARATOR);
|
|
54
|
+
return sha256Hex(payload);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function deriveCacheKey(inputs: CacheKeyInputs): Promise<string> {
|
|
58
|
+
const structuralHash = await computeStructuralHash(inputs.blocks);
|
|
59
|
+
const composite = [
|
|
60
|
+
structuralHash,
|
|
61
|
+
inputs.fontFingerprint,
|
|
62
|
+
String(inputs.engineVersion),
|
|
63
|
+
String(inputs.schemaVersion),
|
|
64
|
+
].join(FIELD_SEPARATOR);
|
|
65
|
+
return sha256Hex(composite);
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.1 — font fingerprint stub for the prerender cache.
|
|
3
|
+
*
|
|
4
|
+
* The cache key composition includes a font fingerprint so cache entries
|
|
5
|
+
* invalidate when the set of fonts available to the measurement backend
|
|
6
|
+
* changes. In Plan A the prerender pipeline forces the empirical
|
|
7
|
+
* measurement backend (see `createLayoutEngine({ autoUpgradeToCanvasBackend:
|
|
8
|
+
* false })`), which is deterministic and font-metric-agnostic — so the
|
|
9
|
+
* fingerprint is a constant literal.
|
|
10
|
+
*
|
|
11
|
+
* Phase 8 (font-metrics precompute via fontkit) replaces this stub with a
|
|
12
|
+
* real font-derived fingerprint that makes Plan B customXml caches portable
|
|
13
|
+
* across machines with different installed fonts.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveFontFingerprint(): string {
|
|
16
|
+
return "empirical-backend";
|
|
17
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RuntimeBlockFragment,
|
|
3
|
+
RuntimePageAnchor,
|
|
4
|
+
RuntimePageGraph,
|
|
5
|
+
RuntimePageNode,
|
|
6
|
+
RuntimePageRegion,
|
|
7
|
+
RuntimePageRegions,
|
|
8
|
+
} from "../layout/page-graph.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* L7 Phase 2.5 Task 2.5.3 — graph canonicalization (determinism fix).
|
|
12
|
+
*
|
|
13
|
+
* `src/runtime/layout/page-graph.ts` increments a module-level
|
|
14
|
+
* `graphRevision` counter on every `buildPageGraph()` call and embeds it
|
|
15
|
+
* into `pageId = page-${revision}-${index}` and dependent
|
|
16
|
+
* `fragmentId = ${pageId}-<suffix>` strings. Two consecutive
|
|
17
|
+
* `prerenderDocument(buf)` calls in the same process would therefore emit
|
|
18
|
+
* different pageIds — the cacheBlob hash would flip on every call even
|
|
19
|
+
* though the document bytes were identical.
|
|
20
|
+
*
|
|
21
|
+
* This module rewrites all identifier strings to a canonical revision-free
|
|
22
|
+
* shape:
|
|
23
|
+
* `page-${index}` (canonical pageId)
|
|
24
|
+
* `page-${index}-<suffix>` (canonical fragmentId)
|
|
25
|
+
*
|
|
26
|
+
* The live runtime's graphRevision contract stays intact — only the
|
|
27
|
+
* serialized cache envelope is canonicalized.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const PAGE_PREFIX = "page-";
|
|
31
|
+
|
|
32
|
+
export function canonicalizeGraph(graph: RuntimePageGraph): RuntimePageGraph {
|
|
33
|
+
// Build a map from live pageId → canonical pageId ("page-<index>").
|
|
34
|
+
const rename = new Map<string, string>();
|
|
35
|
+
for (const page of graph.pages) {
|
|
36
|
+
rename.set(page.pageId, `${PAGE_PREFIX}${page.pageIndex}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rewriteId = (id: string): string => {
|
|
40
|
+
// pageIds map directly.
|
|
41
|
+
const direct = rename.get(id);
|
|
42
|
+
if (direct !== undefined) return direct;
|
|
43
|
+
// fragmentIds have shape `${livePageId}-<suffix>`; find the matching
|
|
44
|
+
// live prefix and swap it for the canonical pageId.
|
|
45
|
+
for (const [livePageId, canonPageId] of rename) {
|
|
46
|
+
if (id.startsWith(`${livePageId}-`)) {
|
|
47
|
+
return `${canonPageId}${id.slice(livePageId.length)}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return id;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const canonicalPages: RuntimePageNode[] = graph.pages.map((page) => ({
|
|
54
|
+
...page,
|
|
55
|
+
pageId: rewriteId(page.pageId),
|
|
56
|
+
regions: rewriteRegions(page.regions, rewriteId),
|
|
57
|
+
lineBoxes: page.lineBoxes.map((line) => ({
|
|
58
|
+
...line,
|
|
59
|
+
fragmentId: rewriteId(line.fragmentId),
|
|
60
|
+
})),
|
|
61
|
+
noteAllocations: page.noteAllocations.map((note) =>
|
|
62
|
+
note.fragmentId === undefined
|
|
63
|
+
? note
|
|
64
|
+
: { ...note, fragmentId: rewriteId(note.fragmentId) },
|
|
65
|
+
),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const canonicalFragments: RuntimeBlockFragment[] = graph.fragments.map((fragment) => ({
|
|
69
|
+
...fragment,
|
|
70
|
+
fragmentId: rewriteId(fragment.fragmentId),
|
|
71
|
+
pageId: rewriteId(fragment.pageId),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const canonicalAnchors: RuntimePageAnchor[] = graph.anchors.map((anchor) => ({
|
|
75
|
+
...anchor,
|
|
76
|
+
pageId: rewriteId(anchor.pageId),
|
|
77
|
+
fragmentId: anchor.fragmentId === undefined ? undefined : rewriteId(anchor.fragmentId),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...graph,
|
|
82
|
+
revision: 0,
|
|
83
|
+
pages: canonicalPages,
|
|
84
|
+
fragments: canonicalFragments,
|
|
85
|
+
anchors: canonicalAnchors,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rewriteRegions(
|
|
90
|
+
regions: RuntimePageRegions,
|
|
91
|
+
rewriteId: (id: string) => string,
|
|
92
|
+
): RuntimePageRegions {
|
|
93
|
+
const result: {
|
|
94
|
+
body: RuntimePageRegion;
|
|
95
|
+
header?: RuntimePageRegion;
|
|
96
|
+
footer?: RuntimePageRegion;
|
|
97
|
+
columns?: RuntimePageRegion[];
|
|
98
|
+
footnotes?: RuntimePageRegion[];
|
|
99
|
+
} = {
|
|
100
|
+
body: rewriteRegion(regions.body, rewriteId),
|
|
101
|
+
};
|
|
102
|
+
if (regions.header !== undefined) result.header = rewriteRegion(regions.header, rewriteId);
|
|
103
|
+
if (regions.footer !== undefined) result.footer = rewriteRegion(regions.footer, rewriteId);
|
|
104
|
+
if (regions.columns !== undefined) {
|
|
105
|
+
result.columns = regions.columns.map((region) => rewriteRegion(region, rewriteId));
|
|
106
|
+
}
|
|
107
|
+
if (regions.footnotes !== undefined) {
|
|
108
|
+
result.footnotes = regions.footnotes.map((region) => rewriteRegion(region, rewriteId));
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function rewriteRegion(
|
|
114
|
+
region: RuntimePageRegion,
|
|
115
|
+
rewriteId: (id: string) => string,
|
|
116
|
+
): RuntimePageRegion {
|
|
117
|
+
return {
|
|
118
|
+
...region,
|
|
119
|
+
fragmentIds: region.fragmentIds.map(rewriteId),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.2 — IndexedDB cache backend (Plan A).
|
|
3
|
+
*
|
|
4
|
+
* Validation-stage storage for the prerender cache. Stores the cache
|
|
5
|
+
* envelope (see cache-envelope.ts in Task 2.5.3) keyed by the SHA-256
|
|
6
|
+
* cacheKey from cache-key.ts. LRU eviction caps the store at
|
|
7
|
+
* LAYCACHE_MAX_ENTRIES so the cache never unbounds.
|
|
8
|
+
*
|
|
9
|
+
* Uses the Web IndexedDB API directly — no idb wrapper, keeping this
|
|
10
|
+
* module dependency-free. Tests use `fake-indexeddb/auto` which installs
|
|
11
|
+
* a spec-compliant in-memory IDB on globalThis.
|
|
12
|
+
*
|
|
13
|
+
* Plan B (customXml persistence) will layer on top of this: customXml is
|
|
14
|
+
* consulted first, IndexedDB second. For Plan A, this is the only backend.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DB_NAME = "beyondwork-laycache";
|
|
18
|
+
const DB_VERSION = 1;
|
|
19
|
+
const STORE_NAME = "envelopes";
|
|
20
|
+
const ACCESSED_AT_INDEX = "accessedAt";
|
|
21
|
+
|
|
22
|
+
export const LAYCACHE_MAX_ENTRIES = 100;
|
|
23
|
+
|
|
24
|
+
interface CacheRecord<T> {
|
|
25
|
+
readonly cacheKey: string;
|
|
26
|
+
readonly envelope: T;
|
|
27
|
+
readonly accessedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Monotonic "logical timestamp" for LRU ordering. `Date.now()` returns the
|
|
34
|
+
* same millisecond for writes issued in rapid succession (common in tests
|
|
35
|
+
* and in our own LRU-eviction scan), which makes cursor-based eviction
|
|
36
|
+
* non-deterministic when entries collide. A process-lifetime counter
|
|
37
|
+
* produces strictly increasing values regardless of wall-clock resolution.
|
|
38
|
+
* Persisted across DB opens: the store's max stored `accessedAt` seeds the
|
|
39
|
+
* counter on first open so a reopen still respects prior recency order.
|
|
40
|
+
*/
|
|
41
|
+
let accessedAtCounter = 0;
|
|
42
|
+
|
|
43
|
+
function nextAccessedAt(): number {
|
|
44
|
+
accessedAtCounter += 1;
|
|
45
|
+
return accessedAtCounter;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requestIndexedDB(): IDBFactory {
|
|
49
|
+
const factory = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
|
|
50
|
+
if (!factory) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"indexeddb-cache: globalThis.indexedDB is undefined. In tests, import 'fake-indexeddb/auto'.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return factory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
request.onsuccess = () => resolve(request.result);
|
|
61
|
+
request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: request failed"));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function promisifyTransaction(tx: IDBTransaction): Promise<void> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
tx.oncomplete = () => resolve();
|
|
68
|
+
tx.onerror = () => reject(tx.error ?? new Error("indexeddb-cache: transaction failed"));
|
|
69
|
+
tx.onabort = () => reject(tx.error ?? new Error("indexeddb-cache: transaction aborted"));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function openDatabase(): Promise<IDBDatabase> {
|
|
74
|
+
if (dbPromise !== null) return dbPromise;
|
|
75
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
76
|
+
const request = requestIndexedDB().open(DB_NAME, DB_VERSION);
|
|
77
|
+
request.onupgradeneeded = () => {
|
|
78
|
+
const db = request.result;
|
|
79
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
80
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: "cacheKey" });
|
|
81
|
+
store.createIndex(ACCESSED_AT_INDEX, "accessedAt", { unique: false });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
request.onsuccess = () => {
|
|
85
|
+
const db = request.result;
|
|
86
|
+
void seedCounterFromStore(db).then(() => resolve(db));
|
|
87
|
+
};
|
|
88
|
+
request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: open failed"));
|
|
89
|
+
});
|
|
90
|
+
return dbPromise;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function seedCounterFromStore(db: IDBDatabase): Promise<void> {
|
|
94
|
+
// On first open, initialise the counter from the highest persisted
|
|
95
|
+
// `accessedAt`. On subsequent opens inside the same process we keep the
|
|
96
|
+
// existing counter — it's already ahead of anything in the store.
|
|
97
|
+
if (accessedAtCounter > 0) return;
|
|
98
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
99
|
+
const index = tx.objectStore(STORE_NAME).index(ACCESSED_AT_INDEX);
|
|
100
|
+
const request = index.openCursor(null, "prev");
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
request.onsuccess = () => {
|
|
103
|
+
const cursor = request.result;
|
|
104
|
+
if (cursor) {
|
|
105
|
+
const value = cursor.value as CacheRecord<unknown>;
|
|
106
|
+
if (typeof value.accessedAt === "number" && value.accessedAt > accessedAtCounter) {
|
|
107
|
+
accessedAtCounter = value.accessedAt;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
resolve();
|
|
111
|
+
};
|
|
112
|
+
request.onerror = () =>
|
|
113
|
+
reject(request.error ?? new Error("indexeddb-cache: seedCounterFromStore failed"));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function readCache<T>(cacheKey: string): Promise<T | null> {
|
|
118
|
+
const db = await openDatabase();
|
|
119
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
120
|
+
const store = tx.objectStore(STORE_NAME);
|
|
121
|
+
const existing = (await promisifyRequest(store.get(cacheKey))) as CacheRecord<T> | undefined;
|
|
122
|
+
if (!existing) {
|
|
123
|
+
await promisifyTransaction(tx);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const touched: CacheRecord<T> = { ...existing, accessedAt: nextAccessedAt() };
|
|
127
|
+
store.put(touched);
|
|
128
|
+
await promisifyTransaction(tx);
|
|
129
|
+
return existing.envelope;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function writeCache<T>(cacheKey: string, envelope: T): Promise<void> {
|
|
133
|
+
const db = await openDatabase();
|
|
134
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
135
|
+
const store = tx.objectStore(STORE_NAME);
|
|
136
|
+
const record: CacheRecord<T> = { cacheKey, envelope, accessedAt: nextAccessedAt() };
|
|
137
|
+
store.put(record);
|
|
138
|
+
const count = await promisifyRequest(store.count());
|
|
139
|
+
if (count > LAYCACHE_MAX_ENTRIES) {
|
|
140
|
+
const toDrop = count - LAYCACHE_MAX_ENTRIES;
|
|
141
|
+
await evictOldest(store, toDrop);
|
|
142
|
+
}
|
|
143
|
+
await promisifyTransaction(tx);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function evictOldest(store: IDBObjectStore, count: number): Promise<void> {
|
|
147
|
+
if (count <= 0) return;
|
|
148
|
+
const index = store.index(ACCESSED_AT_INDEX);
|
|
149
|
+
let remaining = count;
|
|
150
|
+
await new Promise<void>((resolve, reject) => {
|
|
151
|
+
const request = index.openCursor();
|
|
152
|
+
request.onsuccess = () => {
|
|
153
|
+
const cursor = request.result;
|
|
154
|
+
if (!cursor || remaining === 0) {
|
|
155
|
+
resolve();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
cursor.delete();
|
|
159
|
+
remaining -= 1;
|
|
160
|
+
cursor.continue();
|
|
161
|
+
};
|
|
162
|
+
request.onerror = () =>
|
|
163
|
+
reject(request.error ?? new Error("indexeddb-cache: eviction cursor failed"));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function clearCache(): Promise<void> {
|
|
168
|
+
const db = await openDatabase();
|
|
169
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
170
|
+
tx.objectStore(STORE_NAME).clear();
|
|
171
|
+
await promisifyTransaction(tx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Close the cached connection. Used by tests to tear down between cases so
|
|
176
|
+
* the singleton dbPromise does not leak state across runs. Production code
|
|
177
|
+
* can ignore this — the connection is process-scoped and lives until exit.
|
|
178
|
+
*/
|
|
179
|
+
export async function closeCacheDatabase(): Promise<void> {
|
|
180
|
+
if (dbPromise === null) return;
|
|
181
|
+
const db = await dbPromise;
|
|
182
|
+
db.close();
|
|
183
|
+
dbPromise = null;
|
|
184
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
|
+
import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
|
+
import { loadDocxEditorSessionAsync } from "../../io/docx-session.ts";
|
|
4
|
+
import { createLoadScheduler } from "../../io/load-scheduler.ts";
|
|
5
|
+
import {
|
|
6
|
+
LAYCACHE_SCHEMA_VERSION,
|
|
7
|
+
LAYOUT_ENGINE_VERSION,
|
|
8
|
+
} from "../layout/layout-engine-version.ts";
|
|
9
|
+
import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
|
|
10
|
+
import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
|
|
11
|
+
import type { CacheEnvelope } from "./cache-envelope.ts";
|
|
12
|
+
import {
|
|
13
|
+
computeStructuralHash,
|
|
14
|
+
deriveCacheKey,
|
|
15
|
+
type CacheKeyBlock,
|
|
16
|
+
} from "./cache-key.ts";
|
|
17
|
+
import { resolveFontFingerprint } from "./font-fingerprint.ts";
|
|
18
|
+
import { canonicalizeGraph } from "./graph-canonicalize.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* L7 Phase 2.5 Task 2.5.3 — prerenderDocument public API.
|
|
22
|
+
*
|
|
23
|
+
* Runs the parse + layout pipeline for a .docx buffer without mounting
|
|
24
|
+
* React, PM, or any DOM. Produces a CacheEnvelope suitable for writing to
|
|
25
|
+
* the IndexedDB backend (Plan A) or the `laycache` customXml namespace
|
|
26
|
+
* (Plan B, follow-up PR).
|
|
27
|
+
*
|
|
28
|
+
* Determinism contract:
|
|
29
|
+
* - Identical input bytes produce identical cacheKey on two sequential
|
|
30
|
+
* calls. Two sources of non-determinism are handled:
|
|
31
|
+
* (1) `page-graph.ts` graphRevision counter — stripped by
|
|
32
|
+
* `canonicalizeGraph`.
|
|
33
|
+
* (2) A fixed `documentId` ("prerender") is passed to the loader so
|
|
34
|
+
* `createCanonicalDocumentId` is deterministic. Timestamps in
|
|
35
|
+
* `canonicalDocument.createdAt/updatedAt` are loader-controlled
|
|
36
|
+
* but do not enter the envelope.
|
|
37
|
+
*
|
|
38
|
+
* Node-safety:
|
|
39
|
+
* - Uses `createLoadScheduler({ backendOverride: "sync" })` so the
|
|
40
|
+
* async loader runs without DOM scheduler.yield.
|
|
41
|
+
* - Layout engine is constructed with
|
|
42
|
+
* `autoUpgradeToCanvasBackend: false` so the empirical measurement
|
|
43
|
+
* backend is used (no Canvas2D, no node-canvas).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export interface PrerenderOptions {
|
|
47
|
+
readonly fontFingerprint?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Plan B hook — when true, prerenderDocument will inject the cache
|
|
50
|
+
* envelope into the document's `laycache` customXml namespace and
|
|
51
|
+
* return the re-serialized bytes in `docWithCustomXml`. In Plan A
|
|
52
|
+
* (this task) the flag is accepted for API stability but ignored;
|
|
53
|
+
* `docWithCustomXml` returns the input unchanged.
|
|
54
|
+
*/
|
|
55
|
+
readonly persistToCustomXml?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PrerenderCounters {
|
|
59
|
+
readonly blockCount: number;
|
|
60
|
+
readonly pageCount: number;
|
|
61
|
+
readonly prerenderMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PrerenderResult {
|
|
65
|
+
readonly docWithCustomXml: Uint8Array;
|
|
66
|
+
readonly cacheBlob: CacheEnvelope;
|
|
67
|
+
readonly cacheKey: string;
|
|
68
|
+
readonly counters: PrerenderCounters;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PRERENDER_DOCUMENT_ID = "prerender";
|
|
72
|
+
|
|
73
|
+
function toUint8Array(input: ArrayBuffer | Uint8Array): Uint8Array {
|
|
74
|
+
if (input instanceof Uint8Array) return input;
|
|
75
|
+
return new Uint8Array(input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function blocksToCacheKeyBlocks(surface: EditorSurfaceSnapshot): CacheKeyBlock[] {
|
|
79
|
+
return surface.blocks.map((block) => ({ kind: block.kind, blockId: block.blockId }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function prerenderDocument(
|
|
83
|
+
input: ArrayBuffer | Uint8Array,
|
|
84
|
+
options: PrerenderOptions = {},
|
|
85
|
+
): Promise<PrerenderResult> {
|
|
86
|
+
const t0 = performance.now();
|
|
87
|
+
const bytes = toUint8Array(input);
|
|
88
|
+
const fontFingerprint = options.fontFingerprint ?? resolveFontFingerprint();
|
|
89
|
+
|
|
90
|
+
const scheduler = createLoadScheduler({ backendOverride: "sync" });
|
|
91
|
+
let session;
|
|
92
|
+
try {
|
|
93
|
+
session = await loadDocxEditorSessionAsync({
|
|
94
|
+
documentId: PRERENDER_DOCUMENT_ID,
|
|
95
|
+
bytes,
|
|
96
|
+
editorBuild: "prerender",
|
|
97
|
+
scheduler,
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
scheduler.dispose();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (session.fatalError) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`prerenderDocument: failed to load input — ${session.fatalError.message ?? "fatal error"}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const envelope = session.initialSessionState.canonicalDocument;
|
|
110
|
+
const surface = createEditorSurfaceSnapshot(envelope, createSelectionSnapshot(), { kind: "main" });
|
|
111
|
+
|
|
112
|
+
const engine = createLayoutEngine({ autoUpgradeToCanvasBackend: false });
|
|
113
|
+
const rawGraph = engine.getPageGraph({ document: envelope });
|
|
114
|
+
const graph = canonicalizeGraph(rawGraph);
|
|
115
|
+
|
|
116
|
+
const structuralHash = await computeStructuralHash(blocksToCacheKeyBlocks(surface));
|
|
117
|
+
const cacheKey = await deriveCacheKey({
|
|
118
|
+
blocks: blocksToCacheKeyBlocks(surface),
|
|
119
|
+
fontFingerprint,
|
|
120
|
+
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
121
|
+
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const cacheBlob: CacheEnvelope = {
|
|
125
|
+
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
126
|
+
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
127
|
+
fontFingerprint,
|
|
128
|
+
structuralHash,
|
|
129
|
+
graph,
|
|
130
|
+
surface,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const prerenderMs = performance.now() - t0;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
docWithCustomXml: bytes,
|
|
137
|
+
cacheBlob,
|
|
138
|
+
cacheKey,
|
|
139
|
+
counters: {
|
|
140
|
+
blockCount: surface.blocks.length,
|
|
141
|
+
pageCount: graph.pages.length,
|
|
142
|
+
prerenderMs,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* emitted by `src/runtime/surface-projection.ts`:
|
|
11
11
|
* - `paragraph-*` → "paragraph"
|
|
12
12
|
* - `table-*` → "table"
|
|
13
|
+
* - `placeholder-culled-*` → "opaque" (viewport-culled size-preserving stub)
|
|
13
14
|
* - `opaque-*` → "opaque"
|
|
14
15
|
* - `section-break-*` → "opaque" (read-only boundary marker)
|
|
15
16
|
* - `sdt-*`, `sdt-wrapper-*` → "opaque"
|
|
@@ -25,6 +26,7 @@ import type { RenderBlock } from "./render-frame-types.ts";
|
|
|
25
26
|
export function classifyBlockKind(blockId: string): RenderBlock["kind"] {
|
|
26
27
|
if (blockId.startsWith("paragraph")) return "paragraph";
|
|
27
28
|
if (blockId.startsWith("table")) return "table";
|
|
29
|
+
if (blockId.startsWith("placeholder-culled-")) return "opaque";
|
|
28
30
|
if (blockId.startsWith("opaque")) return "opaque";
|
|
29
31
|
if (blockId.startsWith("section-break")) return "opaque";
|
|
30
32
|
if (blockId.startsWith("sdt")) return "opaque";
|