@beyondwork/docx-react-component 1.0.43 → 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 +44 -32
- package/src/api/public-types.ts +139 -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 +316 -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/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
|
@@ -116,12 +116,61 @@ export function canCreateDocxCommentAnchor(
|
|
|
116
116
|
return false;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
return
|
|
119
|
+
return rangeStaysWithinCommentableStory(content, normalized);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function rangeStaysWithinCommentableStory(
|
|
123
|
+
content: unknown,
|
|
124
|
+
range: DocRange,
|
|
125
|
+
): boolean {
|
|
126
|
+
const normalized = normalizeRange(range);
|
|
127
|
+
if (normalized.from === normalized.to) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const surfaceBlocks = readSurfaceBlocks(content);
|
|
132
|
+
if (surfaceBlocks) {
|
|
133
|
+
const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.from, "start");
|
|
134
|
+
const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.to, "end");
|
|
135
|
+
if (!fromOwner || !toOwner) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (fromOwner.tableId !== toOwner.tableId) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (fromOwner.tableCellId !== toOwner.tableCellId) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return !rangeCrossesOpaqueOrTableBoundary(
|
|
145
|
+
surfaceBlocks,
|
|
146
|
+
normalized,
|
|
147
|
+
fromOwner,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const story = parseTextStory(content);
|
|
152
|
+
const upperBound = Math.min(normalized.to, story.units.length);
|
|
153
|
+
for (let index = Math.max(0, normalized.from); index < upperBound; index += 1) {
|
|
154
|
+
const unit = story.units[index];
|
|
155
|
+
if (!unit) continue;
|
|
156
|
+
if (unit.kind === "opaque_block") {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface FlattenedSurfaceBlock {
|
|
164
|
+
kind: string;
|
|
165
|
+
from: number;
|
|
166
|
+
to: number;
|
|
167
|
+
tableId: string | null;
|
|
168
|
+
tableCellId: string | null;
|
|
120
169
|
}
|
|
121
170
|
|
|
122
171
|
function readSurfaceBlocks(
|
|
123
172
|
content: unknown,
|
|
124
|
-
):
|
|
173
|
+
): FlattenedSurfaceBlock[] | undefined {
|
|
125
174
|
if (!content || typeof content !== "object" || !("blocks" in content)) {
|
|
126
175
|
return undefined;
|
|
127
176
|
}
|
|
@@ -131,17 +180,19 @@ function readSurfaceBlocks(
|
|
|
131
180
|
return undefined;
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
const normalized = flattenSurfaceBlocks(blocks);
|
|
183
|
+
const normalized = flattenSurfaceBlocks(blocks, null, null);
|
|
135
184
|
|
|
136
185
|
return normalized.length > 0 ? normalized : undefined;
|
|
137
186
|
}
|
|
138
187
|
|
|
139
188
|
function flattenSurfaceBlocks(
|
|
140
189
|
blocks: unknown[],
|
|
141
|
-
|
|
142
|
-
|
|
190
|
+
tableId: string | null,
|
|
191
|
+
tableCellId: string | null,
|
|
192
|
+
): FlattenedSurfaceBlock[] {
|
|
193
|
+
const flattened: FlattenedSurfaceBlock[] = [];
|
|
143
194
|
|
|
144
|
-
for (const block of blocks) {
|
|
195
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
145
196
|
if (
|
|
146
197
|
!block ||
|
|
147
198
|
typeof block !== "object" ||
|
|
@@ -152,32 +203,91 @@ function flattenSurfaceBlocks(
|
|
|
152
203
|
continue;
|
|
153
204
|
}
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
to: (block as { to: number }).to,
|
|
159
|
-
});
|
|
206
|
+
const kind = (block as { kind: string }).kind;
|
|
207
|
+
const from = (block as { from: number }).from;
|
|
208
|
+
const to = (block as { to: number }).to;
|
|
160
209
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
210
|
+
flattened.push({ kind, from, to, tableId, tableCellId });
|
|
211
|
+
|
|
212
|
+
if (kind === "table" && Array.isArray((block as { rows?: unknown }).rows)) {
|
|
213
|
+
const nextTableId =
|
|
214
|
+
(block as { blockId?: string }).blockId ?? `table-${from}-${to}-${blockIndex}`;
|
|
215
|
+
const rows = (block as { rows: Array<{ cells?: unknown[] }> }).rows;
|
|
216
|
+
for (const [rowIdx, row] of rows.entries()) {
|
|
217
|
+
for (const [cellIdx, cell] of (row.cells ?? []).entries()) {
|
|
167
218
|
if (cell && typeof cell === "object" && Array.isArray((cell as { content?: unknown[] }).content)) {
|
|
168
|
-
|
|
219
|
+
const cellFingerprint = `${nextTableId}-r${rowIdx}-c${cellIdx}`;
|
|
220
|
+
flattened.push(
|
|
221
|
+
...flattenSurfaceBlocks(
|
|
222
|
+
(cell as { content: unknown[] }).content,
|
|
223
|
+
nextTableId,
|
|
224
|
+
cellFingerprint,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
169
227
|
}
|
|
170
228
|
}
|
|
171
229
|
}
|
|
172
230
|
}
|
|
173
231
|
|
|
174
232
|
if (
|
|
175
|
-
|
|
176
|
-
Array.isArray((block as { children?: unknown
|
|
233
|
+
kind === "sdt_block" &&
|
|
234
|
+
Array.isArray((block as { children?: unknown }).children)
|
|
177
235
|
) {
|
|
178
|
-
flattened.push(
|
|
236
|
+
flattened.push(
|
|
237
|
+
...flattenSurfaceBlocks(
|
|
238
|
+
(block as { children: unknown[] }).children,
|
|
239
|
+
tableId,
|
|
240
|
+
tableCellId,
|
|
241
|
+
),
|
|
242
|
+
);
|
|
179
243
|
}
|
|
180
244
|
}
|
|
181
245
|
|
|
182
246
|
return flattened;
|
|
183
247
|
}
|
|
248
|
+
|
|
249
|
+
function findContainingParagraphForEndpoint(
|
|
250
|
+
blocks: readonly FlattenedSurfaceBlock[],
|
|
251
|
+
offset: number,
|
|
252
|
+
kind: "start" | "end",
|
|
253
|
+
): FlattenedSurfaceBlock | null {
|
|
254
|
+
const matches = blocks.filter(
|
|
255
|
+
(block) =>
|
|
256
|
+
block.kind === "paragraph" && offset >= block.from && offset <= block.to,
|
|
257
|
+
);
|
|
258
|
+
if (matches.length === 0) return null;
|
|
259
|
+
if (matches.length === 1) return matches[0]!;
|
|
260
|
+
// When an offset sits exactly on a paragraph boundary it matches the
|
|
261
|
+
// trailing paragraph as well as the leading one. For a start endpoint we
|
|
262
|
+
// prefer the later paragraph (the range extends forward from it); for an
|
|
263
|
+
// end endpoint we prefer the earlier paragraph (the range ends there).
|
|
264
|
+
if (kind === "start") {
|
|
265
|
+
return matches.reduce((a, b) => (b.from >= a.from ? b : a));
|
|
266
|
+
}
|
|
267
|
+
return matches.reduce((a, b) => (b.to <= a.to ? b : a));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function rangeCrossesOpaqueOrTableBoundary(
|
|
271
|
+
blocks: readonly FlattenedSurfaceBlock[],
|
|
272
|
+
range: DocRange,
|
|
273
|
+
origin: FlattenedSurfaceBlock,
|
|
274
|
+
): boolean {
|
|
275
|
+
for (const block of blocks) {
|
|
276
|
+
const overlapFrom = Math.max(block.from, range.from);
|
|
277
|
+
const overlapTo = Math.min(block.to, range.to);
|
|
278
|
+
if (overlapTo <= overlapFrom) continue;
|
|
279
|
+
|
|
280
|
+
if (block.kind === "opaque_block") {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
block.kind === "paragraph" &&
|
|
286
|
+
(block.tableId !== origin.tableId ||
|
|
287
|
+
block.tableCellId !== origin.tableCellId)
|
|
288
|
+
) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { ISSUE_METADATA_ID, REVIEW_ACTION_METADATA_ID } from "./api/public-types
|
|
|
13
13
|
// P17 — metadata persistence error class.
|
|
14
14
|
export { MetadataResolverMissingError } from "./api/public-types.ts";
|
|
15
15
|
|
|
16
|
-
// Collab substrate (P1 – P8f + P14). See docs/plans/collab-
|
|
16
|
+
// Collab substrate (P1 – P8f + P14). See docs/plans/lane-4-collab-clm-vallor.md
|
|
17
17
|
// for the shipped-slice table. Surfaces are stable for host integration;
|
|
18
18
|
// the chrome preset + markdown renderer land in P9 / P10.
|
|
19
19
|
export { createCollabSession } from "./runtime/collab-session.ts";
|
|
@@ -121,6 +121,7 @@ export type {
|
|
|
121
121
|
LoadSourcePolicy,
|
|
122
122
|
EditorSessionState,
|
|
123
123
|
EditorHostAdapter,
|
|
124
|
+
ChartPreviewResolveParams,
|
|
124
125
|
WordReviewEditorProps,
|
|
125
126
|
WordReviewEditorChromePreset,
|
|
126
127
|
WordReviewEditorChromeOptions,
|
|
@@ -185,6 +186,8 @@ export type {
|
|
|
185
186
|
SnapshotRefreshChangeKind,
|
|
186
187
|
SnapshotRefreshHints,
|
|
187
188
|
AddCommentParams,
|
|
189
|
+
AddCommentResult,
|
|
190
|
+
AddCommentReplyResult,
|
|
188
191
|
ExportDocxOptions,
|
|
189
192
|
ExportResult,
|
|
190
193
|
AutosaveConfig,
|
|
@@ -281,3 +284,15 @@ export type {
|
|
|
281
284
|
EditorStatePartLoadFailure,
|
|
282
285
|
EditorStatePartPersistFailure,
|
|
283
286
|
} from "./api/public-types.ts";
|
|
287
|
+
|
|
288
|
+
// L7 Phase 2.5 — prerender cache public API. Platforms / ingest workers
|
|
289
|
+
// call `prerenderDocument(buffer)` on template upload to populate the
|
|
290
|
+
// cache; end-user opens read the cache and skip the layout pass. See
|
|
291
|
+
// docs/plans/lane-2-render-performance.md §Phase 2.5.
|
|
292
|
+
export { prerenderDocument } from "./runtime/prerender/prerender-document.ts";
|
|
293
|
+
export type {
|
|
294
|
+
PrerenderOptions,
|
|
295
|
+
PrerenderResult,
|
|
296
|
+
PrerenderCounters,
|
|
297
|
+
} from "./runtime/prerender/prerender-document.ts";
|
|
298
|
+
export type { CacheEnvelope } from "./runtime/prerender/cache-envelope.ts";
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 0B.1 — chart preview resolver.
|
|
3
|
+
*
|
|
4
|
+
* Given a freshly imported `CanonicalDocument` and the source
|
|
5
|
+
* `OpcPackage`, iterate every `chart_preview` inline node whose
|
|
6
|
+
* `previewMediaId` is unset, extract the chart part XML + theme, and
|
|
7
|
+
* ask the host adapter's `renderChartPreview` callback for preview
|
|
8
|
+
* bytes. Successful returns become synthesized `MediaItem` entries on
|
|
9
|
+
* the document; unresolved charts keep their existing shape so
|
|
10
|
+
* downstream rendering falls back to the Stage 0 typed badge.
|
|
11
|
+
*
|
|
12
|
+
* Pure Node-side. No React imports. Not reachable from the edit path
|
|
13
|
+
* (runs at import time only) so §Performance Invariants in CLAUDE.md
|
|
14
|
+
* about synchronous layout reads do not apply.
|
|
15
|
+
*
|
|
16
|
+
* See docs/plans/lane-5-charts.md §2 Task 2.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
BlockNode,
|
|
21
|
+
CanonicalDocument,
|
|
22
|
+
ChartPreviewNode,
|
|
23
|
+
DocumentRootNode,
|
|
24
|
+
InlineNode,
|
|
25
|
+
MediaItem,
|
|
26
|
+
ParagraphNode,
|
|
27
|
+
} from "../model/canonical-document.ts";
|
|
28
|
+
import type {
|
|
29
|
+
ChartPreviewResolveParams,
|
|
30
|
+
EditorHostAdapter,
|
|
31
|
+
} from "../api/public-types.ts";
|
|
32
|
+
import type { OpcPackage } from "./opc/package-reader.ts";
|
|
33
|
+
import { normalizePartPath, resolveRelationshipTarget } from "./ooxml/part-manifest.ts";
|
|
34
|
+
|
|
35
|
+
interface ResolveContext {
|
|
36
|
+
readonly package: OpcPackage;
|
|
37
|
+
readonly adapter: EditorHostAdapter;
|
|
38
|
+
readonly themeXml: string | undefined;
|
|
39
|
+
/** Monotonic counter so we can generate unique media ids across one run. */
|
|
40
|
+
seq: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PendingResolution {
|
|
44
|
+
/** Path from the document root to the chart_preview node, used to surgically replace the node on resolution. */
|
|
45
|
+
readonly pointer: Pointer;
|
|
46
|
+
readonly node: ChartPreviewNode;
|
|
47
|
+
readonly chartPartPath: string;
|
|
48
|
+
readonly chartXml: string;
|
|
49
|
+
readonly widthEmu: number;
|
|
50
|
+
readonly heightEmu: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Pointer = Array<number>;
|
|
54
|
+
|
|
55
|
+
export async function resolveChartPreviewsForDocument(
|
|
56
|
+
doc: CanonicalDocument,
|
|
57
|
+
pkg: OpcPackage,
|
|
58
|
+
adapter: EditorHostAdapter | undefined,
|
|
59
|
+
): Promise<CanonicalDocument> {
|
|
60
|
+
if (!adapter?.renderChartPreview) return doc;
|
|
61
|
+
|
|
62
|
+
const pending = collectUnresolvedChartPreviews(doc, pkg);
|
|
63
|
+
if (pending.length === 0) return doc;
|
|
64
|
+
|
|
65
|
+
const themeXml = extractPartTextFromPackage(pkg, "/word/theme/theme1.xml");
|
|
66
|
+
const renderer = adapter.renderChartPreview;
|
|
67
|
+
const ctx: ResolveContext = { package: pkg, adapter, themeXml, seq: 0 };
|
|
68
|
+
|
|
69
|
+
const resolutions = await Promise.all(
|
|
70
|
+
pending.map(async (entry) => {
|
|
71
|
+
const params: ChartPreviewResolveParams = {
|
|
72
|
+
chartXml: entry.chartXml,
|
|
73
|
+
chartPartPath: entry.chartPartPath,
|
|
74
|
+
themeXml,
|
|
75
|
+
widthEmu: entry.widthEmu,
|
|
76
|
+
heightEmu: entry.heightEmu,
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
const bytes = await renderer(params);
|
|
80
|
+
if (!bytes || bytes.length === 0) return null;
|
|
81
|
+
return { entry, bytes };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
try {
|
|
84
|
+
adapter.logEvent?.({
|
|
85
|
+
type: "chart_preview_render_failed",
|
|
86
|
+
documentId: doc.docId,
|
|
87
|
+
detail: {
|
|
88
|
+
chartPartPath: entry.chartPartPath,
|
|
89
|
+
message: err instanceof Error ? err.message : String(err),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
// Host's logEvent can throw too; swallow silently — one chart failing
|
|
94
|
+
// must never surface as an unhandled rejection during import.
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const successful = resolutions.filter(
|
|
102
|
+
(r): r is { entry: PendingResolution; bytes: Uint8Array } => r !== null,
|
|
103
|
+
);
|
|
104
|
+
if (successful.length === 0) return doc;
|
|
105
|
+
|
|
106
|
+
void ctx; // resolveContext is reserved for future use (caching, progress events)
|
|
107
|
+
return applyResolutions(doc, successful);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Walk the whole content tree and yield one PendingResolution per
|
|
112
|
+
* chart_preview node that (a) has no previewMediaId yet and (b) can
|
|
113
|
+
* resolve its chart part from the package. The pointer path lets
|
|
114
|
+
* `applyResolutions` rewrite those exact nodes without touching the
|
|
115
|
+
* surrounding structure.
|
|
116
|
+
*/
|
|
117
|
+
function collectUnresolvedChartPreviews(doc: CanonicalDocument, pkg: OpcPackage): PendingResolution[] {
|
|
118
|
+
const out: PendingResolution[] = [];
|
|
119
|
+
const documentRels = collectDocumentPartRelationships(pkg);
|
|
120
|
+
|
|
121
|
+
const paragraphs = doc.content.children;
|
|
122
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
123
|
+
const block = paragraphs[i]!;
|
|
124
|
+
if (block.type !== "paragraph") continue;
|
|
125
|
+
const paragraph = block as ParagraphNode;
|
|
126
|
+
for (let j = 0; j < paragraph.children.length; j++) {
|
|
127
|
+
const child = paragraph.children[j];
|
|
128
|
+
if (!child || child.type !== "chart_preview") continue;
|
|
129
|
+
const chartNode = child as ChartPreviewNode;
|
|
130
|
+
if (chartNode.previewMediaId) continue;
|
|
131
|
+
const resolved = resolveChartPart(chartNode, documentRels, pkg);
|
|
132
|
+
if (!resolved) continue;
|
|
133
|
+
out.push({
|
|
134
|
+
pointer: [i, j],
|
|
135
|
+
node: chartNode,
|
|
136
|
+
chartPartPath: resolved.chartPartPath,
|
|
137
|
+
chartXml: resolved.chartXml,
|
|
138
|
+
widthEmu: resolved.widthEmu,
|
|
139
|
+
heightEmu: resolved.heightEmu,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function collectDocumentPartRelationships(pkg: OpcPackage): Map<string, string> {
|
|
147
|
+
// Only walk /word/document.xml for Stage 0B.1; headers/footers/endnotes
|
|
148
|
+
// carry charts rarely and will be a later iteration.
|
|
149
|
+
const docPart = pkg.parts.get("/word/document.xml");
|
|
150
|
+
if (!docPart?.relationships) return new Map();
|
|
151
|
+
const map = new Map<string, string>();
|
|
152
|
+
for (const rel of docPart.relationships) {
|
|
153
|
+
if (rel.targetMode !== "internal") continue;
|
|
154
|
+
if (!rel.type.endsWith("/chart")) continue;
|
|
155
|
+
const target = resolveRelationshipTarget("/word/document.xml", rel);
|
|
156
|
+
map.set(rel.id, normalizePartPath(target));
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ResolvedChartLocation {
|
|
162
|
+
chartPartPath: string;
|
|
163
|
+
chartXml: string;
|
|
164
|
+
widthEmu: number;
|
|
165
|
+
heightEmu: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveChartPart(
|
|
169
|
+
node: ChartPreviewNode,
|
|
170
|
+
documentRels: Map<string, string>,
|
|
171
|
+
pkg: OpcPackage,
|
|
172
|
+
): ResolvedChartLocation | null {
|
|
173
|
+
const relId = extractChartRelId(node.rawXml);
|
|
174
|
+
if (!relId) return null;
|
|
175
|
+
const chartPartPath = documentRels.get(relId);
|
|
176
|
+
if (!chartPartPath) return null;
|
|
177
|
+
const chartXml = extractPartTextFromPackage(pkg, chartPartPath);
|
|
178
|
+
if (!chartXml) return null;
|
|
179
|
+
const extent = extractDrawingExtent(node.rawXml);
|
|
180
|
+
return {
|
|
181
|
+
chartPartPath,
|
|
182
|
+
chartXml,
|
|
183
|
+
widthEmu: extent.widthEmu,
|
|
184
|
+
heightEmu: extent.heightEmu,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Find `<c:chart ... r:id="rIdN"/>` and return the relationship id. */
|
|
189
|
+
function extractChartRelId(rawXml: string): string | null {
|
|
190
|
+
const match = /<c:chart\b[^>]*?r:id="([^"]+)"/.exec(rawXml);
|
|
191
|
+
return match ? match[1]! : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read `<wp:extent cx="..." cy="..."/>` out of the drawing XML. */
|
|
195
|
+
function extractDrawingExtent(rawXml: string): { widthEmu: number; heightEmu: number } {
|
|
196
|
+
const match = /<wp:extent\b([^/>]*)\/>/.exec(rawXml);
|
|
197
|
+
if (!match) return { widthEmu: 5486400, heightEmu: 3200400 };
|
|
198
|
+
const attrs = match[1] ?? "";
|
|
199
|
+
const cx = /\bcx="(\d+)"/.exec(attrs)?.[1];
|
|
200
|
+
const cy = /\bcy="(\d+)"/.exec(attrs)?.[1];
|
|
201
|
+
return {
|
|
202
|
+
widthEmu: cx ? parseInt(cx, 10) : 5486400,
|
|
203
|
+
heightEmu: cy ? parseInt(cy, 10) : 3200400,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function extractPartTextFromPackage(pkg: OpcPackage, path: string): string | undefined {
|
|
208
|
+
const part = pkg.parts.get(path);
|
|
209
|
+
if (!part?.bytes) return undefined;
|
|
210
|
+
try {
|
|
211
|
+
return new TextDecoder("utf-8").decode(part.bytes);
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Produce a new CanonicalDocument with the resolved chart_preview
|
|
219
|
+
* nodes carrying previewMediaId + corresponding MediaCatalog entries.
|
|
220
|
+
* Never mutates the input doc.
|
|
221
|
+
*/
|
|
222
|
+
function applyResolutions(
|
|
223
|
+
doc: CanonicalDocument,
|
|
224
|
+
resolutions: Array<{ entry: PendingResolution; bytes: Uint8Array }>,
|
|
225
|
+
): CanonicalDocument {
|
|
226
|
+
const newMediaItems: Record<string, MediaItem> = { ...doc.media.items };
|
|
227
|
+
const updates = new Map<string, { previewMediaId: string }>();
|
|
228
|
+
|
|
229
|
+
let seq = 0;
|
|
230
|
+
for (const { entry, bytes } of resolutions) {
|
|
231
|
+
seq += 1;
|
|
232
|
+
const contentType = inferPreviewContentType(bytes);
|
|
233
|
+
const extension = contentType === "image/png" ? "png" : "svg";
|
|
234
|
+
const packagePartName = `/word/media/chart-preview-synth-${seq}.${extension}`;
|
|
235
|
+
const mediaId = `media:word/media/chart-preview-synth-${seq}.${extension}`;
|
|
236
|
+
newMediaItems[mediaId] = {
|
|
237
|
+
mediaId,
|
|
238
|
+
contentType,
|
|
239
|
+
filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1),
|
|
240
|
+
packagePartName,
|
|
241
|
+
widthEmu: entry.widthEmu,
|
|
242
|
+
heightEmu: entry.heightEmu,
|
|
243
|
+
};
|
|
244
|
+
const pointerKey = entry.pointer.join(",");
|
|
245
|
+
updates.set(pointerKey, { previewMediaId: mediaId });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (updates.size === 0) return doc;
|
|
249
|
+
|
|
250
|
+
// Clone the content tree along pointer paths only — everything else
|
|
251
|
+
// keeps object identity so downstream React memoization stays stable.
|
|
252
|
+
const newParagraphs: BlockNode[] = doc.content.children.slice();
|
|
253
|
+
for (const [pointerKey, update] of updates) {
|
|
254
|
+
const [pi, ci] = pointerKey.split(",").map((s) => parseInt(s, 10)) as [number, number];
|
|
255
|
+
const paragraph = newParagraphs[pi];
|
|
256
|
+
if (!paragraph || paragraph.type !== "paragraph") continue;
|
|
257
|
+
const newChildren: InlineNode[] = (paragraph as ParagraphNode).children.slice();
|
|
258
|
+
const existing = newChildren[ci];
|
|
259
|
+
if (!existing || existing.type !== "chart_preview") continue;
|
|
260
|
+
newChildren[ci] = { ...(existing as ChartPreviewNode), previewMediaId: update.previewMediaId };
|
|
261
|
+
newParagraphs[pi] = { ...(paragraph as ParagraphNode), children: newChildren };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const newContent: DocumentRootNode = { ...doc.content, children: newParagraphs };
|
|
265
|
+
const newMedia = { items: newMediaItems };
|
|
266
|
+
return { ...doc, content: newContent, media: newMedia };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Content-type sniff from the first bytes of the rendered preview.
|
|
271
|
+
* PNG magic is 0x89 0x50 0x4E 0x47; everything else is assumed to be
|
|
272
|
+
* SVG text. Hosts that need a different content type should extend
|
|
273
|
+
* this in a follow-up.
|
|
274
|
+
*/
|
|
275
|
+
function inferPreviewContentType(bytes: Uint8Array): string {
|
|
276
|
+
if (bytes.length >= 4 &&
|
|
277
|
+
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
278
|
+
return "image/png";
|
|
279
|
+
}
|
|
280
|
+
return "image/svg+xml";
|
|
281
|
+
}
|
package/src/io/docx-session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CompatibilityReport as PublicCompatibilityReport,
|
|
3
3
|
EditorError,
|
|
4
|
+
EditorHostAdapter,
|
|
4
5
|
EditorSessionState,
|
|
5
6
|
EditorWarning as PublicEditorWarning,
|
|
6
7
|
EditorAnchorProjection as PublicEditorAnchorProjection,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
normalizeParsedTextDocument,
|
|
44
45
|
normalizeParsedTextDocumentAsync,
|
|
45
46
|
} from "./normalize/normalize-text.ts";
|
|
47
|
+
import { resolveChartPreviewsForDocument } from "./chart-preview-resolver.ts";
|
|
46
48
|
import { type LoadScheduler } from "./load-scheduler.ts";
|
|
47
49
|
import {
|
|
48
50
|
CONTENT_TYPES_PATH,
|
|
@@ -228,6 +230,15 @@ interface LoadDocxEditorSessionOptions {
|
|
|
228
230
|
* change.
|
|
229
231
|
*/
|
|
230
232
|
onLoadStage?: (stage: import("../api/public-types.ts").LoadStage, durationMs: number) => void;
|
|
233
|
+
/**
|
|
234
|
+
* Stage 0B.1: host-adapter surface. The sync loader accepts the option for
|
|
235
|
+
* API symmetry with {@link LoadDocxEditorSessionAsyncOptions} but does not
|
|
236
|
+
* invoke `renderChartPreview` — chart-preview synthesis is asynchronous, so
|
|
237
|
+
* hosts that want preview bitmaps must call {@link loadDocxEditorSessionAsync}.
|
|
238
|
+
* Other adapter methods (load/saveSession/logEvent) are not consumed by the
|
|
239
|
+
* loader itself and are carried through unchanged.
|
|
240
|
+
*/
|
|
241
|
+
hostAdapter?: EditorHostAdapter;
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
export interface LoadedDocxEditorSession {
|
|
@@ -1322,7 +1333,7 @@ export async function loadDocxEditorSessionAsync(
|
|
|
1322
1333
|
workflowMetadata: embeddedWorkflowMetadata,
|
|
1323
1334
|
timestamp,
|
|
1324
1335
|
});
|
|
1325
|
-
const
|
|
1336
|
+
const importedDocument = createImportedCanonicalDocument({
|
|
1326
1337
|
documentId: options.documentId,
|
|
1327
1338
|
timestamp,
|
|
1328
1339
|
numbering: parsedNumbering,
|
|
@@ -1377,6 +1388,15 @@ export async function loadDocxEditorSessionAsync(
|
|
|
1377
1388
|
]),
|
|
1378
1389
|
},
|
|
1379
1390
|
});
|
|
1391
|
+
// Stage 0B.1: if the host implements `renderChartPreview`, resolve
|
|
1392
|
+
// chart_preview nodes inline so the first snapshot already carries the
|
|
1393
|
+
// synthesized `previewMediaId`. Fallback-safe: returning null or throwing
|
|
1394
|
+
// is per-chart — the typed badge renders as if the adapter weren't set.
|
|
1395
|
+
const document = (await resolveChartPreviewsForDocument(
|
|
1396
|
+
importedDocument,
|
|
1397
|
+
sourcePackage,
|
|
1398
|
+
options.hostAdapter,
|
|
1399
|
+
)) as CanonicalDocumentEnvelope;
|
|
1380
1400
|
const compatibility = buildCompatibilityReport({
|
|
1381
1401
|
document,
|
|
1382
1402
|
generatedAt: timestamp,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* content type.
|
|
8
8
|
*
|
|
9
9
|
* Required by:
|
|
10
|
-
* docs/plans/
|
|
10
|
+
* docs/plans/lane-3-layout-engine-ooxml-fidelity.md §2 A.5
|
|
11
11
|
*
|
|
12
12
|
* Schema shape (ECMA-376 Part 1, Office Extended properties):
|
|
13
13
|
* <Properties xmlns="…/extended-properties" xmlns:vt="…/docPropsVTypes">
|
|
@@ -267,19 +267,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
|
|
|
267
267
|
continue;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
anchor.range.from >= candidate.start &&
|
|
273
|
-
anchor.range.to <= candidate.end,
|
|
274
|
-
);
|
|
270
|
+
const startParagraph = findParagraphForEndpoint(paragraphs, anchor.range.from, "start");
|
|
271
|
+
const endParagraph = findParagraphForEndpoint(paragraphs, anchor.range.to, "end");
|
|
275
272
|
|
|
276
|
-
if (!
|
|
273
|
+
if (!startParagraph || !endParagraph) {
|
|
277
274
|
skippedCommentIds.push(thread.commentId);
|
|
278
275
|
continue;
|
|
279
276
|
}
|
|
280
277
|
|
|
281
|
-
const startIndex =
|
|
282
|
-
const endIndex =
|
|
278
|
+
const startIndex = startParagraph.boundaries.get(anchor.range.from);
|
|
279
|
+
const endIndex = endParagraph.boundaries.get(anchor.range.to);
|
|
283
280
|
|
|
284
281
|
if (startIndex === undefined || endIndex === undefined) {
|
|
285
282
|
skippedCommentIds.push(thread.commentId);
|
|
@@ -324,6 +321,25 @@ export function serializeCommentAnchorsIntoDocumentXml(
|
|
|
324
321
|
};
|
|
325
322
|
}
|
|
326
323
|
|
|
324
|
+
function findParagraphForEndpoint(
|
|
325
|
+
paragraphs: readonly Pick<
|
|
326
|
+
RevisionParagraphBoundary,
|
|
327
|
+
"paragraphIndex" | "start" | "end" | "boundaries"
|
|
328
|
+
>[],
|
|
329
|
+
offset: number,
|
|
330
|
+
kind: "start" | "end",
|
|
331
|
+
): Pick<RevisionParagraphBoundary, "paragraphIndex" | "start" | "end" | "boundaries"> | undefined {
|
|
332
|
+
const matches = paragraphs.filter(
|
|
333
|
+
(p) => offset >= p.start && offset <= p.end,
|
|
334
|
+
);
|
|
335
|
+
if (matches.length === 0) return undefined;
|
|
336
|
+
if (matches.length === 1) return matches[0];
|
|
337
|
+
if (kind === "start") {
|
|
338
|
+
return matches.reduce((a, b) => (b.start >= a.start ? b : a));
|
|
339
|
+
}
|
|
340
|
+
return matches.reduce((a, b) => (b.end <= a.end ? b : a));
|
|
341
|
+
}
|
|
342
|
+
|
|
327
343
|
export function createCommentExportIdMap(
|
|
328
344
|
threads: readonly CommentThread[],
|
|
329
345
|
preservedDefinitions: readonly ImportedCommentDefinition[] = [],
|
|
@@ -729,6 +745,8 @@ function walkInlineNodeForBoundaries(
|
|
|
729
745
|
}
|
|
730
746
|
|
|
731
747
|
switch (localName(node.name)) {
|
|
748
|
+
case "pPr":
|
|
749
|
+
return;
|
|
732
750
|
case "r": {
|
|
733
751
|
if (!boundaries.has(getCursor())) {
|
|
734
752
|
boundaries.set(getCursor(), node.start);
|
|
@@ -750,7 +768,18 @@ function walkInlineNodeForBoundaries(
|
|
|
750
768
|
return;
|
|
751
769
|
}
|
|
752
770
|
case "tab":
|
|
753
|
-
case "br":
|
|
771
|
+
case "br":
|
|
772
|
+
case "footnoteReference":
|
|
773
|
+
case "endnoteReference":
|
|
774
|
+
case "drawing":
|
|
775
|
+
case "pict":
|
|
776
|
+
case "object":
|
|
777
|
+
case "sym":
|
|
778
|
+
case "ptab":
|
|
779
|
+
case "separator":
|
|
780
|
+
case "continuationSeparator":
|
|
781
|
+
case "noBreakHyphen":
|
|
782
|
+
case "softHyphen": {
|
|
754
783
|
const startCursor = getCursor();
|
|
755
784
|
boundaries.set(startCursor, node.start);
|
|
756
785
|
const nextCursor = startCursor + 1;
|