@beyondwork/docx-react-component 1.0.70 → 1.0.72
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 +243 -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/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/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/io/export/serialize-headers-footers.ts +1 -0
- package/src/io/export/serialize-main-document.ts +13 -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 +11 -0
- package/src/io/ooxml/parse-main-document.ts +21 -5
- 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 +257 -1
- package/src/runtime/layout/paginated-layout-engine.ts +134 -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/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 +49 -3
- package/src/runtime/scopes/semantic-scope-types.ts +8 -0
- package/src/runtime/surface-projection.ts +22 -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/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 +18 -4
- 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 +37 -0
- 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 +5 -2
- 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 +4 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -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 +73 -8
- 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/tw-review-workspace.tsx +1 -0
|
@@ -72,11 +72,11 @@ const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
|
|
|
72
72
|
"palatino linotype": 5.5,
|
|
73
73
|
"cambria": 5.3,
|
|
74
74
|
// Proportional sans-serif (xAvgCharWidth ≈ 0.50-0.60 em)
|
|
75
|
-
"arial":
|
|
75
|
+
"arial": 4.9,
|
|
76
76
|
"calibri": 5.0,
|
|
77
77
|
"helvetica": 5.8,
|
|
78
78
|
"verdana": 6.7,
|
|
79
|
-
"tahoma":
|
|
79
|
+
"tahoma": 6.3,
|
|
80
80
|
"segoe ui": 5.4,
|
|
81
81
|
"trebuchet ms": 5.6,
|
|
82
82
|
// Monospace (every glyph ≈ 0.60-0.67 em)
|
|
@@ -157,9 +157,21 @@ export interface ResolvedTableRowFormatting {
|
|
|
157
157
|
// Paragraph formatting resolution
|
|
158
158
|
// ---------------------------------------------------------------------------
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Resolved theme-font lookup. When a segment's
|
|
162
|
+
* `resolvedRunFormatting.*Theme` carries a slot reference (`minorHAnsi`,
|
|
163
|
+
* `majorAscii`, …) but no literal `fontFamily*` name, the dominant-font
|
|
164
|
+
* resolver consults these fallbacks. Source: `document.subParts?.resolvedTheme`.
|
|
165
|
+
*/
|
|
166
|
+
export interface LayoutThemeFonts {
|
|
167
|
+
minorFont?: string;
|
|
168
|
+
majorFont?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
export function resolveBlockFormatting(
|
|
161
172
|
block: SurfaceBlockSnapshot,
|
|
162
173
|
defaultTabInterval = 720,
|
|
174
|
+
themeFonts?: LayoutThemeFonts,
|
|
163
175
|
): ResolvedParagraphFormatting | null {
|
|
164
176
|
if (block.kind !== "paragraph") {
|
|
165
177
|
return null;
|
|
@@ -167,7 +179,7 @@ export function resolveBlockFormatting(
|
|
|
167
179
|
|
|
168
180
|
const spacing = resolveSpacing(block);
|
|
169
181
|
const indent = resolveIndentation(block);
|
|
170
|
-
const fontInfo = resolveDominantFont(block);
|
|
182
|
+
const fontInfo = resolveDominantFont(block, themeFonts);
|
|
171
183
|
const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
|
|
172
184
|
const markerLane = block.resolvedNumbering?.geometry?.markerLane;
|
|
173
185
|
|
|
@@ -293,6 +305,7 @@ function resolveIndentation(
|
|
|
293
305
|
|
|
294
306
|
function resolveDominantFont(
|
|
295
307
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
308
|
+
themeFonts?: LayoutThemeFonts,
|
|
296
309
|
): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
|
|
297
310
|
// L03 → L04 contract: read formatting from the canonical L03 output
|
|
298
311
|
// (`segment.resolvedRunFormatting`) first; fall back to the surface
|
|
@@ -304,10 +317,27 @@ function resolveDominantFont(
|
|
|
304
317
|
// to combine layers for performance" permits layout to ride the
|
|
305
318
|
// surface snapshot for locality, but the authoritative values must
|
|
306
319
|
// come from L03.
|
|
320
|
+
//
|
|
321
|
+
// Theme-slot fallback (Task A4 follow-up, 2026-04-23): when the L03
|
|
322
|
+
// cascade leaves `fontFamilyAscii` absent but carries an `asciiTheme`
|
|
323
|
+
// slot reference (ECMA-376 §17.3.2.26 — `<w:rFonts w:asciiTheme="minorHAnsi"/>`),
|
|
324
|
+
// resolve through `themeFonts` — mirrors L03's own
|
|
325
|
+
// `resolveRunFontFamily` behavior so the empirical measurement
|
|
326
|
+
// backend doesn't fall to the DEFAULT factor for theme-referenced
|
|
327
|
+
// paragraphs. CCEP templates with Tahoma/Calibri theme-bound bodies
|
|
328
|
+
// (e.g. `EU & Global IT Services SOW`) land here.
|
|
307
329
|
let fontFamily: string | undefined;
|
|
308
330
|
let fontSizeHalfPoints: number | undefined;
|
|
331
|
+
let resolvedViaTheme = false;
|
|
309
332
|
let maxTextLength = 0;
|
|
310
333
|
|
|
334
|
+
const resolveThemeSlot = (
|
|
335
|
+
slot: string | undefined,
|
|
336
|
+
): string | undefined => {
|
|
337
|
+
if (!slot || !themeFonts) return undefined;
|
|
338
|
+
return slot.startsWith("major") ? themeFonts.majorFont : themeFonts.minorFont;
|
|
339
|
+
};
|
|
340
|
+
|
|
311
341
|
for (const segment of block.segments) {
|
|
312
342
|
if (segment.kind !== "text") continue;
|
|
313
343
|
const resolved = segment.resolvedRunFormatting;
|
|
@@ -315,11 +345,23 @@ function resolveDominantFont(
|
|
|
315
345
|
// already layered docDefaults → paragraph style → character style →
|
|
316
346
|
// direct overrides; the markAttrs fallback only applies when the
|
|
317
347
|
// block was viewport-culled (resolvedRunFormatting absent).
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
348
|
+
// Script precedence: ascii → hAnsi → eastAsia → cs. Within each
|
|
349
|
+
// script, concrete family wins over theme slot.
|
|
350
|
+
let segResolvedViaTheme = false;
|
|
351
|
+
let segFontFamily: string | undefined;
|
|
352
|
+
if (resolved?.fontFamilyAscii) {
|
|
353
|
+
segFontFamily = resolved.fontFamilyAscii;
|
|
354
|
+
} else if (resolved?.asciiTheme && themeFonts) {
|
|
355
|
+
segFontFamily = resolveThemeSlot(resolved.asciiTheme);
|
|
356
|
+
if (segFontFamily) segResolvedViaTheme = true;
|
|
357
|
+
} else if (resolved?.fontFamilyHAnsi) {
|
|
358
|
+
segFontFamily = resolved.fontFamilyHAnsi;
|
|
359
|
+
} else if (resolved?.hAnsiTheme && themeFonts) {
|
|
360
|
+
segFontFamily = resolveThemeSlot(resolved.hAnsiTheme);
|
|
361
|
+
if (segFontFamily) segResolvedViaTheme = true;
|
|
362
|
+
} else {
|
|
363
|
+
segFontFamily = resolved?.fontFamily ?? segment.markAttrs?.fontFamily;
|
|
364
|
+
}
|
|
323
365
|
const segFontSize =
|
|
324
366
|
resolved?.fontSizeHalfPoints ?? segment.markAttrs?.fontSize;
|
|
325
367
|
const textLength = segment.text.length;
|
|
@@ -328,6 +370,7 @@ function resolveDominantFont(
|
|
|
328
370
|
maxTextLength = textLength;
|
|
329
371
|
if (typeof segFontFamily === "string") {
|
|
330
372
|
fontFamily = segFontFamily;
|
|
373
|
+
resolvedViaTheme = segResolvedViaTheme;
|
|
331
374
|
}
|
|
332
375
|
if (typeof segFontSize === "number") {
|
|
333
376
|
fontSizeHalfPoints = segFontSize;
|
|
@@ -341,8 +384,32 @@ function resolveDominantFont(
|
|
|
341
384
|
? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
|
|
342
385
|
: DEFAULT_FONT_AVG_CHAR_WIDTH;
|
|
343
386
|
|
|
387
|
+
// Theme-slot safety margin: xAvgCharWidth is derived from a fixed
|
|
388
|
+
// character-frequency sample and undercounts character-class-skewed
|
|
389
|
+
// content (legal-contract prose — heavy on capitalized defined terms
|
|
390
|
+
// and digits). When L03 left the paragraph bound to a theme slot and
|
|
391
|
+
// we resolved through `themeFonts`, the actual rendered width on these
|
|
392
|
+
// paragraphs is systematically wider than `xAvgCharWidth × chars`.
|
|
393
|
+
// Apply a safety multiplier — calibrated against 6 CCEP docs
|
|
394
|
+
// (coord-04 §1.18.2 regression finding). 2.4× is the minimum that
|
|
395
|
+
// restores `eu-global-it-services-sow` to its pre-v47 exact-parity
|
|
396
|
+
// (17/17) without regressing any of the 4 docs that won at v47.
|
|
397
|
+
// Concrete-family paragraphs keep the `xAvgCharWidth` factor untouched.
|
|
398
|
+
// The v49 safety multiplier was calibrated against a corpus where
|
|
399
|
+
// `<w:br w:type="page"/>` was being silently dropped by
|
|
400
|
+
// `src/io/normalize/normalize-text.ts::normalizeInlineChildren` (no
|
|
401
|
+
// `case "page_break"` → fell through). With the normalize fix landed
|
|
402
|
+
// alongside this change, real page-breaks now force pagination and
|
|
403
|
+
// SOW lands at 17/17 exact parity at multiplier 1.0 — i.e. theme-slot
|
|
404
|
+
// resolution is still useful for identifying the font, but the
|
|
405
|
+
// character-width factor does not need additional inflation.
|
|
406
|
+
const themeSafetyMultiplier = 1.0;
|
|
407
|
+
const effectiveFactor = resolvedViaTheme
|
|
408
|
+
? charWidthFactor * themeSafetyMultiplier
|
|
409
|
+
: charWidthFactor;
|
|
410
|
+
|
|
344
411
|
// Average char width in twips = factor * (fontSize in half-points)
|
|
345
|
-
const avgCharWidth = Math.max(96, Math.round(
|
|
412
|
+
const avgCharWidth = Math.max(96, Math.round(effectiveFactor * effectiveSize));
|
|
346
413
|
|
|
347
414
|
return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
|
|
348
415
|
}
|
|
@@ -403,11 +470,39 @@ function resolveTabStops(
|
|
|
403
470
|
leader: tab.leader,
|
|
404
471
|
});
|
|
405
472
|
|
|
406
|
-
|
|
473
|
+
// Style-cascaded tab stops (coord-04 §1.19.a). L03's
|
|
474
|
+
// `resolveEffectiveParagraphFormatting` merges `docDefaults.paragraph.tabs`
|
|
475
|
+
// → basedOn-chain style tabs → direct `w:tabs`, with direct winning on
|
|
476
|
+
// conflicting positions. The resolved merged set is deposited on
|
|
477
|
+
// `block.resolvedParagraphFormatting.tabStops` in canonical shape
|
|
478
|
+
// (`{ position, align, leader }`). Paragraphs styled TOC1/TOC2/etc.
|
|
479
|
+
// typically have no direct `w:tabs`; the style cascade is the only
|
|
480
|
+
// source. Without this fallback, TOC entries measured under L04 behave
|
|
481
|
+
// as if no tab-stops exist — right-aligned page-number columns get no
|
|
482
|
+
// dedicated tab-stop and the paragraph measurement collapses the tab
|
|
483
|
+
// advance to the default tab interval.
|
|
484
|
+
const cascadeTabs = block.resolvedParagraphFormatting?.tabStops;
|
|
485
|
+
const normalizeCanonical = (tab: {
|
|
486
|
+
position: number;
|
|
487
|
+
align: string;
|
|
488
|
+
leader?: string;
|
|
489
|
+
}): LayoutTabStop => ({
|
|
490
|
+
position: tab.position,
|
|
491
|
+
align: tab.align,
|
|
492
|
+
leader: tab.leader,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const paraTabs: LayoutTabStop[] = surfaceTabs && surfaceTabs.length > 0
|
|
496
|
+
? surfaceTabs.map(normalize)
|
|
497
|
+
: cascadeTabs && cascadeTabs.length > 0
|
|
498
|
+
? cascadeTabs.map(normalizeCanonical)
|
|
499
|
+
: [];
|
|
500
|
+
|
|
501
|
+
if (numSurfaceTabs && numSurfaceTabs.length > 0 && paraTabs.length > 0) {
|
|
407
502
|
const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
|
|
408
503
|
const merged = [
|
|
409
504
|
...numSurfaceTabs.map(normalize),
|
|
410
|
-
...
|
|
505
|
+
...paraTabs.filter((t) => !numPositions.has(t.position)),
|
|
411
506
|
];
|
|
412
507
|
return merged.sort((a, b) => a.position - b.position);
|
|
413
508
|
}
|
|
@@ -416,8 +511,8 @@ function resolveTabStops(
|
|
|
416
511
|
return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
|
|
417
512
|
}
|
|
418
513
|
|
|
419
|
-
if (
|
|
420
|
-
return
|
|
514
|
+
if (paraTabs.length > 0) {
|
|
515
|
+
return [...paraTabs].sort((a, b) => a.position - b.position);
|
|
421
516
|
}
|
|
422
517
|
|
|
423
518
|
return [];
|
|
@@ -47,17 +47,34 @@ export function sanitizeMarkdown(raw: string): SanitizeResult {
|
|
|
47
47
|
(_match, addr: string) => `<${addr}>`,
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
+
// SEC-UI-01 (2026-04-23): strip HTML tags strictly.
|
|
51
|
+
//
|
|
52
|
+
// Prior implementation preserved any match containing `://`, `@`, or `:` as
|
|
53
|
+
// a heuristic for autolinks. That let `<img onerror="alert(1)" src="x:y">`
|
|
54
|
+
// and `<a href="javascript:alert(1)">` and SVG `<use xlink:href="..."/>`
|
|
55
|
+
// through — the colon in an attribute value triggered preservation.
|
|
56
|
+
//
|
|
57
|
+
// The autolink pre-passes above (scheme URI + email form) already leave
|
|
58
|
+
// proper autolinks as `<scheme:target>` or `<user@host>`. They are the only
|
|
59
|
+
// angle-bracketed forms that should survive this strip. Anything else —
|
|
60
|
+
// anything with attributes, anything with whitespace inside the brackets,
|
|
61
|
+
// anything with quoted attribute content — is HTML-shaped and must be
|
|
62
|
+
// removed regardless of whether its internals happen to contain `:` / `@`.
|
|
63
|
+
const AUTOLINK_SCHEME_RE =
|
|
64
|
+
/^<[a-zA-Z][a-zA-Z0-9+.-]*:[^\s<>"'`]+>$/;
|
|
65
|
+
const AUTOLINK_EMAIL_RE =
|
|
66
|
+
/^<[A-Za-z0-9][A-Za-z0-9._%+-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}>$/;
|
|
50
67
|
const strippedHtml = text.replace(
|
|
51
68
|
// Require a recognisable HTML tag name so we do not strip safe
|
|
52
69
|
// autolinks that survived the pass above (email autolinks, bare URIs).
|
|
53
70
|
/<\/?([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/?>/g,
|
|
54
71
|
(match, tagName: string) => {
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
if (
|
|
72
|
+
// Preserve ONLY pure autolink shape. Any attributes / whitespace /
|
|
73
|
+
// quotes → definitely HTML, strip.
|
|
74
|
+
if (AUTOLINK_SCHEME_RE.test(match) || AUTOLINK_EMAIL_RE.test(match)) {
|
|
58
75
|
return match;
|
|
59
76
|
}
|
|
60
|
-
//
|
|
77
|
+
// Preserve plain URIs like `<example.com>` — rare, but benign.
|
|
61
78
|
if (/^[a-z]+\.[a-z]/.test(tagName)) {
|
|
62
79
|
return match;
|
|
63
80
|
}
|
|
@@ -353,7 +353,27 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
353
353
|
// Internals
|
|
354
354
|
// ---------------------------------------------------------------------------
|
|
355
355
|
|
|
356
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Inter-page gap in CSS pixels — the vertical gutter the kernel reserves
|
|
358
|
+
* between adjacent page frames in `RenderPage[]` Y coordinates.
|
|
359
|
+
*
|
|
360
|
+
* **Reconciled to 48 px on 2026-04-23 (refactor/10 Slice L11-3, delegated
|
|
361
|
+
* from refactor/05 closureBlockers.16/48px-gap-reconciliation +
|
|
362
|
+
* refactor/05 handover §5 deferred item 1).** Previously 16 px while the
|
|
363
|
+
* DOM page-break widget at `pm-page-break-decorations.ts:38` rendered a
|
|
364
|
+
* 48 px inter-page gap, causing a 32 px-per-page-boundary drift between
|
|
365
|
+
* kernel-reported `frame.pages[i].topPx` and the actual painted scroll
|
|
366
|
+
* position. Drift was previously compensated for by the chrome layer's
|
|
367
|
+
* DOM-measurement fallback path (overlay-rects "experimental" notice
|
|
368
|
+
* + scroll-anchor's `prepaintFallback`); reconciliation moves the
|
|
369
|
+
* geometry-direct warm path from "experimental" to "production".
|
|
370
|
+
*
|
|
371
|
+
* Consumer impact: every `topPx` value on `frame.pages[i]` for `i >= 1`
|
|
372
|
+
* shifts by +32 px per preceding page boundary. `LAYOUT_ENGINE_VERSION`
|
|
373
|
+
* bumped in the same commit so persisted laycache envelopes auto-
|
|
374
|
+
* invalidate.
|
|
375
|
+
*/
|
|
376
|
+
const PAGE_GAP_PX = 48;
|
|
357
377
|
|
|
358
378
|
function buildPage(
|
|
359
379
|
page: PublicPageNode,
|
|
@@ -29,6 +29,11 @@ export interface EmitScopeActionAuditInputs {
|
|
|
29
29
|
readonly validation: ValidationResult;
|
|
30
30
|
readonly emittedAtUtc: string;
|
|
31
31
|
readonly bus?: TelemetryBus;
|
|
32
|
+
/**
|
|
33
|
+
* Gap A — revision IDs the runtime authored during this apply.
|
|
34
|
+
* Populated for suggest-mode dispatch; omitted for direct-edit.
|
|
35
|
+
*/
|
|
36
|
+
readonly authoredRevisionIds?: readonly string[];
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function buildScopeActionAudit(
|
|
@@ -52,6 +57,9 @@ export function buildScopeActionAudit(
|
|
|
52
57
|
),
|
|
53
58
|
validation: inputs.validation,
|
|
54
59
|
emittedAtUtc: inputs.emittedAtUtc,
|
|
60
|
+
...(inputs.authoredRevisionIds && inputs.authoredRevisionIds.length > 0
|
|
61
|
+
? { authoredRevisionIds: Object.freeze([...inputs.authoredRevisionIds]) }
|
|
62
|
+
: {}),
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
|
|
@@ -256,6 +256,46 @@ function buildClassificationIndex(
|
|
|
256
256
|
return out;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Coord-08 §9 / A3 — read a caller-set `stableRefHint` off an overlay
|
|
261
|
+
* scope's metadata. Returns the requested strategy when the field is
|
|
262
|
+
* present AND feasible for the compiler to honor, `null` otherwise
|
|
263
|
+
* (falls back to the default selection).
|
|
264
|
+
*
|
|
265
|
+
* Feasibility matrix:
|
|
266
|
+
* - `"scope-id"` / `"semantic-path"` — always feasible.
|
|
267
|
+
* - `"bookmark"` — requires a bookmark registered at the block; no
|
|
268
|
+
* bookmark-lookup is wired in phase 1, so falls back.
|
|
269
|
+
* - `"runtime-handle"` — not a persistent strategy; ignored.
|
|
270
|
+
* - Unknown string — ignored.
|
|
271
|
+
*/
|
|
272
|
+
function stableRefHintForScopeId(
|
|
273
|
+
scopeId: string,
|
|
274
|
+
overlay: WorkflowOverlay | null | undefined,
|
|
275
|
+
): ScopeHandle["stableRef"]["kind"] | null {
|
|
276
|
+
if (!overlay) return null;
|
|
277
|
+
const scope = overlay.scopes.find(
|
|
278
|
+
(s: WorkflowScope) => s.scopeId === scopeId,
|
|
279
|
+
);
|
|
280
|
+
if (!scope || !scope.metadata) return null;
|
|
281
|
+
const hintField = scope.metadata.find(
|
|
282
|
+
(f: WorkflowScopeMetadataField) => f.key === "stableRefHint",
|
|
283
|
+
);
|
|
284
|
+
if (!hintField || typeof hintField.value !== "string") return null;
|
|
285
|
+
switch (hintField.value) {
|
|
286
|
+
case "scope-id":
|
|
287
|
+
case "semantic-path":
|
|
288
|
+
return hintField.value;
|
|
289
|
+
case "bookmark":
|
|
290
|
+
case "runtime-handle":
|
|
291
|
+
// Declared-but-not-implementable: fall back. Adding bookmark
|
|
292
|
+
// lookup is a phase-2 extension; see coord-08 §9.
|
|
293
|
+
return null;
|
|
294
|
+
default:
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
259
299
|
/**
|
|
260
300
|
* Walk paragraph children and return the first scope-marker id that
|
|
261
301
|
* *starts* inside the paragraph. When a paragraph carries multiple
|
|
@@ -417,6 +457,26 @@ export function enumerateScopes(
|
|
|
417
457
|
markerScopeId !== null ? "marker-backed" : "derived";
|
|
418
458
|
const rangePrecision: ScopeHandle["rangePrecision"] =
|
|
419
459
|
markerScopeId !== null ? "marker-backed" : "canonical";
|
|
460
|
+
// Coord-08 §9 — honor caller-set stableRefHint when present. Only
|
|
461
|
+
// consulted on marker-backed paragraphs (derived scopes have no
|
|
462
|
+
// overlay entry to carry a hint).
|
|
463
|
+
const hint =
|
|
464
|
+
markerScopeId !== null
|
|
465
|
+
? stableRefHintForScopeId(markerScopeId, inputs.overlay)
|
|
466
|
+
: null;
|
|
467
|
+
const defaultMarkerStable =
|
|
468
|
+
markerScopeId !== null
|
|
469
|
+
? ({ kind: "scope-id", value: markerScopeId } as const)
|
|
470
|
+
: undefined;
|
|
471
|
+
const stableRefOverride =
|
|
472
|
+
hint === "semantic-path"
|
|
473
|
+
? ({
|
|
474
|
+
kind: "semantic-path" as const,
|
|
475
|
+
value: semanticPath.join("/"),
|
|
476
|
+
})
|
|
477
|
+
: hint === "scope-id" && markerScopeId !== null
|
|
478
|
+
? ({ kind: "scope-id" as const, value: markerScopeId })
|
|
479
|
+
: defaultMarkerStable;
|
|
420
480
|
const handle = buildHandle(
|
|
421
481
|
scopeId,
|
|
422
482
|
documentId,
|
|
@@ -424,9 +484,7 @@ export function enumerateScopes(
|
|
|
424
484
|
provenance,
|
|
425
485
|
rangePrecision,
|
|
426
486
|
undefined,
|
|
427
|
-
|
|
428
|
-
? { kind: "scope-id", value: markerScopeId }
|
|
429
|
-
: undefined,
|
|
487
|
+
stableRefOverride,
|
|
430
488
|
);
|
|
431
489
|
const classifications =
|
|
432
490
|
markerScopeId !== null
|
|
@@ -66,6 +66,17 @@ export interface ApplyScopeReplacementResult {
|
|
|
66
66
|
readonly audit?: ScopeActionAudit;
|
|
67
67
|
readonly plan?: RuntimeOperationPlan;
|
|
68
68
|
readonly scope?: SemanticScope;
|
|
69
|
+
/**
|
|
70
|
+
* Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
|
|
71
|
+
* by the runtime during this apply. Populated for suggest-mode
|
|
72
|
+
* dispatch (where `text-insert-tracked` / `text-delete-tracked`
|
|
73
|
+
* commands author `RevisionRecord` entries in
|
|
74
|
+
* `review.revisions`); empty array for direct-edit (no revisions
|
|
75
|
+
* authored). Agents can pass each id into `ai.acceptRevision` /
|
|
76
|
+
* `ai.rejectRevision` to land or discard the proposal without
|
|
77
|
+
* having to diff the runtime's revision map themselves.
|
|
78
|
+
*/
|
|
79
|
+
readonly authoredRevisionIds: readonly string[];
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
function documentHash(doc: CanonicalDocumentEnvelope): string {
|
|
@@ -117,7 +128,12 @@ export function applyScopeReplacement(
|
|
|
117
128
|
]),
|
|
118
129
|
warnings: Object.freeze([]),
|
|
119
130
|
};
|
|
120
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
applied: false,
|
|
133
|
+
reason: "scope-not-resolvable",
|
|
134
|
+
validation,
|
|
135
|
+
authoredRevisionIds: Object.freeze([]),
|
|
136
|
+
};
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
const verdict = composeScopeValidation({
|
|
@@ -139,6 +155,7 @@ export function applyScopeReplacement(
|
|
|
139
155
|
reason: "validation-blocked",
|
|
140
156
|
validation: verdict,
|
|
141
157
|
scope: resolvedScope,
|
|
158
|
+
authoredRevisionIds: Object.freeze([]),
|
|
142
159
|
};
|
|
143
160
|
}
|
|
144
161
|
|
|
@@ -190,19 +207,44 @@ export function applyScopeReplacement(
|
|
|
190
207
|
blockedReasons: Object.freeze([blocker]),
|
|
191
208
|
warnings: verdict.warnings,
|
|
192
209
|
};
|
|
210
|
+
// Coord-08 U5 — `reason` mirrors `blockers[0]` for symmetry. Agents
|
|
211
|
+
// reading either channel at the top level see the same
|
|
212
|
+
// most-actionable signal (`compile-refused:<kind>[:operation-not-
|
|
213
|
+
// implemented:<op>]`). Pre-U5 (before 2026-04-23) `reason` was the
|
|
214
|
+
// bare prefix `"compile-refused"` — callers had to descend into
|
|
215
|
+
// `validation.blockedReasons[0]` to recover the suffix.
|
|
193
216
|
return {
|
|
194
217
|
applied: false,
|
|
195
|
-
reason:
|
|
218
|
+
reason: blocker,
|
|
196
219
|
validation: refused,
|
|
197
220
|
scope: resolvedScope,
|
|
221
|
+
authoredRevisionIds: Object.freeze([]),
|
|
198
222
|
};
|
|
199
223
|
}
|
|
200
224
|
|
|
201
225
|
const documentHashBefore = documentHash(docBefore);
|
|
202
226
|
|
|
227
|
+
// Gap A — capture revision-map keys before dispatch so we can diff
|
|
228
|
+
// post-apply and surface the authored ids to agents. Suggest-mode
|
|
229
|
+
// dispatch (text-insert-tracked / text-delete-tracked) authors
|
|
230
|
+
// insertion + deletion `RevisionRecord` entries in
|
|
231
|
+
// `canonicalDocument.review.revisions`; direct-edit dispatch touches
|
|
232
|
+
// no revisions. The diff approach is side-channel-free and doesn't
|
|
233
|
+
// require plumbing a new return value through the runtime's
|
|
234
|
+
// applyTextCommandInActiveStory seam.
|
|
235
|
+
const revisionsBefore = new Set(
|
|
236
|
+
Object.keys(docBefore.review.revisions ?? {}),
|
|
237
|
+
);
|
|
238
|
+
|
|
203
239
|
inputs.sink.applyScopeReplacement(plan);
|
|
204
240
|
|
|
205
|
-
const
|
|
241
|
+
const docAfter = inputs.sink.getCanonicalDocument();
|
|
242
|
+
const documentHashAfter = documentHash(docAfter);
|
|
243
|
+
|
|
244
|
+
const authoredRevisionIds: string[] = [];
|
|
245
|
+
for (const id of Object.keys(docAfter.review.revisions ?? {})) {
|
|
246
|
+
if (!revisionsBefore.has(id)) authoredRevisionIds.push(id);
|
|
247
|
+
}
|
|
206
248
|
|
|
207
249
|
const audit = emitScopeActionAudit({
|
|
208
250
|
actionId: inputs.actionId ?? "replacement",
|
|
@@ -215,6 +257,9 @@ export function applyScopeReplacement(
|
|
|
215
257
|
plan,
|
|
216
258
|
validation: verdict,
|
|
217
259
|
emittedAtUtc: inputs.emittedAtUtc,
|
|
260
|
+
...(authoredRevisionIds.length > 0
|
|
261
|
+
? { authoredRevisionIds }
|
|
262
|
+
: {}),
|
|
218
263
|
...(inputs.bus ? { bus: inputs.bus } : {}),
|
|
219
264
|
});
|
|
220
265
|
|
|
@@ -224,5 +269,6 @@ export function applyScopeReplacement(
|
|
|
224
269
|
audit,
|
|
225
270
|
plan,
|
|
226
271
|
scope: resolvedScope,
|
|
272
|
+
authoredRevisionIds: Object.freeze([...authoredRevisionIds]),
|
|
227
273
|
};
|
|
228
274
|
}
|
|
@@ -426,6 +426,14 @@ export interface ScopeActionAudit {
|
|
|
426
426
|
}[];
|
|
427
427
|
readonly validation: ValidationResult;
|
|
428
428
|
readonly emittedAtUtc: string;
|
|
429
|
+
/**
|
|
430
|
+
* Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
|
|
431
|
+
* by the runtime during this apply. Populated for suggest-mode
|
|
432
|
+
* dispatch (tracked insert + delete revisions); omitted for direct-
|
|
433
|
+
* edit. Agents chain into `ai.acceptRevision` / `ai.rejectRevision`
|
|
434
|
+
* to land / discard the proposal without diffing the revision map.
|
|
435
|
+
*/
|
|
436
|
+
readonly authoredRevisionIds?: readonly string[];
|
|
429
437
|
}
|
|
430
438
|
|
|
431
439
|
/* -------------------------------------------------------------------------
|
|
@@ -1400,6 +1400,25 @@ function appendInlineSegments(
|
|
|
1400
1400
|
state: "locked-preserve-only",
|
|
1401
1401
|
});
|
|
1402
1402
|
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
1403
|
+
case "page_break":
|
|
1404
|
+
// coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>` forces
|
|
1405
|
+
// subsequent content onto a new page. Mirror `column_break`'s
|
|
1406
|
+
// quiet-marker emission (label-based detection). L04 pagination
|
|
1407
|
+
// reads `segment.label === "Page break"` via `hasPageBreak` and
|
|
1408
|
+
// forces `pushPage(block.to)` after placing the carrying block.
|
|
1409
|
+
paragraph.segments.push({
|
|
1410
|
+
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
1411
|
+
kind: "opaque_inline",
|
|
1412
|
+
from: start,
|
|
1413
|
+
to: start + 1,
|
|
1414
|
+
fragmentId: "",
|
|
1415
|
+
warningId: "",
|
|
1416
|
+
label: "Page break",
|
|
1417
|
+
detail: "Word hard page break marker — pagination forces a new page here.",
|
|
1418
|
+
presentation: "quiet-marker",
|
|
1419
|
+
state: "locked-preserve-only",
|
|
1420
|
+
});
|
|
1421
|
+
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
1403
1422
|
case "footnote_ref":
|
|
1404
1423
|
paragraph.segments.push({
|
|
1405
1424
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
@@ -2070,6 +2089,8 @@ function summarizePreviewInline(node: InlineNode): string {
|
|
|
2070
2089
|
return node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD";
|
|
2071
2090
|
case "column_break":
|
|
2072
2091
|
return "[Column break]";
|
|
2092
|
+
case "page_break":
|
|
2093
|
+
return "[Page break]";
|
|
2073
2094
|
case "chart_preview":
|
|
2074
2095
|
return "[Embedded chart]";
|
|
2075
2096
|
case "smartart_preview":
|
|
@@ -2376,6 +2397,7 @@ function cloneMarks(marks: TextMark[]): {
|
|
|
2376
2397
|
break;
|
|
2377
2398
|
case "highlight":
|
|
2378
2399
|
highlightColor = mark.color;
|
|
2400
|
+
supported.push("highlight");
|
|
2379
2401
|
break;
|
|
2380
2402
|
case "charSpacing":
|
|
2381
2403
|
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
|
}
|