@beyondwork/docx-react-component 1.0.103 → 1.0.105

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +66 -1
  3. package/src/api/v3/_runtime-handle.ts +2 -0
  4. package/src/api/v3/ai/_pe2-evidence.ts +153 -0
  5. package/src/api/v3/ai/bundle.ts +13 -5
  6. package/src/api/v3/ai/inspect.ts +7 -1
  7. package/src/api/v3/ai/outline.ts +2 -7
  8. package/src/api/v3/ai/replacement.ts +113 -0
  9. package/src/api/v3/runtime/geometry.ts +79 -0
  10. package/src/api/v3/ui/_types.ts +86 -0
  11. package/src/api/v3/ui/index.ts +5 -0
  12. package/src/api/v3/ui/overlays.ts +104 -0
  13. package/src/io/ooxml/parse-drawing.ts +99 -1
  14. package/src/io/ooxml/parse-fields.ts +27 -6
  15. package/src/io/ooxml/parse-shapes.ts +130 -0
  16. package/src/model/canonical-document.ts +34 -3
  17. package/src/model/canonical-layout-inputs.ts +979 -0
  18. package/src/model/layout/index.ts +9 -0
  19. package/src/model/layout/page-graph-types.ts +150 -0
  20. package/src/model/layout/runtime-page-graph-types.ts +23 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  22. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  23. package/src/runtime/document-runtime.ts +30 -14
  24. package/src/runtime/event-refresh-hints.ts +35 -5
  25. package/src/runtime/formatting/formatting-context.ts +110 -9
  26. package/src/runtime/formatting/index.ts +2 -0
  27. package/src/runtime/formatting/layout-inputs.ts +67 -3
  28. package/src/runtime/geometry/caret-geometry.ts +82 -10
  29. package/src/runtime/geometry/geometry-facet.ts +44 -0
  30. package/src/runtime/geometry/geometry-index.ts +1268 -0
  31. package/src/runtime/geometry/geometry-types.ts +227 -1
  32. package/src/runtime/geometry/index.ts +26 -0
  33. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  34. package/src/runtime/geometry/object-handles.ts +7 -4
  35. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  36. package/src/runtime/layout/layout-engine-instance.ts +2 -0
  37. package/src/runtime/layout/layout-engine-version.ts +44 -1
  38. package/src/runtime/layout/page-graph.ts +877 -2
  39. package/src/runtime/layout/project-block-fragments.ts +101 -1
  40. package/src/runtime/layout/public-facet.ts +152 -0
  41. package/src/runtime/prerender/graph-canonicalize.ts +44 -0
  42. package/src/runtime/surface-projection.ts +43 -3
  43. package/src/runtime/workflow/coordinator.ts +57 -11
  44. package/src/ui/ui-controller-factory.ts +11 -0
  45. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +3 -0
@@ -21,6 +21,17 @@ import type {
21
21
  EditorStoryTarget,
22
22
  PageLayoutSnapshot,
23
23
  } from "../../api/public-types";
24
+ import type {
25
+ BlockNode,
26
+ FooterDocument,
27
+ HeaderDocument,
28
+ InlineNode,
29
+ PreserveOnlyObjectSizing,
30
+ } from "../../model/canonical-document.ts";
31
+ import {
32
+ formatPageNumber,
33
+ formatPageNumberWithChapter,
34
+ } from "../formatting/field/page-number-format.ts";
24
35
  import type {
25
36
  ResolvedPageStories,
26
37
  } from "./page-story-resolver.ts";
@@ -48,6 +59,19 @@ import type { ResolvedDocumentSection } from "../document-layout.ts";
48
59
  export type {
49
60
  RuntimePageRegions,
50
61
  RuntimePageRegion,
62
+ RuntimeTwipsRect,
63
+ RuntimeResolvedRegions,
64
+ RuntimeExclusionZone,
65
+ RuntimeLayoutDivergenceKind,
66
+ RuntimeLayoutDivergence,
67
+ RuntimePageFrame,
68
+ RuntimePageLocalStoryInstance,
69
+ RuntimeResolvedStoryField,
70
+ RuntimeStoryAnchoredObject,
71
+ RuntimeLayoutContinuationCursor,
72
+ RuntimeParagraphContinuationCursor,
73
+ RuntimeTableContinuationCursor,
74
+ RuntimeTableVerticalMergeCarry,
51
75
  RuntimeBlockFragment,
52
76
  RuntimeLineBox,
53
77
  RuntimeNoteAllocation,
@@ -62,11 +86,17 @@ export type {
62
86
 
63
87
  import type {
64
88
  RuntimeBlockFragment,
89
+ RuntimeLayoutDivergence,
65
90
  RuntimeLineBox,
66
91
  RuntimeNoteAllocation,
67
92
  RuntimePageAnchor,
93
+ RuntimePageFrame,
94
+ RuntimePageLocalStoryInstance,
68
95
  RuntimePageRegion,
69
96
  RuntimePageRegions,
97
+ RuntimeResolvedRegions,
98
+ RuntimeStoryAnchoredObject,
99
+ RuntimeTwipsRect,
70
100
  } from "../../model/layout/page-graph-types.ts";
71
101
  import type {
72
102
  RuntimePageGraph,
@@ -80,6 +110,19 @@ import type {
80
110
 
81
111
  let graphRevision = 0;
82
112
 
113
+ const PAGE_INSTANCE_FIELD_FAMILIES = new Set([
114
+ "PAGE",
115
+ "NUMPAGES",
116
+ "SECTIONPAGES",
117
+ ]);
118
+
119
+ const EMUS_PER_TWIP = 635;
120
+
121
+ type HeaderFooterStoryTarget = Extract<
122
+ EditorStoryTarget,
123
+ { kind: "header" | "footer" }
124
+ >;
125
+
83
126
  export function buildPageGraph(input: BuildPageGraphInput): RuntimePageGraph;
84
127
  export function buildPageGraph(
85
128
  pages: readonly DocumentPageSnapshot[],
@@ -104,6 +147,7 @@ export function buildPageGraph(
104
147
  const pages: RuntimePageNode[] = [];
105
148
  const aggregatedFragments: RuntimeBlockFragment[] = [...(input.fragments ?? [])];
106
149
  const pageIdByGlobalPageIndex = new Map<number, string>();
150
+ const pageFieldCounts = buildPageFieldCounts(input.pages);
107
151
  for (let index = 0; index < input.pages.length; index += 1) {
108
152
  const page = input.pages[index]!;
109
153
  pageIdByGlobalPageIndex.set(page.pageIndex, `page-${graphRevision}-${index}`);
@@ -164,6 +208,29 @@ export function buildPageGraph(
164
208
  input.noteAllocations?.get(pageId) ??
165
209
  [];
166
210
 
211
+ const frameId = buildPageFrameId(
212
+ page.pageIndex,
213
+ page.sectionIndex,
214
+ stories.displayPageNumber,
215
+ );
216
+ const regions = buildRegions(page.layout, bodyPageFragments, stories, pageNoteAllocations);
217
+ const frameDivergences = detectFrameDivergences(frameId, regions);
218
+ const builtFrame = buildPageFrame({
219
+ frameId,
220
+ pageId,
221
+ pageIndex: page.pageIndex,
222
+ sectionIndex: page.sectionIndex,
223
+ displayPageNumber: stories.displayPageNumber,
224
+ layout: page.layout,
225
+ stories,
226
+ regions,
227
+ divergences: frameDivergences,
228
+ subParts: input.subParts,
229
+ pageFieldCounts,
230
+ });
231
+ const divergences = builtFrame.divergences;
232
+ const frame = builtFrame.frame;
233
+
167
234
  const node: RuntimePageNode = {
168
235
  pageId,
169
236
  pageIndex: page.pageIndex,
@@ -173,7 +240,9 @@ export function buildPageGraph(
173
240
  endOffset: page.endOffset,
174
241
  layout: page.layout,
175
242
  stories,
176
- regions: buildRegions(page.layout, bodyPageFragments, stories, pageNoteAllocations),
243
+ regions,
244
+ frame,
245
+ divergences,
177
246
  lineBoxes:
178
247
  input.lineBoxesByPageIndex?.get(page.pageIndex) ??
179
248
  input.lineBoxesByPageIndex?.get(index) ??
@@ -240,6 +309,12 @@ function buildRegions(
240
309
  originTwips: layout.pageHeight - layout.marginBottom - totalNoteHeight,
241
310
  widthTwips: Math.max(0, bodyWidth),
242
311
  heightTwips: Math.max(0, totalNoteHeight),
312
+ rectTwips: rect(
313
+ layout.marginLeft,
314
+ layout.pageHeight - layout.marginBottom - totalNoteHeight,
315
+ Math.max(0, bodyWidth),
316
+ Math.max(0, totalNoteHeight),
317
+ ),
243
318
  fragmentIds,
244
319
  };
245
320
  bodyHeight = Math.max(0, bodyHeight - totalNoteHeight);
@@ -251,6 +326,7 @@ function buildRegions(
251
326
  originTwips: layout.marginTop,
252
327
  widthTwips: Math.max(0, bodyWidth),
253
328
  heightTwips: Math.max(0, bodyHeight),
329
+ rectTwips: rect(layout.marginLeft, layout.marginTop, Math.max(0, bodyWidth), Math.max(0, bodyHeight)),
254
330
  fragmentIds: [...bodyFragmentIds],
255
331
  };
256
332
 
@@ -262,6 +338,12 @@ function buildRegions(
262
338
  originTwips: layout.headerMargin ?? 720,
263
339
  widthTwips: Math.max(0, bodyWidth),
264
340
  heightTwips: Math.max(0, layout.marginTop - (layout.headerMargin ?? 720)),
341
+ rectTwips: rect(
342
+ layout.marginLeft,
343
+ layout.headerMargin ?? 720,
344
+ Math.max(0, bodyWidth),
345
+ Math.max(0, layout.marginTop - (layout.headerMargin ?? 720)),
346
+ ),
265
347
  fragmentIds: [],
266
348
  };
267
349
  }
@@ -271,6 +353,12 @@ function buildRegions(
271
353
  originTwips: layout.pageHeight - layout.marginBottom,
272
354
  widthTwips: Math.max(0, bodyWidth),
273
355
  heightTwips: Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720)),
356
+ rectTwips: rect(
357
+ layout.marginLeft,
358
+ layout.pageHeight - layout.marginBottom,
359
+ Math.max(0, bodyWidth),
360
+ Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720)),
361
+ ),
274
362
  fragmentIds: [],
275
363
  };
276
364
  }
@@ -299,6 +387,12 @@ function buildRegions(
299
387
  originTwips: layout.marginTop,
300
388
  widthTwips: perColumnWidth,
301
389
  heightTwips: Math.max(0, bodyHeight),
390
+ rectTwips: rect(
391
+ layout.marginLeft + i * (perColumnWidth + gap),
392
+ layout.marginTop,
393
+ perColumnWidth,
394
+ Math.max(0, bodyHeight),
395
+ ),
302
396
  fragmentIds: perColumnIds[i]!,
303
397
  });
304
398
  }
@@ -312,6 +406,715 @@ function buildRegions(
312
406
  return regions;
313
407
  }
314
408
 
409
+ function rect(
410
+ xTwips: number,
411
+ yTwips: number,
412
+ widthTwips: number,
413
+ heightTwips: number,
414
+ ): RuntimeTwipsRect {
415
+ return { xTwips, yTwips, widthTwips, heightTwips };
416
+ }
417
+
418
+ function buildPageFrame(input: {
419
+ frameId: string;
420
+ pageId: string;
421
+ pageIndex: number;
422
+ sectionIndex: number;
423
+ displayPageNumber: number;
424
+ layout: PageLayoutSnapshot;
425
+ stories: ResolvedPageStories;
426
+ regions: RuntimePageRegions;
427
+ divergences: readonly RuntimeLayoutDivergence[];
428
+ subParts?: BuildPageGraphInput["subParts"];
429
+ pageFieldCounts: PageFieldCounts;
430
+ }): { frame: RuntimePageFrame; divergences: RuntimeLayoutDivergence[] } {
431
+ const regions: RuntimeResolvedRegions = {
432
+ body: input.regions.body,
433
+ exclusionZones: [],
434
+ ...(input.regions.header ? { header: input.regions.header } : {}),
435
+ ...(input.regions.footer ? { footer: input.regions.footer } : {}),
436
+ ...(input.regions.columns ? { columns: input.regions.columns } : {}),
437
+ ...(input.regions.footnotes ? { footnotes: input.regions.footnotes } : {}),
438
+ };
439
+ const pageLocalStoryResult = buildPageLocalStoryInstances({
440
+ frameId: input.frameId,
441
+ pageId: input.pageId,
442
+ pageIndex: input.pageIndex,
443
+ sectionIndex: input.sectionIndex,
444
+ displayPageNumber: input.displayPageNumber,
445
+ layout: input.layout,
446
+ stories: input.stories,
447
+ regions,
448
+ subParts: input.subParts,
449
+ pageFieldCounts: input.pageFieldCounts,
450
+ });
451
+ const divergences = [...input.divergences, ...pageLocalStoryResult.divergences];
452
+ const pageLocalStories = pageLocalStoryResult.instances;
453
+ const divergenceIds = divergences.map((d) => d.divergenceId);
454
+ return {
455
+ frame: {
456
+ frameId: input.frameId,
457
+ pageId: input.pageId,
458
+ pageIndex: input.pageIndex,
459
+ sectionIndex: input.sectionIndex,
460
+ displayPageNumber: input.displayPageNumber,
461
+ physicalBoundsTwips: rect(0, 0, input.layout.pageWidth, input.layout.pageHeight),
462
+ regions,
463
+ pageLocalStories,
464
+ divergenceIds,
465
+ signature: buildPageFrameSignature(input, pageLocalStories, divergenceIds),
466
+ },
467
+ divergences,
468
+ };
469
+ }
470
+
471
+ function buildPageLocalStoryInstances(input: {
472
+ frameId: string;
473
+ pageId: string;
474
+ pageIndex: number;
475
+ sectionIndex: number;
476
+ displayPageNumber: number;
477
+ layout: PageLayoutSnapshot;
478
+ stories: ResolvedPageStories;
479
+ regions: RuntimeResolvedRegions;
480
+ subParts?: BuildPageGraphInput["subParts"];
481
+ pageFieldCounts: PageFieldCounts;
482
+ }): {
483
+ instances: RuntimePageLocalStoryInstance[];
484
+ divergences: RuntimeLayoutDivergence[];
485
+ } {
486
+ const instances: RuntimePageLocalStoryInstance[] = [];
487
+ const divergences: RuntimeLayoutDivergence[] = [];
488
+ if (isHeaderFooterStoryTarget(input.stories.header)) {
489
+ const built = buildPageLocalStoryInstance(
490
+ input.frameId,
491
+ input.pageId,
492
+ input.pageIndex,
493
+ input.sectionIndex,
494
+ input.displayPageNumber,
495
+ input.layout,
496
+ input.stories.header,
497
+ input.regions.header,
498
+ findHeaderFooterPart(input.subParts?.headers, input.stories.header),
499
+ input.pageFieldCounts,
500
+ );
501
+ instances.push(built.instance);
502
+ divergences.push(...built.divergences);
503
+ }
504
+ if (isHeaderFooterStoryTarget(input.stories.footer)) {
505
+ const built = buildPageLocalStoryInstance(
506
+ input.frameId,
507
+ input.pageId,
508
+ input.pageIndex,
509
+ input.sectionIndex,
510
+ input.displayPageNumber,
511
+ input.layout,
512
+ input.stories.footer,
513
+ input.regions.footer,
514
+ findHeaderFooterPart(input.subParts?.footers, input.stories.footer),
515
+ input.pageFieldCounts,
516
+ );
517
+ instances.push(built.instance);
518
+ divergences.push(...built.divergences);
519
+ }
520
+ return { instances, divergences };
521
+ }
522
+
523
+ function buildPageLocalStoryInstance(
524
+ frameId: string,
525
+ pageId: string,
526
+ pageIndex: number,
527
+ sectionIndex: number,
528
+ displayPageNumber: number,
529
+ layout: PageLayoutSnapshot,
530
+ target: HeaderFooterStoryTarget,
531
+ region: RuntimePageRegion | undefined,
532
+ source: HeaderDocument | FooterDocument | undefined,
533
+ pageFieldCounts: PageFieldCounts,
534
+ ): {
535
+ instance: RuntimePageLocalStoryInstance;
536
+ divergences: RuntimeLayoutDivergence[];
537
+ } {
538
+ const measuredFrameHeightTwips = region?.heightTwips ?? 0;
539
+ const sectionPart =
540
+ target.sectionIndex === undefined ? "section-unknown" : `section-${target.sectionIndex}`;
541
+ const instanceId = `${frameId}:${target.kind}:${target.variant}:${target.relationshipId}`;
542
+ const storyKey = `${target.kind}:${target.relationshipId}`;
543
+ const resolvedFields = source
544
+ ? collectResolvedStoryFields(source.blocks, {
545
+ storyKey,
546
+ pageIndex,
547
+ sectionIndex,
548
+ displayPageNumber,
549
+ layout,
550
+ pageFieldCounts,
551
+ })
552
+ : [];
553
+ const objectLedger = source
554
+ ? collectStoryAnchoredObjects(source.blocks, {
555
+ frameId,
556
+ storyKey,
557
+ kind: target.kind,
558
+ variant: target.variant,
559
+ relationshipId: target.relationshipId,
560
+ })
561
+ : { objects: [], divergences: [] };
562
+ const signature = buildPageLocalStorySignature({
563
+ kind: target.kind,
564
+ variant: target.variant,
565
+ relationshipId: target.relationshipId,
566
+ sectionPart,
567
+ measuredFrameHeightTwips,
568
+ resolvedFields,
569
+ anchoredObjects: objectLedger.objects,
570
+ });
571
+ return {
572
+ instance: {
573
+ instanceId,
574
+ storyKey,
575
+ pageId,
576
+ kind: target.kind,
577
+ variant: target.variant,
578
+ relationshipId: target.relationshipId,
579
+ ...(target.sectionIndex === undefined ? {} : { sectionIndex: target.sectionIndex }),
580
+ resolvedFields,
581
+ anchoredObjects: objectLedger.objects,
582
+ measuredFrameHeightTwips,
583
+ signature,
584
+ },
585
+ divergences: objectLedger.divergences,
586
+ };
587
+ }
588
+
589
+ function buildPageLocalStorySignature(input: {
590
+ kind: RuntimePageLocalStoryInstance["kind"];
591
+ variant: RuntimePageLocalStoryInstance["variant"];
592
+ relationshipId: string;
593
+ sectionPart: string;
594
+ measuredFrameHeightTwips: number;
595
+ resolvedFields: readonly RuntimePageLocalStoryInstance["resolvedFields"][number][];
596
+ anchoredObjects: readonly RuntimeStoryAnchoredObject[];
597
+ }): string {
598
+ return [
599
+ "page-local-story",
600
+ input.kind,
601
+ input.variant,
602
+ input.relationshipId,
603
+ input.sectionPart,
604
+ input.measuredFrameHeightTwips,
605
+ ...input.resolvedFields.map((field) =>
606
+ [field.fieldId, field.family, field.displayText].join(":"),
607
+ ),
608
+ ...input.anchoredObjects.map((object) =>
609
+ [
610
+ object.objectId,
611
+ object.sourceType,
612
+ object.display,
613
+ object.extentTwips?.widthTwips ?? "",
614
+ object.extentTwips?.heightTwips ?? "",
615
+ object.relationshipIds?.join(",") ?? "",
616
+ object.preserveOnly ? "preserve-only" : "renderable",
617
+ object.divergenceIds.join(","),
618
+ ].join(":"),
619
+ ),
620
+ ].join("|");
621
+ }
622
+
623
+ interface PageFieldCounts {
624
+ contentPageCount: number;
625
+ sectionContentPageCountByIndex: ReadonlyMap<number, number>;
626
+ }
627
+
628
+ interface PageFieldResolutionInput {
629
+ storyKey: string;
630
+ pageIndex: number;
631
+ sectionIndex: number;
632
+ displayPageNumber: number;
633
+ layout: PageLayoutSnapshot;
634
+ pageFieldCounts: PageFieldCounts;
635
+ }
636
+
637
+ function buildPageFieldCounts(
638
+ pages: readonly Pick<DocumentPageSnapshot, "pageInSection" | "sectionIndex">[],
639
+ ): PageFieldCounts {
640
+ const sectionContentPageCountByIndex = new Map<number, number>();
641
+ let contentPageCount = 0;
642
+ for (const page of pages) {
643
+ if (page.pageInSection === -1) continue;
644
+ contentPageCount += 1;
645
+ sectionContentPageCountByIndex.set(
646
+ page.sectionIndex,
647
+ (sectionContentPageCountByIndex.get(page.sectionIndex) ?? 0) + 1,
648
+ );
649
+ }
650
+ return { contentPageCount, sectionContentPageCountByIndex };
651
+ }
652
+
653
+ function findHeaderFooterPart<T extends HeaderDocument | FooterDocument>(
654
+ parts: ReadonlyArray<T> | undefined,
655
+ target: HeaderFooterStoryTarget,
656
+ ): T | undefined {
657
+ return parts?.find((part) => part.relationshipId === target.relationshipId);
658
+ }
659
+
660
+ function collectResolvedStoryFields(
661
+ blocks: readonly BlockNode[],
662
+ context: PageFieldResolutionInput,
663
+ ): RuntimePageLocalStoryInstance["resolvedFields"] {
664
+ const fields: RuntimePageLocalStoryInstance["resolvedFields"] = [];
665
+ let ordinal = 0;
666
+
667
+ const visitBlock = (block: BlockNode): void => {
668
+ switch (block.type) {
669
+ case "paragraph":
670
+ for (const child of block.children) visitInline(child);
671
+ break;
672
+ case "table":
673
+ for (const row of block.rows) {
674
+ for (const cell of row.cells) {
675
+ for (const child of cell.children) visitBlock(child);
676
+ }
677
+ }
678
+ break;
679
+ case "sdt":
680
+ for (const child of block.children) visitBlock(child);
681
+ break;
682
+ default:
683
+ break;
684
+ }
685
+ };
686
+
687
+ const visitInline = (inline: InlineNode): void => {
688
+ switch (inline.type) {
689
+ case "field": {
690
+ const family = inline.fieldFamily ?? classifyFieldInstructionLocal(inline.instruction);
691
+ if (PAGE_INSTANCE_FIELD_FAMILIES.has(family)) {
692
+ const fieldId = `${context.storyKey}:field-${ordinal}:${family}`;
693
+ fields.push({
694
+ fieldId,
695
+ family,
696
+ displayText: resolvePageInstanceFieldDisplayText(
697
+ family,
698
+ flattenInline(inline.children),
699
+ context,
700
+ ),
701
+ });
702
+ ordinal += 1;
703
+ }
704
+ break;
705
+ }
706
+ case "hyperlink":
707
+ for (const child of inline.children) visitInline(child);
708
+ break;
709
+ default:
710
+ break;
711
+ }
712
+ };
713
+
714
+ for (const block of blocks) visitBlock(block);
715
+ return fields;
716
+ }
717
+
718
+ interface StoryObjectContext {
719
+ frameId: string;
720
+ storyKey: string;
721
+ kind: "header" | "footer";
722
+ variant: RuntimePageLocalStoryInstance["variant"];
723
+ relationshipId: string;
724
+ }
725
+
726
+ function collectStoryAnchoredObjects(
727
+ blocks: readonly BlockNode[],
728
+ context: StoryObjectContext,
729
+ ): {
730
+ objects: RuntimeStoryAnchoredObject[];
731
+ divergences: RuntimeLayoutDivergence[];
732
+ } {
733
+ const objects: RuntimeStoryAnchoredObject[] = [];
734
+ const divergences: RuntimeLayoutDivergence[] = [];
735
+ let ordinal = 0;
736
+
737
+ const pushObject = (
738
+ object: Omit<RuntimeStoryAnchoredObject, "objectId" | "divergenceIds"> & {
739
+ objectId?: string;
740
+ preserveHint?: PreserveOnlyObjectSizing;
741
+ wrapMode?: string;
742
+ },
743
+ ): void => {
744
+ const objectId =
745
+ object.objectId ?? `${context.storyKey}:object-${ordinal}:${object.sourceType}`;
746
+ const objectDivergenceIds: string[] = [];
747
+ const objectDisplay = object.display;
748
+
749
+ if (object.preserveOnly || object.preserveHint) {
750
+ const divergenceId = `${context.frameId}:preserve-only-placeholder:${objectId}`;
751
+ objectDivergenceIds.push(divergenceId);
752
+ divergences.push({
753
+ divergenceId,
754
+ kind: "preserve-only-placeholder",
755
+ source: "runtime",
756
+ severity: "info",
757
+ message: `Page-local ${context.kind} story contains preserve-only object ${objectId}`,
758
+ regionKinds: [context.kind],
759
+ objectIds: [objectId],
760
+ });
761
+ }
762
+
763
+ const wrapMode = object.wrapMode;
764
+ if (
765
+ objectDisplay === "floating" &&
766
+ wrapMode !== undefined &&
767
+ wrapMode !== "none" &&
768
+ wrapMode !== "topAndBottom"
769
+ ) {
770
+ const divergenceId = `${context.frameId}:unsupported-wrap:${objectId}:${wrapMode}`;
771
+ objectDivergenceIds.push(divergenceId);
772
+ divergences.push({
773
+ divergenceId,
774
+ kind: "unsupported-wrap",
775
+ source: "runtime",
776
+ severity: "warning",
777
+ message: `Page-local ${context.kind} story object ${objectId} uses unsupported wrap mode ${wrapMode}`,
778
+ regionKinds: [context.kind],
779
+ objectIds: [objectId],
780
+ });
781
+ }
782
+
783
+ const { preserveHint: _preserveHint, wrapMode: _wrapMode, ...ledger } = object;
784
+ objects.push({
785
+ ...ledger,
786
+ objectId,
787
+ divergenceIds: objectDivergenceIds,
788
+ });
789
+ ordinal += 1;
790
+ };
791
+
792
+ const visitBlock = (block: BlockNode): void => {
793
+ switch (block.type) {
794
+ case "paragraph":
795
+ for (const child of block.children) visitInline(child);
796
+ break;
797
+ case "table":
798
+ for (const row of block.rows) {
799
+ for (const cell of row.cells) {
800
+ for (const child of cell.children) visitBlock(child);
801
+ }
802
+ }
803
+ break;
804
+ case "sdt":
805
+ for (const child of block.children) visitBlock(child);
806
+ break;
807
+ default:
808
+ break;
809
+ }
810
+ };
811
+
812
+ const visitInline = (inline: InlineNode): void => {
813
+ switch (inline.type) {
814
+ case "image": {
815
+ pushObject({
816
+ objectId: inline.mediaId,
817
+ sourceType: "image",
818
+ display: inline.display === "floating" ? "floating" : "inline",
819
+ preserveOnly: false,
820
+ wrapMode: inline.floating?.wrap,
821
+ });
822
+ break;
823
+ }
824
+ case "drawing_frame": {
825
+ const preserveHint = getDrawingFramePreserveHint(inline);
826
+ const relationshipIds = collectDrawingRelationshipIds(inline);
827
+ const display = inline.anchor.display;
828
+ pushObject({
829
+ objectId: getDrawingFrameObjectId(inline, context.storyKey, ordinal),
830
+ sourceType: "drawing-frame",
831
+ display,
832
+ extentTwips: extentTwipsFromEmu(
833
+ inline.anchor.extent.widthEmu,
834
+ inline.anchor.extent.heightEmu,
835
+ ),
836
+ ...(relationshipIds.length > 0 ? { relationshipIds } : {}),
837
+ preserveOnly: Boolean(preserveHint),
838
+ ...(preserveHint ? { preserveHint } : {}),
839
+ wrapMode: inline.anchor.wrapMode,
840
+ });
841
+ if (inline.content.type === "shape") {
842
+ for (const child of inline.content.txbxBlocks ?? []) visitBlock(child);
843
+ }
844
+ break;
845
+ }
846
+ case "chart_preview":
847
+ case "smartart_preview":
848
+ case "shape":
849
+ case "wordart":
850
+ case "vml_shape": {
851
+ const preserveHint = inline.preserveOnlyObject;
852
+ pushObject({
853
+ objectId: getPreserveOnlyObjectId(inline, context.storyKey, ordinal),
854
+ sourceType: sourceTypeForInlineObject(inline.type),
855
+ display: preserveHint?.display ?? "unknown",
856
+ ...(preserveHint?.extentEmu
857
+ ? {
858
+ extentTwips: extentTwipsFromEmu(
859
+ preserveHint.extentEmu.widthEmu,
860
+ preserveHint.extentEmu.heightEmu,
861
+ ),
862
+ }
863
+ : {}),
864
+ ...(preserveHint?.relationshipIds
865
+ ? { relationshipIds: [...preserveHint.relationshipIds] }
866
+ : {}),
867
+ preserveOnly: Boolean(preserveHint),
868
+ ...(preserveHint ? { preserveHint } : {}),
869
+ });
870
+ if (inline.type === "shape") {
871
+ for (const child of inline.txbxBlocks ?? []) visitBlock(child);
872
+ }
873
+ break;
874
+ }
875
+ case "ole_embed":
876
+ pushObject({
877
+ objectId: inline.id,
878
+ sourceType: "ole-embed",
879
+ display: "unknown",
880
+ relationshipIds: [inline.relationshipId],
881
+ preserveOnly: true,
882
+ });
883
+ break;
884
+ case "opaque_inline":
885
+ pushObject({
886
+ objectId: inline.fragmentId,
887
+ sourceType: "opaque-inline",
888
+ display: "unknown",
889
+ preserveOnly: true,
890
+ });
891
+ break;
892
+ case "field":
893
+ for (const child of inline.children) visitInline(child);
894
+ break;
895
+ case "hyperlink":
896
+ for (const child of inline.children) visitInline(child);
897
+ break;
898
+ default:
899
+ break;
900
+ }
901
+ };
902
+
903
+ for (const block of blocks) visitBlock(block);
904
+ return { objects, divergences };
905
+ }
906
+
907
+ function getDrawingFramePreserveHint(
908
+ inline: Extract<InlineNode, { type: "drawing_frame" }>,
909
+ ): PreserveOnlyObjectSizing | undefined {
910
+ const content = inline.content;
911
+ if (content.type === "picture") return undefined;
912
+ return content.preserveOnlyObject;
913
+ }
914
+
915
+ function collectDrawingRelationshipIds(
916
+ inline: Extract<InlineNode, { type: "drawing_frame" }>,
917
+ ): string[] {
918
+ const content = inline.content;
919
+ if (content.type === "picture") return [content.blipRef];
920
+ const preserveIds = content.preserveOnlyObject?.relationshipIds ?? [];
921
+ return [...preserveIds];
922
+ }
923
+
924
+ function getDrawingFrameObjectId(
925
+ inline: Extract<InlineNode, { type: "drawing_frame" }>,
926
+ storyKey: string,
927
+ ordinal: number,
928
+ ): string {
929
+ if (inline.anchor.docPr?.id) return `${storyKey}:drawing-${inline.anchor.docPr.id}`;
930
+ if (inline.content.type === "picture") return `${storyKey}:picture-${inline.content.blipRef}`;
931
+ return `${storyKey}:drawing-${ordinal}`;
932
+ }
933
+
934
+ function getPreserveOnlyObjectId(
935
+ inline: Extract<
936
+ InlineNode,
937
+ { type: "chart_preview" | "smartart_preview" | "shape" | "wordart" | "vml_shape" }
938
+ >,
939
+ storyKey: string,
940
+ ordinal: number,
941
+ ): string {
942
+ return inline.preserveOnlyObject?.sourceId ?? `${storyKey}:${inline.type}-${ordinal}`;
943
+ }
944
+
945
+ function sourceTypeForInlineObject(
946
+ type: Extract<
947
+ InlineNode,
948
+ { type: "chart_preview" | "smartart_preview" | "shape" | "wordart" | "vml_shape" }
949
+ >["type"],
950
+ ): RuntimeStoryAnchoredObject["sourceType"] {
951
+ switch (type) {
952
+ case "chart_preview":
953
+ return "chart-preview";
954
+ case "smartart_preview":
955
+ return "smartart-preview";
956
+ case "shape":
957
+ return "shape";
958
+ case "wordart":
959
+ return "wordart";
960
+ case "vml_shape":
961
+ return "vml-shape";
962
+ }
963
+ }
964
+
965
+ function extentTwipsFromEmu(
966
+ widthEmu: number,
967
+ heightEmu: number,
968
+ ): RuntimeStoryAnchoredObject["extentTwips"] {
969
+ return {
970
+ widthTwips: Math.max(0, Math.round(widthEmu / EMUS_PER_TWIP)),
971
+ heightTwips: Math.max(0, Math.round(heightEmu / EMUS_PER_TWIP)),
972
+ };
973
+ }
974
+
975
+ function resolvePageInstanceFieldDisplayText(
976
+ family: string,
977
+ cachedDisplayText: string,
978
+ context: PageFieldResolutionInput,
979
+ ): string {
980
+ switch (family) {
981
+ case "PAGE":
982
+ return formatPageNumberWithChapter(
983
+ context.displayPageNumber,
984
+ context.layout.pageNumbering,
985
+ );
986
+ case "NUMPAGES":
987
+ return formatPageNumber(
988
+ context.pageFieldCounts.contentPageCount,
989
+ context.layout.pageNumbering?.format,
990
+ );
991
+ case "SECTIONPAGES":
992
+ return formatPageNumber(
993
+ context.pageFieldCounts.sectionContentPageCountByIndex.get(context.sectionIndex) ?? 0,
994
+ context.layout.pageNumbering?.format,
995
+ );
996
+ default:
997
+ return cachedDisplayText;
998
+ }
999
+ }
1000
+
1001
+ function flattenInline(inlines: ReadonlyArray<InlineNode> | undefined): string {
1002
+ if (!inlines) return "";
1003
+ let buf = "";
1004
+ for (const inline of inlines) {
1005
+ if (inline.type === "text") buf += inline.text;
1006
+ else if (inline.type === "hard_break" || inline.type === "tab") buf += " ";
1007
+ }
1008
+ return buf;
1009
+ }
1010
+
1011
+ function classifyFieldInstructionLocal(instr: string): string {
1012
+ const match = /^\s*(\w+)/.exec(instr);
1013
+ return match ? match[1]!.toUpperCase() : "UNKNOWN";
1014
+ }
1015
+
1016
+ function isHeaderFooterStoryTarget(
1017
+ target: EditorStoryTarget | undefined,
1018
+ ): target is HeaderFooterStoryTarget {
1019
+ return target?.kind === "header" || target?.kind === "footer";
1020
+ }
1021
+
1022
+ function buildPageFrameId(
1023
+ pageIndex: number,
1024
+ sectionIndex: number,
1025
+ displayPageNumber: number,
1026
+ ): string {
1027
+ return `page-frame-${pageIndex}-section-${sectionIndex}-display-${displayPageNumber}`;
1028
+ }
1029
+
1030
+ function buildPageFrameSignature(
1031
+ input: {
1032
+ pageIndex: number;
1033
+ sectionIndex: number;
1034
+ displayPageNumber: number;
1035
+ layout: PageLayoutSnapshot;
1036
+ regions: RuntimePageRegions;
1037
+ },
1038
+ pageLocalStories: readonly RuntimePageLocalStoryInstance[],
1039
+ divergenceIds: readonly string[],
1040
+ ): string {
1041
+ const regionParts = collectRegions(input.regions).map((region) => {
1042
+ const r = region.rectTwips ?? rect(0, region.originTwips, region.widthTwips, region.heightTwips);
1043
+ return [
1044
+ region.kind,
1045
+ r.xTwips,
1046
+ r.yTwips,
1047
+ r.widthTwips,
1048
+ r.heightTwips,
1049
+ region.fragmentIds.length,
1050
+ ].join(":");
1051
+ });
1052
+ return [
1053
+ "page-frame",
1054
+ input.pageIndex,
1055
+ input.sectionIndex,
1056
+ input.displayPageNumber,
1057
+ input.layout.pageWidth,
1058
+ input.layout.pageHeight,
1059
+ ...regionParts,
1060
+ ...pageLocalStories.map((story) => story.signature),
1061
+ ...divergenceIds,
1062
+ ].join("|");
1063
+ }
1064
+
1065
+ function detectFrameDivergences(
1066
+ frameId: string,
1067
+ regions: RuntimePageRegions,
1068
+ ): RuntimeLayoutDivergence[] {
1069
+ const candidates = collectRegions(regions).filter((region) => region.rectTwips !== undefined);
1070
+ const divergences: RuntimeLayoutDivergence[] = [];
1071
+ for (let i = 0; i < candidates.length; i += 1) {
1072
+ for (let j = i + 1; j < candidates.length; j += 1) {
1073
+ const a = candidates[i]!;
1074
+ const b = candidates[j]!;
1075
+ if (!shouldDetectCollision(a, b)) continue;
1076
+ if (!rectsOverlap(a.rectTwips!, b.rectTwips!)) continue;
1077
+ const kinds = [a.kind, b.kind].sort();
1078
+ divergences.push({
1079
+ divergenceId: `${frameId}:frame-collision:${kinds.join("-")}`,
1080
+ kind: "frame-collision",
1081
+ source: "runtime",
1082
+ severity: "warning",
1083
+ message: `Resolved page regions overlap: ${kinds.join(" / ")}`,
1084
+ regionKinds: kinds as RuntimeLayoutDivergence["regionKinds"],
1085
+ fragmentIds: [...a.fragmentIds, ...b.fragmentIds],
1086
+ });
1087
+ }
1088
+ }
1089
+ return divergences;
1090
+ }
1091
+
1092
+ function collectRegions(regions: RuntimePageRegions): RuntimePageRegion[] {
1093
+ return [
1094
+ regions.body,
1095
+ ...(regions.header ? [regions.header] : []),
1096
+ ...(regions.footer ? [regions.footer] : []),
1097
+ ...(regions.columns ?? []),
1098
+ ...(regions.footnotes ?? []),
1099
+ ];
1100
+ }
1101
+
1102
+ function shouldDetectCollision(a: RuntimePageRegion, b: RuntimePageRegion): boolean {
1103
+ if (a.kind === "body" && b.kind === "column") return false;
1104
+ if (a.kind === "column" && b.kind === "body") return false;
1105
+ if (a.kind === "column" && b.kind === "column") return false;
1106
+ return true;
1107
+ }
1108
+
1109
+ function rectsOverlap(a: RuntimeTwipsRect, b: RuntimeTwipsRect): boolean {
1110
+ return (
1111
+ a.xTwips < b.xTwips + b.widthTwips &&
1112
+ a.xTwips + a.widthTwips > b.xTwips &&
1113
+ a.yTwips < b.yTwips + b.heightTwips &&
1114
+ a.yTwips + a.heightTwips > b.yTwips
1115
+ );
1116
+ }
1117
+
315
1118
  // ---------------------------------------------------------------------------
316
1119
  // Graph queries
317
1120
  // ---------------------------------------------------------------------------
@@ -464,10 +1267,11 @@ export function spliceGraph(
464
1267
  }));
465
1268
 
466
1269
  const contentPageCount = nextPages.filter((p) => !p.isBlankFiller).length;
1270
+ const normalizedPages = normalizePageLocalStoryFieldsForPages(nextPages);
467
1271
 
468
1272
  return {
469
1273
  revision: graphRevision,
470
- pages: nextPages,
1274
+ pages: normalizedPages,
471
1275
  fragments: mergedFragments,
472
1276
  anchors,
473
1277
  sections: [...prior.sections],
@@ -475,6 +1279,77 @@ export function spliceGraph(
475
1279
  };
476
1280
  }
477
1281
 
1282
+ function normalizePageLocalStoryFieldsForPages(
1283
+ pages: readonly RuntimePageNode[],
1284
+ ): RuntimePageNode[] {
1285
+ const pageFieldCounts = buildPageFieldCounts(pages);
1286
+ return pages.map((page) => {
1287
+ const frame = page.frame;
1288
+ if (!frame || frame.pageLocalStories.every((story) => story.resolvedFields.length === 0)) {
1289
+ return page;
1290
+ }
1291
+
1292
+ let changed = false;
1293
+ const pageLocalStories = frame.pageLocalStories.map((story) => {
1294
+ if (story.resolvedFields.length === 0) return story;
1295
+ let storyChanged = false;
1296
+ const resolvedFields = story.resolvedFields.map((field) => {
1297
+ const displayText = resolvePageInstanceFieldDisplayText(
1298
+ field.family,
1299
+ field.displayText,
1300
+ {
1301
+ storyKey: story.storyKey,
1302
+ pageIndex: page.pageIndex,
1303
+ sectionIndex: page.sectionIndex,
1304
+ displayPageNumber: page.stories.displayPageNumber,
1305
+ layout: page.layout,
1306
+ pageFieldCounts,
1307
+ },
1308
+ );
1309
+ if (displayText === field.displayText) return field;
1310
+ storyChanged = true;
1311
+ changed = true;
1312
+ return { ...field, displayText };
1313
+ });
1314
+ if (!storyChanged) return story;
1315
+ const sectionPart =
1316
+ story.sectionIndex === undefined ? "section-unknown" : `section-${story.sectionIndex}`;
1317
+ return {
1318
+ ...story,
1319
+ resolvedFields,
1320
+ signature: buildPageLocalStorySignature({
1321
+ kind: story.kind,
1322
+ variant: story.variant,
1323
+ relationshipId: story.relationshipId,
1324
+ sectionPart,
1325
+ measuredFrameHeightTwips: story.measuredFrameHeightTwips,
1326
+ resolvedFields,
1327
+ anchoredObjects: story.anchoredObjects,
1328
+ }),
1329
+ };
1330
+ });
1331
+
1332
+ if (!changed) return page;
1333
+ const divergenceIds = frame.divergenceIds;
1334
+ const nextFrame: RuntimePageFrame = {
1335
+ ...frame,
1336
+ pageLocalStories,
1337
+ signature: buildPageFrameSignature(
1338
+ {
1339
+ pageIndex: frame.pageIndex,
1340
+ sectionIndex: frame.sectionIndex,
1341
+ displayPageNumber: frame.displayPageNumber,
1342
+ layout: page.layout,
1343
+ regions: page.regions,
1344
+ },
1345
+ pageLocalStories,
1346
+ divergenceIds,
1347
+ ),
1348
+ };
1349
+ return { ...page, frame: nextFrame };
1350
+ });
1351
+ }
1352
+
478
1353
  /**
479
1354
  * Compare two `RuntimePageNode`s by the fields that matter for
480
1355
  * pagination identity — if these all match, the fresh node represents