@beyondwork/docx-react-component 1.0.104 → 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.
@@ -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";
@@ -54,6 +65,13 @@ export type {
54
65
  RuntimeLayoutDivergenceKind,
55
66
  RuntimeLayoutDivergence,
56
67
  RuntimePageFrame,
68
+ RuntimePageLocalStoryInstance,
69
+ RuntimeResolvedStoryField,
70
+ RuntimeStoryAnchoredObject,
71
+ RuntimeLayoutContinuationCursor,
72
+ RuntimeParagraphContinuationCursor,
73
+ RuntimeTableContinuationCursor,
74
+ RuntimeTableVerticalMergeCarry,
57
75
  RuntimeBlockFragment,
58
76
  RuntimeLineBox,
59
77
  RuntimeNoteAllocation,
@@ -73,9 +91,11 @@ import type {
73
91
  RuntimeNoteAllocation,
74
92
  RuntimePageAnchor,
75
93
  RuntimePageFrame,
94
+ RuntimePageLocalStoryInstance,
76
95
  RuntimePageRegion,
77
96
  RuntimePageRegions,
78
97
  RuntimeResolvedRegions,
98
+ RuntimeStoryAnchoredObject,
79
99
  RuntimeTwipsRect,
80
100
  } from "../../model/layout/page-graph-types.ts";
81
101
  import type {
@@ -90,6 +110,19 @@ import type {
90
110
 
91
111
  let graphRevision = 0;
92
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
+
93
126
  export function buildPageGraph(input: BuildPageGraphInput): RuntimePageGraph;
94
127
  export function buildPageGraph(
95
128
  pages: readonly DocumentPageSnapshot[],
@@ -114,6 +147,7 @@ export function buildPageGraph(
114
147
  const pages: RuntimePageNode[] = [];
115
148
  const aggregatedFragments: RuntimeBlockFragment[] = [...(input.fragments ?? [])];
116
149
  const pageIdByGlobalPageIndex = new Map<number, string>();
150
+ const pageFieldCounts = buildPageFieldCounts(input.pages);
117
151
  for (let index = 0; index < input.pages.length; index += 1) {
118
152
  const page = input.pages[index]!;
119
153
  pageIdByGlobalPageIndex.set(page.pageIndex, `page-${graphRevision}-${index}`);
@@ -180,17 +214,22 @@ export function buildPageGraph(
180
214
  stories.displayPageNumber,
181
215
  );
182
216
  const regions = buildRegions(page.layout, bodyPageFragments, stories, pageNoteAllocations);
183
- const divergences = detectFrameDivergences(frameId, regions);
184
- const frame = buildPageFrame({
217
+ const frameDivergences = detectFrameDivergences(frameId, regions);
218
+ const builtFrame = buildPageFrame({
185
219
  frameId,
186
220
  pageId,
187
221
  pageIndex: page.pageIndex,
188
222
  sectionIndex: page.sectionIndex,
189
223
  displayPageNumber: stories.displayPageNumber,
190
224
  layout: page.layout,
225
+ stories,
191
226
  regions,
192
- divergences,
227
+ divergences: frameDivergences,
228
+ subParts: input.subParts,
229
+ pageFieldCounts,
193
230
  });
231
+ const divergences = builtFrame.divergences;
232
+ const frame = builtFrame.frame;
194
233
 
195
234
  const node: RuntimePageNode = {
196
235
  pageId,
@@ -383,9 +422,12 @@ function buildPageFrame(input: {
383
422
  sectionIndex: number;
384
423
  displayPageNumber: number;
385
424
  layout: PageLayoutSnapshot;
425
+ stories: ResolvedPageStories;
386
426
  regions: RuntimePageRegions;
387
427
  divergences: readonly RuntimeLayoutDivergence[];
388
- }): RuntimePageFrame {
428
+ subParts?: BuildPageGraphInput["subParts"];
429
+ pageFieldCounts: PageFieldCounts;
430
+ }): { frame: RuntimePageFrame; divergences: RuntimeLayoutDivergence[] } {
389
431
  const regions: RuntimeResolvedRegions = {
390
432
  body: input.regions.body,
391
433
  exclusionZones: [],
@@ -394,18 +436,587 @@ function buildPageFrame(input: {
394
436
  ...(input.regions.columns ? { columns: input.regions.columns } : {}),
395
437
  ...(input.regions.footnotes ? { footnotes: input.regions.footnotes } : {}),
396
438
  };
397
- const divergenceIds = input.divergences.map((d) => d.divergenceId);
398
- return {
439
+ const pageLocalStoryResult = buildPageLocalStoryInstances({
399
440
  frameId: input.frameId,
400
441
  pageId: input.pageId,
401
442
  pageIndex: input.pageIndex,
402
443
  sectionIndex: input.sectionIndex,
403
444
  displayPageNumber: input.displayPageNumber,
404
- physicalBoundsTwips: rect(0, 0, input.layout.pageWidth, input.layout.pageHeight),
445
+ layout: input.layout,
446
+ stories: input.stories,
405
447
  regions,
406
- divergenceIds,
407
- signature: buildPageFrameSignature(input, divergenceIds),
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
+ }
408
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";
409
1020
  }
410
1021
 
411
1022
  function buildPageFrameId(
@@ -424,6 +1035,7 @@ function buildPageFrameSignature(
424
1035
  layout: PageLayoutSnapshot;
425
1036
  regions: RuntimePageRegions;
426
1037
  },
1038
+ pageLocalStories: readonly RuntimePageLocalStoryInstance[],
427
1039
  divergenceIds: readonly string[],
428
1040
  ): string {
429
1041
  const regionParts = collectRegions(input.regions).map((region) => {
@@ -445,6 +1057,7 @@ function buildPageFrameSignature(
445
1057
  input.layout.pageWidth,
446
1058
  input.layout.pageHeight,
447
1059
  ...regionParts,
1060
+ ...pageLocalStories.map((story) => story.signature),
448
1061
  ...divergenceIds,
449
1062
  ].join("|");
450
1063
  }
@@ -654,10 +1267,11 @@ export function spliceGraph(
654
1267
  }));
655
1268
 
656
1269
  const contentPageCount = nextPages.filter((p) => !p.isBlankFiller).length;
1270
+ const normalizedPages = normalizePageLocalStoryFieldsForPages(nextPages);
657
1271
 
658
1272
  return {
659
1273
  revision: graphRevision,
660
- pages: nextPages,
1274
+ pages: normalizedPages,
661
1275
  fragments: mergedFragments,
662
1276
  anchors,
663
1277
  sections: [...prior.sections],
@@ -665,6 +1279,77 @@ export function spliceGraph(
665
1279
  };
666
1280
  }
667
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
+
668
1353
  /**
669
1354
  * Compare two `RuntimePageNode`s by the fields that matter for
670
1355
  * pagination identity — if these all match, the fresh node represents