@beyondwork/docx-react-component 1.0.42 → 1.0.45
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 +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- 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/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -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/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- 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-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -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/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -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";
|
|
@@ -56,6 +56,23 @@ export interface RenderPageRegions {
|
|
|
56
56
|
footer?: RenderStoryRegion;
|
|
57
57
|
columns?: readonly RenderStoryRegion[];
|
|
58
58
|
footnoteArea?: RenderStoryRegion;
|
|
59
|
+
/**
|
|
60
|
+
* P8.3 — footnote areas reserved at the bottom of the page (above the
|
|
61
|
+
* footer band). Mirrors the page graph's `RuntimePageRegions.footnotes`
|
|
62
|
+
* — one entry per allocated region (typically one per page today, but
|
|
63
|
+
* shape allows for multiple should allocation-splitting land).
|
|
64
|
+
* Populated only when `PublicPageRegions.footnotes` is non-empty.
|
|
65
|
+
* Additive — back-compat safe; consumers that ignore this field see no
|
|
66
|
+
* change.
|
|
67
|
+
*/
|
|
68
|
+
footnotes?: readonly RenderStoryRegion[];
|
|
69
|
+
/**
|
|
70
|
+
* P8.3 — endnote regions. Reserved seam; per-page endnote projection is
|
|
71
|
+
* NOT populated today (endnotes use document-end placement via
|
|
72
|
+
* `facet.getDocumentEndnoteBlocks()`). Shape exists so a future
|
|
73
|
+
* per-section endnote renderer has a stable read surface.
|
|
74
|
+
*/
|
|
75
|
+
endnotes?: readonly RenderStoryRegion[];
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
export interface RenderStoryRegion {
|