@beyondwork/docx-react-component 1.0.71 → 1.0.73
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 +964 -75
- package/package.json +1 -1
- package/src/api/public-types.ts +280 -1
- package/src/api/v3/_create.ts +16 -1
- package/src/api/v3/_runtime-handle.ts +2 -0
- package/src/api/v3/ai/evaluate.ts +113 -0
- package/src/api/v3/ai/outline.ts +140 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ai/replacement.ts +8 -0
- package/src/api/v3/ai/review.ts +342 -0
- package/src/api/v3/ai/stats.ts +62 -0
- package/src/api/v3/runtime/viewport.ts +181 -0
- package/src/api/v3/runtime/workflow.ts +114 -1
- package/src/api/v3/ui/_types.ts +35 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/index.ts +1 -0
- package/src/api/v3/ui/viewport.ts +112 -0
- package/src/compare/diff-engine.ts +2 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/table-structure-commands.ts +1 -0
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +7 -0
- package/src/io/export/serialize-main-document.ts +20 -0
- package/src/io/export/serialize-paragraph-formatting.ts +34 -0
- package/src/io/export/split-review-boundaries.ts +1 -0
- package/src/io/normalize/normalize-text.ts +49 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +148 -7
- package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
- package/src/model/canonical-document.ts +401 -1
- package/src/runtime/formatting/formatting-context.ts +2 -1
- package/src/runtime/geometry/overlay-rects.ts +7 -10
- package/src/runtime/layout/layout-engine-version.ts +278 -1
- package/src/runtime/layout/paginated-layout-engine.ts +181 -8
- package/src/runtime/layout/resolved-formatting-state.ts +108 -13
- package/src/runtime/markdown-sanitizer.ts +21 -4
- package/src/runtime/render/render-kernel.ts +21 -1
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/audit-bundle.ts +8 -0
- package/src/runtime/scopes/compiler-service.ts +1 -0
- package/src/runtime/scopes/enumerate-scopes.ts +61 -3
- package/src/runtime/scopes/replacement/apply.ts +50 -3
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/semantic-scope-types.ts +27 -0
- package/src/runtime/surface-projection.ts +77 -0
- package/src/runtime/workflow/coordinator.ts +3 -0
- package/src/runtime/workflow/scope-writer.ts +34 -0
- package/src/session/export/embedded-reconstitute.ts +37 -3
- package/src/session/import/embedded-offload.ts +26 -1
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/shell/media-previews.ts +8 -6
- package/src/ui/WordReviewEditor.tsx +1 -0
- package/src/ui/editor-surface-controller.tsx +11 -0
- package/src/ui/headless/selection-helpers.ts +2 -2
- package/src/ui/runtime-shortcut-dispatch.ts +4 -4
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
- package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
- package/src/ui-tailwind/index.ts +4 -2
- package/src/ui-tailwind/page-chrome-model.ts +5 -7
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
- package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* compiler-backed.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CanonicalDocument,
|
|
17
|
+
InlineNode,
|
|
18
|
+
} from "../../../model/canonical-document.ts";
|
|
16
19
|
import { resolveEffectiveFormattingForScope } from "../_formatting-seam.ts";
|
|
17
20
|
import type { ParagraphLikeEnumeratedScope } from "../enumerate-scopes.ts";
|
|
18
|
-
import { computeBlockPositions } from "../position-map.ts";
|
|
21
|
+
import { buildScopePositionMap, computeBlockPositions } from "../position-map.ts";
|
|
19
22
|
import { deriveReplaceability } from "../replaceability.ts";
|
|
20
23
|
import {
|
|
21
24
|
isStructuredReplacementContent,
|
|
@@ -32,6 +35,99 @@ import {
|
|
|
32
35
|
extractParagraphText,
|
|
33
36
|
} from "./_paragraph-text.ts";
|
|
34
37
|
|
|
38
|
+
function inlineLengthLocal(node: InlineNode): number {
|
|
39
|
+
switch (node.type) {
|
|
40
|
+
case "text":
|
|
41
|
+
return Array.from(node.text).length;
|
|
42
|
+
case "hyperlink":
|
|
43
|
+
case "field":
|
|
44
|
+
return (node.children as readonly InlineNode[]).reduce(
|
|
45
|
+
(total, child) => total + inlineLengthLocal(child),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
case "bookmark_start":
|
|
49
|
+
case "bookmark_end":
|
|
50
|
+
case "scope_marker_start":
|
|
51
|
+
case "scope_marker_end":
|
|
52
|
+
return 0;
|
|
53
|
+
default:
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Opt-in preservation helper: walk a paragraph's inline children,
|
|
60
|
+
* return the longest contiguous text-only run's [from, to] sub-range
|
|
61
|
+
* within the caller's `target` range. Opaque inlines / images / other
|
|
62
|
+
* non-text inlines break the run. Returns `null` when no text-only run
|
|
63
|
+
* exists inside the target range (paragraph is entirely opaques or the
|
|
64
|
+
* range falls on a non-text inline).
|
|
65
|
+
*
|
|
66
|
+
* Used by coord-08 §17 — `ReplacementPreservePolicy.opaqueFragments =
|
|
67
|
+
* true` narrows the replace range to this text-only sub-range so the
|
|
68
|
+
* opaque inlines survive the apply.
|
|
69
|
+
*/
|
|
70
|
+
function longestTextOnlyRangeInParagraph(
|
|
71
|
+
paragraph: ParagraphLikeEnumeratedScope["paragraph"],
|
|
72
|
+
paragraphFrom: number,
|
|
73
|
+
target: { readonly from: number; readonly to: number },
|
|
74
|
+
): { readonly from: number; readonly to: number } | null {
|
|
75
|
+
let best: { readonly from: number; readonly to: number } | null = null;
|
|
76
|
+
let runStart: number | null = null;
|
|
77
|
+
let cursor = paragraphFrom;
|
|
78
|
+
|
|
79
|
+
const consider = (from: number, to: number) => {
|
|
80
|
+
// Clip to the caller's target range.
|
|
81
|
+
const clippedFrom = Math.max(from, target.from);
|
|
82
|
+
const clippedTo = Math.min(to, target.to);
|
|
83
|
+
if (clippedTo <= clippedFrom) return;
|
|
84
|
+
if (!best || clippedTo - clippedFrom > best.to - best.from) {
|
|
85
|
+
best = { from: clippedFrom, to: clippedTo };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const isTextRun = (child: InlineNode): boolean => {
|
|
90
|
+
if (child.type === "text" || child.type === "tab" || child.type === "hard_break") {
|
|
91
|
+
// tab + hard_break carry length 1 and are safe to text-replace
|
|
92
|
+
// (the runtime's text.insert handles them structurally). Treat
|
|
93
|
+
// as part of the text run.
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (child.type === "hyperlink" || child.type === "field") {
|
|
97
|
+
// Hyperlink / field wrappers contribute their text-only children
|
|
98
|
+
// to the run; they're not themselves opaque.
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
child.type === "bookmark_start" ||
|
|
103
|
+
child.type === "bookmark_end" ||
|
|
104
|
+
child.type === "scope_marker_start" ||
|
|
105
|
+
child.type === "scope_marker_end"
|
|
106
|
+
) {
|
|
107
|
+
// Zero-width boundary markers — preserve via length 0. Don't
|
|
108
|
+
// break the text run.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const child of paragraph.children) {
|
|
115
|
+
const len = inlineLengthLocal(child);
|
|
116
|
+
const childFrom = cursor;
|
|
117
|
+
const childTo = cursor + len;
|
|
118
|
+
if (isTextRun(child)) {
|
|
119
|
+
if (runStart === null) runStart = childFrom;
|
|
120
|
+
} else {
|
|
121
|
+
// Opaque / image / non-text inline breaks the run.
|
|
122
|
+
if (runStart !== null) consider(runStart, childFrom);
|
|
123
|
+
runStart = null;
|
|
124
|
+
}
|
|
125
|
+
cursor = childTo;
|
|
126
|
+
}
|
|
127
|
+
if (runStart !== null) consider(runStart, cursor);
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
|
|
35
131
|
export interface CompileParagraphOptions {
|
|
36
132
|
readonly document?: CanonicalDocument;
|
|
37
133
|
readonly paragraphIndex?: number;
|
|
@@ -125,10 +221,71 @@ export function compileParagraphReplacement(
|
|
|
125
221
|
const blockRange = blocks.find((b) => b.blockIndex === entry.blockIndex);
|
|
126
222
|
if (!blockRange) return null;
|
|
127
223
|
|
|
224
|
+
// Inline-range correction (2026-04-24 bug fix): when the scope is
|
|
225
|
+
// marker-backed AND the marker pair sits strictly INSIDE the
|
|
226
|
+
// paragraph (marker range is narrower than the paragraph's full
|
|
227
|
+
// block range), the replace range must follow the markers — not
|
|
228
|
+
// the paragraph. Otherwise surrounding text in the same paragraph
|
|
229
|
+
// gets destroyed on every applyReplacementScope call. The markers'
|
|
230
|
+
// positions are already tracked in `buildScopePositionMap().
|
|
231
|
+
// markerScopes`; we consult it here only when the scope is marker-
|
|
232
|
+
// backed so non-marker paragraphs pay zero cost.
|
|
233
|
+
let effectiveRange: { readonly from: number; readonly to: number } = {
|
|
234
|
+
from: blockRange.from,
|
|
235
|
+
to: blockRange.to,
|
|
236
|
+
};
|
|
237
|
+
let rangeKind: "block" | "inline-marker" | "opaque-preserving-text" = "block";
|
|
238
|
+
if (entry.handle.provenance === "marker-backed") {
|
|
239
|
+
const positionMap = buildScopePositionMap(options.document);
|
|
240
|
+
const markerRange = positionMap.markerScopes.get(entry.handle.scopeId);
|
|
241
|
+
if (
|
|
242
|
+
markerRange &&
|
|
243
|
+
markerRange.from >= blockRange.from &&
|
|
244
|
+
markerRange.to <= blockRange.to &&
|
|
245
|
+
// Strictly narrower than the paragraph — only then is the marker
|
|
246
|
+
// a sub-paragraph inline range. Whole-paragraph marker scopes
|
|
247
|
+
// (markers wrapping all of the paragraph's text) continue to use
|
|
248
|
+
// the block range, preserving the existing replace-whole-paragraph
|
|
249
|
+
// semantics.
|
|
250
|
+
(markerRange.from > blockRange.from || markerRange.to < blockRange.to)
|
|
251
|
+
) {
|
|
252
|
+
effectiveRange = markerRange;
|
|
253
|
+
rangeKind = "inline-marker";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Coord-08 §17 (2026-04-24) — opt-in opaque preservation. When the
|
|
258
|
+
// caller sets `preserve.opaqueFragments === true`, narrow the
|
|
259
|
+
// replace range to the longest contiguous text-only sub-range so
|
|
260
|
+
// opaque inlines (images, charts, preserve-only fragments) survive
|
|
261
|
+
// the apply at their original positions.
|
|
262
|
+
if (proposed.preserve?.opaqueFragments === true) {
|
|
263
|
+
const textOnly = longestTextOnlyRangeInParagraph(
|
|
264
|
+
entry.paragraph,
|
|
265
|
+
blockRange.from,
|
|
266
|
+
effectiveRange,
|
|
267
|
+
);
|
|
268
|
+
if (!textOnly) {
|
|
269
|
+
// Paragraph is entirely opaques within the target range — no
|
|
270
|
+
// text-only sub-range to replace. Refuse cleanly; the composer's
|
|
271
|
+
// `preserve:opaque-fragment:*` warning already signalled the
|
|
272
|
+
// opt-in intent.
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
effectiveRange = textOnly;
|
|
276
|
+
rangeKind = "opaque-preserving-text";
|
|
277
|
+
}
|
|
278
|
+
|
|
128
279
|
if (proposed.proposedContent.kind === "text") {
|
|
129
280
|
const text = proposed.proposedContent.text ?? "";
|
|
130
281
|
const stepKind =
|
|
131
282
|
options.posture === "suggest-mode" ? "text-insert-tracked" : "text-replace";
|
|
283
|
+
const summaryScope =
|
|
284
|
+
rangeKind === "inline-marker"
|
|
285
|
+
? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
286
|
+
: rangeKind === "opaque-preserving-text"
|
|
287
|
+
? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
288
|
+
: `paragraph #${entry.blockIndex}`;
|
|
132
289
|
return {
|
|
133
290
|
scopeId: entry.handle.scopeId,
|
|
134
291
|
targetKind: "paragraph",
|
|
@@ -138,9 +295,9 @@ export function compileParagraphReplacement(
|
|
|
138
295
|
kind: stepKind,
|
|
139
296
|
summary:
|
|
140
297
|
stepKind === "text-replace"
|
|
141
|
-
? `replace
|
|
142
|
-
: `suggest-mode
|
|
143
|
-
range: { from:
|
|
298
|
+
? `replace ${summaryScope} text (len ${text.length})`
|
|
299
|
+
: `suggest-mode ${summaryScope} text replace (len ${text.length})`,
|
|
300
|
+
range: { from: effectiveRange.from, to: effectiveRange.to },
|
|
144
301
|
text,
|
|
145
302
|
},
|
|
146
303
|
]),
|
|
@@ -161,6 +318,12 @@ export function compileParagraphReplacement(
|
|
|
161
318
|
const fragment = proposed.proposedContent.structured;
|
|
162
319
|
if (!isStructuredReplacementContent(fragment)) return null;
|
|
163
320
|
const blockCount = fragment.blocks.length;
|
|
321
|
+
const summaryScope =
|
|
322
|
+
rangeKind === "inline-marker"
|
|
323
|
+
? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
324
|
+
: rangeKind === "opaque-preserving-text"
|
|
325
|
+
? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
|
|
326
|
+
: `paragraph #${entry.blockIndex}`;
|
|
164
327
|
return {
|
|
165
328
|
scopeId: entry.handle.scopeId,
|
|
166
329
|
targetKind: "paragraph",
|
|
@@ -168,8 +331,8 @@ export function compileParagraphReplacement(
|
|
|
168
331
|
steps: Object.freeze([
|
|
169
332
|
{
|
|
170
333
|
kind: "fragment-replace",
|
|
171
|
-
summary: `replace
|
|
172
|
-
range: { from:
|
|
334
|
+
summary: `replace ${summaryScope} with structured fragment (${blockCount} block(s))`,
|
|
335
|
+
range: { from: effectiveRange.from, to: effectiveRange.to },
|
|
173
336
|
fragment,
|
|
174
337
|
},
|
|
175
338
|
]),
|
|
@@ -296,6 +296,25 @@ export interface ReplacementPreservePolicy {
|
|
|
296
296
|
readonly comments?: boolean;
|
|
297
297
|
readonly revisions?: boolean;
|
|
298
298
|
readonly bookmarks?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Opt-in (2026-04-24): allow the replace to proceed when the target
|
|
301
|
+
* range contains opaque fragments (inline images, charts, or other
|
|
302
|
+
* `preserve-only` payload) that the runtime cannot safely
|
|
303
|
+
* re-serialize. When `true`:
|
|
304
|
+
*
|
|
305
|
+
* - `computePreservationVerdict` downgrades
|
|
306
|
+
* `preserve:opaque-fragment:*` reasons from blockers to warnings
|
|
307
|
+
* (still surfaced on `validation.warnings` + audit-bundle).
|
|
308
|
+
* - `compileParagraphReplacement` narrows the replace range to the
|
|
309
|
+
* longest contiguous text-only sub-range, leaving opaques at
|
|
310
|
+
* their original inline offsets. If no text sub-range exists
|
|
311
|
+
* (paragraph is entirely opaques), compile still refuses.
|
|
312
|
+
*
|
|
313
|
+
* Default (absent / `false`) preserves the pre-opt-in refusal
|
|
314
|
+
* semantics. Callers that don't set this flag are protected from
|
|
315
|
+
* accidental opaque-fragment loss.
|
|
316
|
+
*/
|
|
317
|
+
readonly opaqueFragments?: boolean;
|
|
299
318
|
}
|
|
300
319
|
|
|
301
320
|
export interface ReplacementScope {
|
|
@@ -426,6 +445,14 @@ export interface ScopeActionAudit {
|
|
|
426
445
|
}[];
|
|
427
446
|
readonly validation: ValidationResult;
|
|
428
447
|
readonly emittedAtUtc: string;
|
|
448
|
+
/**
|
|
449
|
+
* Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
|
|
450
|
+
* by the runtime during this apply. Populated for suggest-mode
|
|
451
|
+
* dispatch (tracked insert + delete revisions); omitted for direct-
|
|
452
|
+
* edit. Agents chain into `ai.acceptRevision` / `ai.rejectRevision`
|
|
453
|
+
* to land / discard the proposal without diffing the revision map.
|
|
454
|
+
*/
|
|
455
|
+
readonly authoredRevisionIds?: readonly string[];
|
|
429
456
|
}
|
|
430
457
|
|
|
431
458
|
/* -------------------------------------------------------------------------
|
|
@@ -796,6 +796,58 @@ function resolveSurfaceParagraphShading(
|
|
|
796
796
|
};
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Strip the verbatim-OOXML `rawXml` round-trip field from
|
|
801
|
+
* `FrameProperties` before forwarding to the public surface. Consumers
|
|
802
|
+
* (L04 paginated-layout placement, L11 render) read the modeled
|
|
803
|
+
* positioning + size fields; `rawXml` exists only so the serializer can
|
|
804
|
+
* merge extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`) back
|
|
805
|
+
* onto the element on export.
|
|
806
|
+
*/
|
|
807
|
+
function toSurfaceFrameProperties(
|
|
808
|
+
frame: NonNullable<CanonicalParagraphFormatting["frameProperties"]>,
|
|
809
|
+
): NonNullable<
|
|
810
|
+
Extract<
|
|
811
|
+
import("../api/public-types.ts").SurfaceBlockSnapshot,
|
|
812
|
+
{ kind: "paragraph" }
|
|
813
|
+
>["frameProperties"]
|
|
814
|
+
> {
|
|
815
|
+
const {
|
|
816
|
+
widthTwips,
|
|
817
|
+
heightTwips,
|
|
818
|
+
hRule,
|
|
819
|
+
xTwips,
|
|
820
|
+
yTwips,
|
|
821
|
+
xAlign,
|
|
822
|
+
yAlign,
|
|
823
|
+
hAnchor,
|
|
824
|
+
vAnchor,
|
|
825
|
+
wrap,
|
|
826
|
+
hSpaceTwips,
|
|
827
|
+
vSpaceTwips,
|
|
828
|
+
dropCap,
|
|
829
|
+
lines,
|
|
830
|
+
anchorLock,
|
|
831
|
+
} = frame;
|
|
832
|
+
return {
|
|
833
|
+
...(widthTwips !== undefined ? { widthTwips } : {}),
|
|
834
|
+
...(heightTwips !== undefined ? { heightTwips } : {}),
|
|
835
|
+
...(hRule !== undefined ? { hRule } : {}),
|
|
836
|
+
...(xTwips !== undefined ? { xTwips } : {}),
|
|
837
|
+
...(yTwips !== undefined ? { yTwips } : {}),
|
|
838
|
+
...(xAlign !== undefined ? { xAlign } : {}),
|
|
839
|
+
...(yAlign !== undefined ? { yAlign } : {}),
|
|
840
|
+
...(hAnchor !== undefined ? { hAnchor } : {}),
|
|
841
|
+
...(vAnchor !== undefined ? { vAnchor } : {}),
|
|
842
|
+
...(wrap !== undefined ? { wrap } : {}),
|
|
843
|
+
...(hSpaceTwips !== undefined ? { hSpaceTwips } : {}),
|
|
844
|
+
...(vSpaceTwips !== undefined ? { vSpaceTwips } : {}),
|
|
845
|
+
...(dropCap !== undefined ? { dropCap } : {}),
|
|
846
|
+
...(lines !== undefined ? { lines } : {}),
|
|
847
|
+
...(anchorLock !== undefined ? { anchorLock } : {}),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
799
851
|
function resolveSurfaceParagraphFormatting(
|
|
800
852
|
formatting: CanonicalParagraphFormatting,
|
|
801
853
|
themeResolver: ThemeColorResolver | undefined,
|
|
@@ -1066,6 +1118,9 @@ function createParagraphBlock(
|
|
|
1066
1118
|
: {}),
|
|
1067
1119
|
...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
|
|
1068
1120
|
...(paragraph.borders ? { borders: paragraph.borders } : {}),
|
|
1121
|
+
...(paragraph.frameProperties
|
|
1122
|
+
? { frameProperties: toSurfaceFrameProperties(paragraph.frameProperties) }
|
|
1123
|
+
: {}),
|
|
1069
1124
|
...(paragraph.shading
|
|
1070
1125
|
? { shading: resolveSurfaceParagraphShading(paragraph.shading, themeResolver) }
|
|
1071
1126
|
: {}),
|
|
@@ -1400,6 +1455,25 @@ function appendInlineSegments(
|
|
|
1400
1455
|
state: "locked-preserve-only",
|
|
1401
1456
|
});
|
|
1402
1457
|
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
1458
|
+
case "page_break":
|
|
1459
|
+
// coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>` forces
|
|
1460
|
+
// subsequent content onto a new page. Mirror `column_break`'s
|
|
1461
|
+
// quiet-marker emission (label-based detection). L04 pagination
|
|
1462
|
+
// reads `segment.label === "Page break"` via `hasPageBreak` and
|
|
1463
|
+
// forces `pushPage(block.to)` after placing the carrying block.
|
|
1464
|
+
paragraph.segments.push({
|
|
1465
|
+
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
1466
|
+
kind: "opaque_inline",
|
|
1467
|
+
from: start,
|
|
1468
|
+
to: start + 1,
|
|
1469
|
+
fragmentId: "",
|
|
1470
|
+
warningId: "",
|
|
1471
|
+
label: "Page break",
|
|
1472
|
+
detail: "Word hard page break marker — pagination forces a new page here.",
|
|
1473
|
+
presentation: "quiet-marker",
|
|
1474
|
+
state: "locked-preserve-only",
|
|
1475
|
+
});
|
|
1476
|
+
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
1403
1477
|
case "footnote_ref":
|
|
1404
1478
|
paragraph.segments.push({
|
|
1405
1479
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
@@ -2070,6 +2144,8 @@ function summarizePreviewInline(node: InlineNode): string {
|
|
|
2070
2144
|
return node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD";
|
|
2071
2145
|
case "column_break":
|
|
2072
2146
|
return "[Column break]";
|
|
2147
|
+
case "page_break":
|
|
2148
|
+
return "[Page break]";
|
|
2073
2149
|
case "chart_preview":
|
|
2074
2150
|
return "[Embedded chart]";
|
|
2075
2151
|
case "smartart_preview":
|
|
@@ -2376,6 +2452,7 @@ function cloneMarks(marks: TextMark[]): {
|
|
|
2376
2452
|
break;
|
|
2377
2453
|
case "highlight":
|
|
2378
2454
|
highlightColor = mark.color;
|
|
2455
|
+
supported.push("highlight");
|
|
2379
2456
|
break;
|
|
2380
2457
|
case "charSpacing":
|
|
2381
2458
|
attrs.charSpacing = mark.val;
|
|
@@ -838,6 +838,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
838
838
|
anchor: publicAnchor,
|
|
839
839
|
...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
|
|
840
840
|
...(params.label ? { label: params.label } : {}),
|
|
841
|
+
...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
|
|
842
|
+
? { metadata: [...params.scopeMetadataFields] }
|
|
843
|
+
: {}),
|
|
841
844
|
};
|
|
842
845
|
|
|
843
846
|
deps.dispatch({
|
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
RuntimeRenderSnapshot,
|
|
35
35
|
WorkflowMetadataEntry,
|
|
36
36
|
WorkflowMetadataPersistence,
|
|
37
|
+
WorkflowScopeMetadataField,
|
|
37
38
|
WorkflowScopeMode,
|
|
38
39
|
} from "../../api/public-types.ts";
|
|
39
40
|
|
|
@@ -68,6 +69,26 @@ export interface CreateScopeFromBlockIdInput {
|
|
|
68
69
|
* completeness since `BoundaryAssoc` typing allows them.
|
|
69
70
|
*/
|
|
70
71
|
readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
|
|
72
|
+
/**
|
|
73
|
+
* Coord-08 §9 / coord-09 §1.13 (A3) — caller-steerable identity
|
|
74
|
+
* strategy for the enumerated scope's `ScopeHandle.stableRef`.
|
|
75
|
+
* Stored as an overlay-scope metadata field (`key: "stableRefHint"`)
|
|
76
|
+
* at creation time; the L08 compiler reads it back during
|
|
77
|
+
* enumeration and honors the requested kind when the strategy is
|
|
78
|
+
* feasible, falling back to its default selection otherwise (see
|
|
79
|
+
* `src/runtime/scopes/enumerate-scopes.ts::stableRefHintForScopeId`
|
|
80
|
+
* for the feasibility matrix).
|
|
81
|
+
*
|
|
82
|
+
* Today's honored values: `"scope-id"` / `"semantic-path"`. The
|
|
83
|
+
* `"bookmark"` + `"runtime-handle"` kinds currently fall back (no
|
|
84
|
+
* bookmark-lookup wired in phase 1; runtime-handle is a transient
|
|
85
|
+
* strategy with no durability meaning).
|
|
86
|
+
*/
|
|
87
|
+
readonly stableRefHint?:
|
|
88
|
+
| "scope-id"
|
|
89
|
+
| "bookmark"
|
|
90
|
+
| "semantic-path"
|
|
91
|
+
| "runtime-handle";
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
export type CreateScopeFromBlockIdResult =
|
|
@@ -172,6 +193,16 @@ export function createScopeFromBlockId(
|
|
|
172
193
|
if (!anchor) {
|
|
173
194
|
return { status: "block-not-found", blockId: input.blockId };
|
|
174
195
|
}
|
|
196
|
+
// Coord-08 §9 — encode stableRefHint as an overlay-scope metadata
|
|
197
|
+
// field so the L08 compiler can read it back at enumeration time.
|
|
198
|
+
const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
|
|
199
|
+
if (input.stableRefHint !== undefined) {
|
|
200
|
+
scopeMetadataFields.push({
|
|
201
|
+
key: "stableRefHint",
|
|
202
|
+
valueType: "string",
|
|
203
|
+
value: input.stableRefHint,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
175
206
|
const result: AddScopeResult = runtime.addScope({
|
|
176
207
|
anchor,
|
|
177
208
|
mode: input.mode,
|
|
@@ -180,6 +211,9 @@ export function createScopeFromBlockId(
|
|
|
180
211
|
metadata: input.metadata,
|
|
181
212
|
storyTarget: input.storyTarget,
|
|
182
213
|
label: input.label,
|
|
214
|
+
...(scopeMetadataFields.length > 0
|
|
215
|
+
? { scopeMetadataFields }
|
|
216
|
+
: {}),
|
|
183
217
|
});
|
|
184
218
|
return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
|
|
185
219
|
}
|
|
@@ -68,6 +68,34 @@ export async function reconstituteEmbeddedDocuments(
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* SEC-IO-01 (2026-04-23): tamper-verified resolution.
|
|
73
|
+
*
|
|
74
|
+
* Two sources are consulted in order:
|
|
75
|
+
* 1. `hostAdapter.loadEmbeddedDocument(storageReference)` — verified against
|
|
76
|
+
* `entry.sha256`. Mismatch falls through to inline bytes, treated as a
|
|
77
|
+
* storage failure.
|
|
78
|
+
* 2. `entry.inlineBytes` base64 fallback — also verified against
|
|
79
|
+
* `entry.sha256`. If the inline bytes have been tampered with (attacker-
|
|
80
|
+
* modified customXml payload carrying altered bytes with the original
|
|
81
|
+
* declared sha), this check fail-closes: export throws a specific error
|
|
82
|
+
* rather than shipping attacker-chosen bytes into the OPC package.
|
|
83
|
+
*
|
|
84
|
+
* Before the fix, the inline-bytes path returned the decoded payload without
|
|
85
|
+
* verification — a crafted DOCX could inject attacker-controlled bytes into
|
|
86
|
+
* every exported package part whose host-adapter lookup happened to fail.
|
|
87
|
+
*/
|
|
88
|
+
class EmbeddedOffloadTamperError extends Error {
|
|
89
|
+
constructor(public readonly entry: { sourcePartPath: string; sha256: string }) {
|
|
90
|
+
super(
|
|
91
|
+
`Embedded offload entry '${entry.sourcePartPath}' failed sha256 integrity ` +
|
|
92
|
+
`check against inline-bytes fallback (expected sha256=${entry.sha256.slice(0, 16)}…). ` +
|
|
93
|
+
`Source customXml payload is tampered.`,
|
|
94
|
+
);
|
|
95
|
+
this.name = "EmbeddedOffloadTamperError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
async function resolveEntryBytes(
|
|
72
100
|
hostAdapter: EditorHostAdapter | undefined,
|
|
73
101
|
entry: EmbeddingOffloadEntry,
|
|
@@ -89,11 +117,17 @@ async function resolveEntryBytes(
|
|
|
89
117
|
}
|
|
90
118
|
} catch {
|
|
91
119
|
// Treat identically to `null` — fall through to the inline-
|
|
92
|
-
// bytes fallback.
|
|
93
|
-
// fails on storage unavailability.
|
|
120
|
+
// bytes fallback.
|
|
94
121
|
}
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
// SEC-IO-01 verification on the inline fallback. Unlike storage
|
|
124
|
+
// unavailability (which is benign), sha mismatch here means the
|
|
125
|
+
// source customXml payload was tampered with. Fail closed.
|
|
126
|
+
const decoded = decodeInlineBytes(entry.inlineBytes);
|
|
127
|
+
if (sha256Hex(decoded) !== entry.sha256) {
|
|
128
|
+
throw new EmbeddedOffloadTamperError(entry);
|
|
129
|
+
}
|
|
130
|
+
return decoded;
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
/**
|
|
@@ -263,6 +263,31 @@ const EMPTY_RESULT: EmbeddedOffloadResult = Object.freeze({
|
|
|
263
263
|
// (Buffer) and browser (btoa/atob) contexts. Direct call; the earlier
|
|
264
264
|
// local `encodeBase64` alias was unnecessary indirection.
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* SEC-IO-02 (2026-04-23): allowlist check for offload sourcePartPath.
|
|
268
|
+
*
|
|
269
|
+
* Offload entries come from customXml — untrusted content. The export-time
|
|
270
|
+
* `reconstituteEmbeddedDocuments` writes to `entry.sourcePartPath` via
|
|
271
|
+
* `exportSession.replaceOwnedPart`, which accepts arbitrary OPC paths. A
|
|
272
|
+
* malicious `sourcePartPath` could overwrite `/[Content_Types].xml`,
|
|
273
|
+
* `/_rels/.rels`, `/word/document.xml`, or any other sensitive part of the
|
|
274
|
+
* exported package.
|
|
275
|
+
*
|
|
276
|
+
* The offload feature covers EMBEDDED documents only — sub-documents Word
|
|
277
|
+
* stores under `/word/embeddings/embedded{N}.bin` (OLE) or similar. Narrow
|
|
278
|
+
* the allowlist to that canonical prefix. Entries with any other path,
|
|
279
|
+
* any traversal segment, or any control char silently drop.
|
|
280
|
+
*/
|
|
281
|
+
const SAFE_EMBEDDING_PATH = /^\/word\/embeddings\/[A-Za-z0-9_.-]+$/;
|
|
282
|
+
|
|
283
|
+
function isSafeSourcePartPath(path: unknown): path is string {
|
|
284
|
+
if (typeof path !== "string") return false;
|
|
285
|
+
if (path.length === 0 || path.length > 256) return false;
|
|
286
|
+
if (path.includes("..")) return false;
|
|
287
|
+
if (path.includes("\0") || path.includes("\n") || path.includes("\r")) return false;
|
|
288
|
+
return SAFE_EMBEDDING_PATH.test(path);
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
function parseEmbeddingsInline(
|
|
267
292
|
inline: unknown,
|
|
268
293
|
): EmbeddingOffloadEntry[] | undefined {
|
|
@@ -293,7 +318,7 @@ function parseEmbeddingsInline(
|
|
|
293
318
|
if (
|
|
294
319
|
typeof r.embeddedDocId !== "string" ||
|
|
295
320
|
typeof r.relationshipId !== "string" ||
|
|
296
|
-
|
|
321
|
+
!isSafeSourcePartPath(r.sourcePartPath) ||
|
|
297
322
|
typeof r.contentType !== "string" ||
|
|
298
323
|
typeof r.sha256 !== "string" ||
|
|
299
324
|
typeof r.inlineBytes !== "string" ||
|
|
@@ -119,6 +119,24 @@ export interface LoadDocxEditorSessionOptions {
|
|
|
119
119
|
* FRESH offload on first-open.
|
|
120
120
|
*/
|
|
121
121
|
offloadEmbeddedDocuments?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Phase 1 cosmetic-marker strip. When `true` (the default), the
|
|
124
|
+
* OOXML parser drops `<w:lastRenderedPageBreak/>`, `<w:proofErr/>`,
|
|
125
|
+
* and `<w:noBreakHyphen/>` during the body walk instead of emitting
|
|
126
|
+
* them as `opaque_inline` nodes. Aggregate counts are surfaced as
|
|
127
|
+
* a single `diagnostic:parser.skipped-cosmetic-markers` warning on
|
|
128
|
+
* the returned canonical document.
|
|
129
|
+
*
|
|
130
|
+
* These markers carry no contract semantics (Word regenerates them
|
|
131
|
+
* on reopen) and are structural boundaries that today cause
|
|
132
|
+
* `replaceText` to refuse ranges that happen to cross them. See
|
|
133
|
+
* `docs/architecture/cosmetic-marker-strip.md`.
|
|
134
|
+
*
|
|
135
|
+
* Set to `false` to preserve pre-strip behavior exactly — useful for
|
|
136
|
+
* byte-preservation tests and diagnostic scripts that need to
|
|
137
|
+
* inspect every marker.
|
|
138
|
+
*/
|
|
139
|
+
stripCosmeticMarkers?: boolean;
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
/**
|
|
@@ -540,6 +540,7 @@ export async function loadDocxSessionAsync(
|
|
|
540
540
|
mediaParts,
|
|
541
541
|
mainDocumentPath,
|
|
542
542
|
chartPartLookup,
|
|
543
|
+
{ stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
|
|
543
544
|
);
|
|
544
545
|
} finally {
|
|
545
546
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -1312,6 +1313,7 @@ export function loadDocxSessionSync(
|
|
|
1312
1313
|
mediaParts,
|
|
1313
1314
|
mainDocumentPath,
|
|
1314
1315
|
chartPartLookup,
|
|
1316
|
+
{ stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
|
|
1315
1317
|
);
|
|
1316
1318
|
} finally {
|
|
1317
1319
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* digest, walks the OPC parts table, and projects each canonical media
|
|
7
7
|
* item into a `MediaPreviewDescriptor`.
|
|
8
8
|
*
|
|
9
|
-
* SVG is
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* SVG is EXCLUDED from the content-type allowlist (SEC-UI-03, 2026-04-23).
|
|
10
|
+
* While Chromium's `<img src="data:image/svg+xml;base64,…">` path does block
|
|
11
|
+
* script execution on modern browsers, user-authored SVGs in DOCX `word/media/`
|
|
12
|
+
* can still leak user presence via `<image xlink:href="http://...">` trackers
|
|
13
|
+
* and have historically had CSP-bypass gaps. Chart-preview SVGs (Stage 0B)
|
|
14
|
+
* come from our own renderer via `chart-preview-resolver.ts`, not from
|
|
15
|
+
* `word/media/*`, so dropping SVG here does not regress charts.
|
|
14
16
|
*
|
|
15
17
|
* Lives in `src/shell/` so the three `io/**` substrate imports stay
|
|
16
18
|
* outside the L11 boundary register — package decoding is a shell
|
|
@@ -33,7 +35,7 @@ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
|
|
|
33
35
|
"image/gif",
|
|
34
36
|
"image/webp",
|
|
35
37
|
"image/bmp",
|
|
36
|
-
|
|
38
|
+
// SVG intentionally EXCLUDED — see SEC-UI-03 header comment.
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
export function buildMediaPreviews(
|
|
@@ -3321,6 +3321,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3321
3321
|
pasteFragmentParser={SHELL_PASTE_FRAGMENT_PARSER}
|
|
3322
3322
|
runtimeSearchDocument={api.runtime.search.searchDocument}
|
|
3323
3323
|
runtimeGetTableSelectionDescriptor={api.runtime.table.getSelectionDescriptor}
|
|
3324
|
+
scopeTagRegistryFactory={api.runtime.workflow.createScopeTagRegistry}
|
|
3324
3325
|
snapshot={snapshot}
|
|
3325
3326
|
canonicalDocument={canonicalDocument}
|
|
3326
3327
|
documentNavigation={documentNavigation}
|
|
@@ -84,6 +84,17 @@ export interface EditorSurfaceControllerProps {
|
|
|
84
84
|
runtimeGetTableSelectionDescriptor?: (
|
|
85
85
|
state: import("prosemirror-state").EditorState,
|
|
86
86
|
) => TableSelectionDescriptor | null;
|
|
87
|
+
/**
|
|
88
|
+
* Coord-10 L11-4 — forwarded to `TwProseMirrorSurface`. Threaded
|
|
89
|
+
* from `api.runtime.workflow.createScopeTagRegistry` (shipped by
|
|
90
|
+
* L06 in react-refactor `534f2b97`). Replaces the pre-L06-catalog
|
|
91
|
+
* pragmatic public-types re-export (refactor/11 §4.17, 2026-04-24
|
|
92
|
+
* `7a2d2fc0`). When absent, `TwProseMirrorSurface` falls back to
|
|
93
|
+
* the public-types re-export for test / headless mount back-compat.
|
|
94
|
+
*/
|
|
95
|
+
scopeTagRegistryFactory?: () => import(
|
|
96
|
+
"../api/public-types.ts"
|
|
97
|
+
).ScopeTagRegistry;
|
|
87
98
|
onPasteApplied?: (meta: {
|
|
88
99
|
segmentCount: number;
|
|
89
100
|
charCount: number;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { SelectionSnapshot } from "../../api/public-types";
|
|
2
1
|
import {
|
|
2
|
+
type SelectionSnapshot,
|
|
3
3
|
createPublicNodeAnchor,
|
|
4
4
|
createPublicRangeAnchor,
|
|
5
|
-
} from "../../
|
|
5
|
+
} from "../../api/public-types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Headless-UI-side `createSelectionSnapshot` that produces the **public**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
CAPABILITY_BY_ID,
|
|
3
|
+
type StyleCatalogSnapshot,
|
|
4
|
+
type WorkflowBlockedCommandReason,
|
|
4
5
|
} from "../api/public-types.ts";
|
|
5
|
-
import { CAPABILITY_BY_ID } from "../runtime/editor-surface/capabilities.ts";
|
|
6
6
|
|
|
7
7
|
export interface ShortcutKeyInput {
|
|
8
8
|
key: string;
|