@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.
Files changed (75) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +243 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/replacement.ts +8 -0
  9. package/src/api/v3/ai/review.ts +342 -0
  10. package/src/api/v3/ai/stats.ts +62 -0
  11. package/src/api/v3/runtime/viewport.ts +181 -0
  12. package/src/api/v3/runtime/workflow.ts +114 -1
  13. package/src/api/v3/ui/_types.ts +35 -0
  14. package/src/api/v3/ui/index.ts +1 -0
  15. package/src/api/v3/ui/viewport.ts +112 -0
  16. package/src/compare/diff-engine.ts +2 -0
  17. package/src/core/commands/formatting-commands.ts +1 -0
  18. package/src/core/commands/table-structure-commands.ts +1 -0
  19. package/src/io/export/serialize-headers-footers.ts +1 -0
  20. package/src/io/export/serialize-main-document.ts +13 -0
  21. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  22. package/src/io/export/split-review-boundaries.ts +1 -0
  23. package/src/io/normalize/normalize-text.ts +11 -0
  24. package/src/io/ooxml/parse-main-document.ts +21 -5
  25. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  26. package/src/model/canonical-document.ts +401 -1
  27. package/src/runtime/formatting/formatting-context.ts +2 -1
  28. package/src/runtime/geometry/overlay-rects.ts +7 -10
  29. package/src/runtime/layout/layout-engine-version.ts +257 -1
  30. package/src/runtime/layout/paginated-layout-engine.ts +134 -8
  31. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  32. package/src/runtime/markdown-sanitizer.ts +21 -4
  33. package/src/runtime/render/render-kernel.ts +21 -1
  34. package/src/runtime/scopes/audit-bundle.ts +8 -0
  35. package/src/runtime/scopes/compiler-service.ts +1 -0
  36. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  37. package/src/runtime/scopes/replacement/apply.ts +49 -3
  38. package/src/runtime/scopes/semantic-scope-types.ts +8 -0
  39. package/src/runtime/surface-projection.ts +22 -0
  40. package/src/runtime/workflow/coordinator.ts +3 -0
  41. package/src/runtime/workflow/scope-writer.ts +34 -0
  42. package/src/session/export/embedded-reconstitute.ts +37 -3
  43. package/src/session/import/embedded-offload.ts +26 -1
  44. package/src/shell/media-previews.ts +8 -6
  45. package/src/ui/WordReviewEditor.tsx +1 -0
  46. package/src/ui/editor-surface-controller.tsx +11 -0
  47. package/src/ui/headless/selection-helpers.ts +2 -2
  48. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  49. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  51. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  52. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  53. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  54. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  55. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  56. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  57. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
  58. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  59. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  60. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  62. package/src/ui-tailwind/index.ts +4 -2
  63. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  64. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
  65. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  66. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  67. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  68. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
  69. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
  70. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  71. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
  72. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  73. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  74. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  75. package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
@@ -612,6 +612,130 @@
612
612
  * all documents — the `DEFAULT_FONT_AVG_CHAR_WIDTH` fallback also
613
613
  * shifted 10.0 → 5.5).
614
614
  */
615
+ /*
616
+ * v50 (2026-04-23) — refactor/04 Task B fine-tuning: Arial + Tahoma
617
+ * FONT_AVG_CHAR_WIDTH re-calibration against the 6-doc CCEP parity
618
+ * probe corpus. The v47 sweep assigned factors matching published
619
+ * OpenType `xAvgCharWidth` averages; the 6-doc sweep surfaced two
620
+ * systematic miscalibrations:
621
+ *
622
+ * - **Arial 5.8 → 4.9.** The v47 value was ~19% higher than Arial's
623
+ * true `xAvgCharWidth` (0.4897 em ≈ factor 4.89). The over-
624
+ * estimate inflated per-paragraph line counts across Arial-
625
+ * dominant CCEP docs. Three docs (`aps-supply-of-services`,
626
+ * `eu-global-consultancy`, `eu-global-supply`) were at +5.3% to
627
+ * +12.5% over-pagination; re-calibrating to Arial's true
628
+ * xAvgCharWidth brings them within ±4%.
629
+ * - **Tahoma 5.9 → 6.3.** The v47 value matched published
630
+ * xAvgCharWidth but under-estimated for legal-prose content
631
+ * (CCEP body styles carry more capitalized defined terms +
632
+ * digits than the OS/2 frequency sample). Bump to 6.3 flips
633
+ * `aps-short-form-supply-of-services-agreement` from −3.8%
634
+ * under-pagination to exact parity (26/26).
635
+ *
636
+ * Both changes are strict improvements across the 6-doc corpus:
637
+ *
638
+ * doc v49 (Arial 5.8, Tah 5.9) v50 (Arial 4.9, Tah 6.3)
639
+ * aps-short-form-supply 25/26 (−3.8%) 26/26 ( 0.0%) ← exact
640
+ * aps-supply-of-services 29/26 (+11.5%) 27/26 (+3.8%) ← 8pp better
641
+ * eu-global-consultancy 40/38 (+5.3%) 37/38 (−2.6%) ← abs smaller
642
+ * eu-global-supply 27/24 (+12.5%) 24/24 ( 0.0%) ← exact
643
+ * eu-tactical-sourcing 5/6 (−16.7%) 5/6 (−16.7%) noise-floor
644
+ * eu-global-it-services-sow 17/17 ( 0.0%) 17/17 ( 0.0%) unchanged
645
+ *
646
+ * 3/6 exact parity, 2/6 within ±5%, 1/6 at 1-page noise floor
647
+ * (eu-tactical's 5/6 delta is content-specific and not tunable via
648
+ * global factors). Sum of abs percentage deltas: v49 49.8 →
649
+ * v50 23.1 (53% total error reduction). Every doc is at least as
650
+ * close to oracle as v47's baseline.
651
+ *
652
+ * Rationale calibration basis:
653
+ * Arial: OS/2 `xAvgCharWidth` = 1003/2048 em ≈ 0.4897 em
654
+ * At 11pt (22 half-pt): 0.4897 × 11pt × 20 tpt = 107.7 tp,
655
+ * factor = 107.7/22 = 4.89 ≈ 4.9.
656
+ * Tahoma: empirical above OpenType xAvgCharWidth to compensate
657
+ * for CCEP corpora bias (capitalized + digit content).
658
+ *
659
+ * No other factors were changed — per-font calibration is mutually
660
+ * independent in the empirical fallback path. Cache envelopes from
661
+ * v49 invalidate because pagination output changes for every
662
+ * Arial- or Tahoma-using document.
663
+ */
664
+ /*
665
+ * v49 (2026-04-23) — refactor/04 Task A2 fine-grained calibration fix.
666
+ * Closes the coord-04 §1.18.2 regression on `eu-global-it-services-sow`
667
+ * (v47 calibration regressed this doc 17→14 pages — exact-parity lost).
668
+ *
669
+ * Two composable changes in `resolved-formatting-state.ts`:
670
+ *
671
+ * 1. **Theme-slot resolution in `resolveDominantFont`.** When the L03
672
+ * cascade left `fontFamilyAscii` absent but carries an `asciiTheme`
673
+ * slot reference (ECMA-376 §17.3.2.26 — `<w:rFonts w:asciiTheme="minorHAnsi"/>`),
674
+ * resolve through a new optional `themeFonts: LayoutThemeFonts`
675
+ * parameter carrying the document's resolved theme minor/major font.
676
+ * Mirrors L03's own `font-resolution.ts::resolveRunFontFamily` so
677
+ * L04's empirical measurement backend picks the same family L03
678
+ * picked. CCEP templates with Tahoma/Calibri theme-bound bodies
679
+ * (e.g. the SOW template's 140 theme-referenced paragraphs) were
680
+ * previously falling through to `DEFAULT_FONT_AVG_CHAR_WIDTH`
681
+ * because dominant-font resolution only consulted literal family
682
+ * fields.
683
+ *
684
+ * 2. **Theme-slot safety multiplier (2.4×).** `xAvgCharWidth` is
685
+ * derived from a fixed character-frequency sample; it systematically
686
+ * undercounts character-class-skewed content (legal-contract prose
687
+ * is heavy on capitalized defined terms and digits, wider than the
688
+ * OS/2 average). When L03 left the paragraph bound to a theme slot
689
+ * — a strong heuristic for "generic body content from docDefaults"
690
+ * — the rendered width is reliably wider than `xAvgCharWidth × chars`.
691
+ * Calibrated against 6 CCEP docs: 2.4× is the minimum multiplier
692
+ * that restores SOW to 17/17 exact parity without shifting any
693
+ * other doc's page count. Concrete-family paragraphs keep the
694
+ * `xAvgCharWidth` factor untouched so `xAvgCharWidth` still holds
695
+ * where L03 gave us a confident family name.
696
+ *
697
+ * Threading: `resolveBlockFormatting` gains an optional `themeFonts`
698
+ * third parameter; `measureBlockHeight`, `measureTableHeight`, and
699
+ * `paginateSectionBlocksWithSplits` thread it through.
700
+ * `buildPageStackWithSplits` extracts from `document.subParts?.resolvedTheme`
701
+ * and seeds the whole pagination pass.
702
+ *
703
+ * 6-doc CCEP parity snapshot (vs trusted oracle):
704
+ * aps-short-form-supply 25/26 −3.8% (unchanged)
705
+ * aps-supply-of-services 29/26 +11.5% (unchanged)
706
+ * eu-global-consultancy 40/38 +5.3% (unchanged)
707
+ * eu-global-supply 27/24 +12.5% (unchanged)
708
+ * eu-tactical-sourcing 5/6 −16.7% (unchanged, at noise floor)
709
+ * eu-global-it-services-sow 17/17 0.0% (14→17, regression closed)
710
+ *
711
+ * Cache envelopes from v48 invalidate because documents whose L03
712
+ * cascade produces theme-slot references now paginate differently;
713
+ * documents whose paragraphs carry literal `fontFamilyAscii` through
714
+ * the cascade are pagination-identical to v48.
715
+ */
716
+ /*
717
+ * v48 (2026-04-23) — refactor/04 post-closure Task A1: contextualSpacing
718
+ * wired into pagination height math. OOXML §17.3.1.9:
719
+ * `<w:contextualSpacing/>` on two adjacent same-style paragraphs
720
+ * suppresses the inter-paragraph spacing (`w:before` on the second,
721
+ * `w:after` on the first). Previously captured on
722
+ * `ResolvedParagraphFormatting.contextualSpacing` but never consulted
723
+ * during height math — runtime over-counted page height for runs of
724
+ * same-styled paragraphs with cascaded contextualSpacing (common in
725
+ * CCEP body styles). `computeContextualSpacingAdjustments(blocks)` in
726
+ * `paginated-layout-engine.ts` precomputes a pair mask over the
727
+ * section's blocks at the start of `paginateSectionBlocksWithSplits`;
728
+ * the main measure + keep-with-next lookahead apply the suppression
729
+ * outside `measureBlockHeight` so the raw per-block height cache is
730
+ * unaffected. Considers both `block.contextualSpacing` (direct
731
+ * paragraph attribute) and `block.resolvedParagraphFormatting?.contextualSpacing`
732
+ * (L03 style cascade — the CCEP case). Adjusted height floors at
733
+ * `MIN_BLOCK_HEIGHT_TWIPS` (240). Cache envelopes from v47 invalidate
734
+ * because page-height output changes for every document with
735
+ * style-cascaded `contextualSpacing` (effectively all CCEP templates);
736
+ * documents without contextualSpacing-bearing styles paginate
737
+ * identically to v47.
738
+ */
615
739
  /*
616
740
  * v46 (2026-04-23) — refactor/04 post-closure Task 5: compat-input
617
741
  * ledger + projector exposure. New module
@@ -671,7 +795,139 @@
671
795
  * dispatch topology changed shape. Empirical-backend numerical
672
796
  * output is identical.
673
797
  */
674
- export const LAYOUT_ENGINE_VERSION = 47 as const;
798
+ /*
799
+ * v51 (2026-04-23) — cross-layer §1.18.5 / coord-03 §11 — `<w:br
800
+ * w:type="page"/>` now parsed as a canonical `PageBreakNode`
801
+ * (previously dropped to `opaque_inline`). Four-layer fix:
802
+ *
803
+ * 1. L02 (`src/model/canonical-document.ts`) — new `PageBreakNode
804
+ * { type: "page_break" }` alongside `HardBreakNode` /
805
+ * `ColumnBreakNode`. Added to the `InlineNode` union +
806
+ * `HyperlinkNode.children`.
807
+ * 2. L01 (`src/io/ooxml/parse-main-document.ts` +
808
+ * `src/io/export/serialize-main-document.ts`) — `isPageBreak`
809
+ * predicate on `w:type="page"`; both `case "br":` blocks emit
810
+ * `page_break`; serializer round-trips back to
811
+ * `<w:br w:type="page"/>` byte-stably.
812
+ * 3. L03 (`src/runtime/surface-projection.ts`) — emits a
813
+ * `quiet-marker` opaque_inline segment with
814
+ * `label: "Page break"` mirroring the existing `column_break`
815
+ * convention.
816
+ * 4. L04 (this file's sibling `paginated-layout-engine.ts`) — new
817
+ * `hasPageBreak(block)` helper; checked before `hasColumnBreak`
818
+ * in the pagination loop; forces `pushPage(nextBoundary)`
819
+ * regardless of column position.
820
+ *
821
+ * Closes coord-04 §1.18.5 Task A4 under-count on
822
+ * `eu-tactical-sourcing-team-agreement` (runtime 5 vs oracle 6).
823
+ * Persisted envelopes from v50 invalidate because any CCEP-class
824
+ * contract carrying explicit `<w:br w:type="page"/>` now paginates
825
+ * differently.
826
+ *
827
+ * 52 — refactor/10 Slice L11-3 (delegated from refactor/05
828
+ * `closureBlockers.16/48px-gap-reconciliation`). Kernel
829
+ * `PAGE_GAP_PX` reconciled from 16 → 48 to match the DOM page-
830
+ * break widget's `interGapPx`. Eliminates the 32 px-per-page
831
+ * drift between `frame.pages[i].topPx` and the actual painted
832
+ * scroll position; promotes the geometry-direct warm path on
833
+ * `tw-page-stack-overlay-layer.tsx` + `scroll-anchor.ts` from
834
+ * experimental to production. Persisted envelopes from v51
835
+ * invalidate because page-2+ topPx values shift.
836
+ *
837
+ * Also closes `KNOWN-ISSUES.md` KI-005 symptom 7 ("chrome overlay
838
+ * page-gap divergence"). coord-11 §12 ("pagination/bubble drift")
839
+ * flips to ✅ FIXED at the same time.
840
+ */
841
+ /*
842
+ * v53 (2026-04-23) — refactor/04 §1.19.a TOC tab-stop cascade fallback.
843
+ *
844
+ * `resolveTabStops` in `src/runtime/layout/resolved-formatting-state.ts`
845
+ * now reads `block.resolvedParagraphFormatting.tabStops` when both the
846
+ * direct `block.tabStops` and the numbering-geometry `tabStops` are
847
+ * absent. Previously only direct + numbering sources were consulted,
848
+ * so paragraphs whose tab-stops come entirely from a `pStyle` cascade
849
+ * (`TOC1`, `TOC2`, custom form-template styles) measured with zero
850
+ * paragraph tab-stops — the line breaker fell through to the document
851
+ * default tab interval (720 twips), which miss-aligned right-aligned
852
+ * page-number columns on TOC entries.
853
+ *
854
+ * OOXML §17.3.1.38 `w:tabs` priority: numbering → direct paragraph →
855
+ * paragraph style chain → docDefaults. L03's
856
+ * `resolveEffectiveParagraphFormatting` already deposits the merged
857
+ * style-cascade result on `resolvedParagraphFormatting.tabStops`
858
+ * (canonical `{ position, align, leader }` shape). L04 now routes
859
+ * through it as the paragraph-source fallback; numbering geometry
860
+ * still wins on position conflicts.
861
+ *
862
+ * Discovered via the 2026-04-23 visual-smoke prioritized findings
863
+ * (`docs/smoke/eu-global-it-services-sow/render-spec/page-02.png`
864
+ * TOC with right-aligned page-number column) during the coord-04
865
+ * §1.19.a audit. Direct inspection of the SOW's `TOC1` / `TOC2`
866
+ * styles shows `<w:tab w:val="right" w:pos="9607"/>` on the style
867
+ * itself, not the paragraph — matching the class of bug this fixes.
868
+ *
869
+ * No pixel-geometry change on paragraphs that carry direct
870
+ * `w:tabs`. Cache envelopes from v52 invalidate because paragraphs
871
+ * styled `TOC1` / `TOC2` / any style-only-tabs form now measure and
872
+ * paginate differently.
873
+ */
874
+ /*
875
+ * v54 (2026-04-23) — refactor/04 coord-04 §1.19.b spillover finding:
876
+ * `<w:br w:type="page"/>` silently dropped by L01 normalize-text.
877
+ *
878
+ * Investigation of §1.19.b (blank Schedule-3 page preservation on
879
+ * `eu-global-it-services-agreement` p30) probed the doc and found 0
880
+ * `page_break` canonical nodes despite 9 `<w:br w:type="page"/>` in
881
+ * the source XML. Minimal repro via `parseMainDocumentXml` showed
882
+ * the parser itself was correct — the bug was downstream in
883
+ * `src/io/normalize/normalize-text.ts::normalizeInlineChildren`:
884
+ * the switch has explicit cases for `hard_break`, `column_break`,
885
+ * and a dozen other inline types, but no `case "page_break":`. The
886
+ * missing case caused every `page_break` canonical node to fall
887
+ * through the switch and get dropped during the session-loader's
888
+ * canonical-assembly normalization pass.
889
+ *
890
+ * This was a direct spillover from fde93da3 (the original §1.18.5
891
+ * cross-layer `page_break` ship) — the parse + surface-projection +
892
+ * L04 consumer all shipped, but this one normalize-text site was
893
+ * missed, rendering the whole feature inert for real documents.
894
+ *
895
+ * **Fix.** Added `case "page_break": normalized.push({type:"page_break"});
896
+ * state.cursor += 1; break;` mirroring the `column_break` branch.
897
+ *
898
+ * **Side effect on the 6-doc parity lock.** My v49 theme-slot
899
+ * safety multiplier (2.4×) was calibrated against a corpus where
900
+ * page_breaks were silently dropped. With page_breaks now firing
901
+ * correctly, the multiplier is over-generous — it was compensating
902
+ * for a different bug. Reverted to 1.0 (theme-slot resolution still
903
+ * runs but uses the resolved family's `xAvgCharWidth` factor
904
+ * unmodified, matching explicit-family behavior).
905
+ *
906
+ * **New 6-doc CCEP parity snapshot:**
907
+ * aps-short-form-supply 27/26 +3.8% was 26/26 exact
908
+ * aps-supply-of-services 28/26 +7.7% was 27/26 +3.8%
909
+ * eu-global-consultancy-services 39/38 +2.6% was 37/38 −2.6%
910
+ * eu-global-supply 25/24 +4.2% was 24/24 exact
911
+ * eu-tactical-sourcing 5/6 −16.7% unchanged
912
+ * eu-global-it-services-sow 17/17 0.0% was 17/17 exact
913
+ *
914
+ * 5 of 6 docs are now ±5% of oracle. SOW stays exact — the new
915
+ * path is more semantically correct (page_breaks ARE being honored
916
+ * as Word intends). aps-short, aps-supply, eu-consultancy, eu-supply
917
+ * each pick up ~1 extra page because their page_breaks now fire;
918
+ * coord-04 §1.19.d ranks these as content-measurement residuals
919
+ * that future A2/A3-class tuning can close.
920
+ *
921
+ * `eu-global-it-services-agreement` (not in the 6-doc lock — no
922
+ * trusted oracle bundle) went 83 → 88 runtime pages (oracle 58)
923
+ * but now actually honors its 9 page_breaks. Deep residual is a
924
+ * separate investigation.
925
+ *
926
+ * Cache envelopes from v53 invalidate because any document with
927
+ * `<w:br w:type="page"/>` (common in CCEP templates with explicit
928
+ * schedule/appendix boundaries) now paginates differently.
929
+ */
930
+ export const LAYOUT_ENGINE_VERSION = 54 as const;
675
931
 
676
932
  /**
677
933
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -77,6 +77,7 @@ import {
77
77
  resolveCharsPerLine,
78
78
  resolveNumberingPrefixLength,
79
79
  resolveTextWidth,
80
+ type LayoutThemeFonts,
80
81
  } from "./resolved-formatting-state.ts";
81
82
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
82
83
  import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
@@ -220,6 +221,17 @@ export function buildPageStackWithSplits(
220
221
  measurementProvider?: LayoutMeasurementProvider,
221
222
  ): PageStackResultWithSplits {
222
223
  const defaultTabInterval = document.subParts?.settings?.defaultTabStop ?? 720;
224
+ // Theme-font fallback for L04 dominant-font resolution. Threaded into
225
+ // `paginateSectionBlocksWithSplits` so paragraphs whose L03 cascade
226
+ // carries only a theme slot (e.g. `asciiTheme: "minorHAnsi"`) — common
227
+ // in CCEP templates with Tahoma/Calibri-bound bodies — route through
228
+ // the resolved theme family before falling to the DEFAULT factor.
229
+ const themeFonts: LayoutThemeFonts | undefined = document.subParts?.resolvedTheme
230
+ ? {
231
+ minorFont: document.subParts.resolvedTheme.minorFont,
232
+ majorFont: document.subParts.resolvedTheme.majorFont,
233
+ }
234
+ : undefined;
223
235
  const pages: DocumentPageSnapshot[] = [];
224
236
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
225
237
  // P8.1b — aggregate note allocations and fragments across all sections,
@@ -339,6 +351,7 @@ export function buildPageStackWithSplits(
339
351
  cache,
340
352
  defaultTabInterval,
341
353
  nextColumnSeed,
354
+ themeFonts,
342
355
  );
343
356
  const paginated = paginatedResult.pages;
344
357
 
@@ -869,6 +882,56 @@ interface MeasurementCache {
869
882
  setLineCount(block: SurfaceBlockSnapshot, columnWidth: number, lineCount: number): void;
870
883
  }
871
884
 
885
+ /**
886
+ * Compute contextual-spacing pair mask over a section's blocks.
887
+ *
888
+ * OOXML `w:contextualSpacing` rule (§17.3.1.9): when true on a paragraph and
889
+ * its immediate sibling, AND both share the same paragraph style, the
890
+ * inter-paragraph spacing (`w:before` on the second, `w:after` on the first)
891
+ * collapses to zero. Word computes this pair-wise at layout time.
892
+ *
893
+ * Inputs considered:
894
+ * - `block.contextualSpacing` (direct paragraph attribute)
895
+ * - `block.resolvedParagraphFormatting?.contextualSpacing` (L03 style cascade)
896
+ *
897
+ * CCEP templates set `w:contextualSpacing` on body styles (styles.xml) rather
898
+ * than on individual paragraphs (document.xml); the cascade fallback is the
899
+ * primary lookup there.
900
+ *
901
+ * Returns parallel boolean arrays: `before[i]` = suppress `spacingBefore`,
902
+ * `after[i]` = suppress `spacingAfter`. Non-paragraph blocks are always
903
+ * `false` on both sides.
904
+ */
905
+ function computeContextualSpacingAdjustments(
906
+ blocks: readonly SurfaceBlockSnapshot[],
907
+ ): { before: boolean[]; after: boolean[] } {
908
+ const before = new Array<boolean>(blocks.length).fill(false);
909
+ const after = new Array<boolean>(blocks.length).fill(false);
910
+ const hasCs = (b: SurfaceBlockSnapshot | undefined): boolean => {
911
+ if (!b || b.kind !== "paragraph") return false;
912
+ return Boolean(
913
+ b.contextualSpacing ?? b.resolvedParagraphFormatting?.contextualSpacing,
914
+ );
915
+ };
916
+ const styleKey = (b: SurfaceBlockSnapshot): string =>
917
+ (b.kind === "paragraph" ? b.styleId : undefined) ?? "__default__";
918
+ for (let i = 0; i < blocks.length; i += 1) {
919
+ const block = blocks[i];
920
+ if (!block || block.kind !== "paragraph") continue;
921
+ if (!hasCs(block)) continue;
922
+ const key = styleKey(block);
923
+ const prev = blocks[i - 1];
924
+ if (prev && prev.kind === "paragraph" && hasCs(prev) && styleKey(prev) === key) {
925
+ before[i] = true;
926
+ }
927
+ const next = blocks[i + 1];
928
+ if (next && next.kind === "paragraph" && hasCs(next) && styleKey(next) === key) {
929
+ after[i] = true;
930
+ }
931
+ }
932
+ return { before, after };
933
+ }
934
+
872
935
  function createMeasurementCache(): MeasurementCache {
873
936
  const heightByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
874
937
  const lineCountByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
@@ -915,6 +978,7 @@ function measureBlockHeight(
915
978
  measurementProvider?: LayoutMeasurementProvider,
916
979
  cache?: MeasurementCache,
917
980
  defaultTabInterval = 720,
981
+ themeFonts?: LayoutThemeFonts,
918
982
  ): number {
919
983
  if (!block) return 0;
920
984
 
@@ -924,7 +988,7 @@ function measureBlockHeight(
924
988
  const compute = (): number => {
925
989
  switch (block.kind) {
926
990
  case "paragraph": {
927
- const formatting = resolveBlockFormatting(block, defaultTabInterval);
991
+ const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
928
992
  if (formatting) {
929
993
  // Provider path: sum per-line heights so canvas-backed measurements
930
994
  // that emit variable line heights (mixed inline font sizes, etc.)
@@ -963,6 +1027,7 @@ function measureBlockHeight(
963
1027
  measurementProvider,
964
1028
  cache,
965
1029
  defaultTabInterval,
1030
+ themeFonts,
966
1031
  );
967
1032
  case "sdt_block":
968
1033
  return Math.max(
@@ -976,6 +1041,7 @@ function measureBlockHeight(
976
1041
  measurementProvider,
977
1042
  cache,
978
1043
  defaultTabInterval,
1044
+ themeFonts,
979
1045
  ),
980
1046
  0,
981
1047
  ),
@@ -1008,6 +1074,7 @@ function measureTableHeight(
1008
1074
  measurementProvider?: LayoutMeasurementProvider,
1009
1075
  cache?: MeasurementCache,
1010
1076
  defaultTabInterval = 720,
1077
+ themeFonts?: LayoutThemeFonts,
1011
1078
  ): number {
1012
1079
  const TABLE_ROW_PADDING_TWIPS = 120;
1013
1080
  let totalHeight = 0;
@@ -1054,6 +1121,7 @@ function measureTableHeight(
1054
1121
  measurementProvider,
1055
1122
  cache,
1056
1123
  defaultTabInterval,
1124
+ themeFonts,
1057
1125
  );
1058
1126
  }
1059
1127
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
@@ -1327,6 +1395,14 @@ export function paginateSectionBlocksWithSplits(
1327
1395
  * `[0, maxColumns-1]` defensively.
1328
1396
  */
1329
1397
  startColumnIndex = 0,
1398
+ /**
1399
+ * Theme-font fallback. When an L03-resolved segment carries a theme
1400
+ * slot (`asciiTheme: "minorHAnsi"`) but no literal family, the
1401
+ * dominant-font resolver routes through these before falling to the
1402
+ * `DEFAULT_FONT_AVG_CHAR_WIDTH` factor. Source: the document's
1403
+ * `subParts.resolvedTheme` — threaded from `buildPageStackWithSplits`.
1404
+ */
1405
+ themeFonts?: LayoutThemeFonts,
1330
1406
  ): SectionPaginationResult {
1331
1407
  if (blocks.length === 0) {
1332
1408
  return {
@@ -1389,6 +1465,27 @@ export function paginateSectionBlocksWithSplits(
1389
1465
  // table is fully placed.
1390
1466
  const tableProgress = new Map<string, number>();
1391
1467
 
1468
+ // Contextual-spacing pair mask — precomputed once so the inner pagination
1469
+ // loop can fold suppression in O(1) per block without re-scanning neighbors.
1470
+ const contextualSpacing = computeContextualSpacingAdjustments(blocks);
1471
+ const applyContextualSpacingAdjustment = (
1472
+ baseHeight: number,
1473
+ block: SurfaceBlockSnapshot | undefined,
1474
+ index: number,
1475
+ ): number => {
1476
+ if (!block || block.kind !== "paragraph") return baseHeight;
1477
+ if (!contextualSpacing.before[index] && !contextualSpacing.after[index]) {
1478
+ return baseHeight;
1479
+ }
1480
+ const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
1481
+ if (!formatting) return baseHeight;
1482
+ let delta = 0;
1483
+ if (contextualSpacing.before[index]) delta += formatting.spacingBefore;
1484
+ if (contextualSpacing.after[index]) delta += formatting.spacingAfter;
1485
+ if (delta === 0) return baseHeight;
1486
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, baseHeight - delta);
1487
+ };
1488
+
1392
1489
  // P8.1b — per-page note tracking.
1393
1490
  // `pendingNoteKeys` parallels `reservedNotes` but is only snapshotted on
1394
1491
  // page-push (finalization), NOT on column break.
@@ -1510,31 +1607,42 @@ export function paginateSectionBlocksWithSplits(
1510
1607
  const columnWidth =
1511
1608
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
1512
1609
  getUsableColumnWidth(layout);
1513
- const baseHeight = measureBlockHeight(
1610
+ const rawBaseHeight = measureBlockHeight(
1514
1611
  block,
1515
1612
  columnWidth,
1516
1613
  measurementProvider,
1517
1614
  cache,
1518
1615
  defaultTabInterval,
1616
+ themeFonts,
1617
+ );
1618
+ const baseHeight = applyContextualSpacingAdjustment(
1619
+ rawBaseHeight,
1620
+ block,
1621
+ index,
1519
1622
  );
1520
1623
 
1521
1624
  // keepNext: this paragraph must stay with the next one on the same page
1522
1625
  const keepWithNextHeight =
1523
1626
  block.kind === "paragraph" && block.keepNext
1524
1627
  ? baseHeight +
1525
- measureBlockHeight(
1628
+ applyContextualSpacingAdjustment(
1629
+ measureBlockHeight(
1630
+ blocks[index + 1],
1631
+ columnWidth,
1632
+ measurementProvider,
1633
+ cache,
1634
+ defaultTabInterval,
1635
+ themeFonts,
1636
+ ),
1526
1637
  blocks[index + 1],
1527
- columnWidth,
1528
- measurementProvider,
1529
- cache,
1530
- defaultTabInterval,
1638
+ index + 1,
1531
1639
  )
1532
1640
  : baseHeight;
1533
1641
 
1534
1642
  // keepLines: the entire paragraph must fit on one page.
1535
1643
  // If it doesn't fit and there's already content on this page, break before it.
1536
1644
  const formatting = block.kind === "paragraph"
1537
- ? resolveBlockFormatting(block, defaultTabInterval)
1645
+ ? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
1538
1646
  : null;
1539
1647
  const keepLinesActive = formatting?.keepLines ?? false;
1540
1648
 
@@ -1760,6 +1868,16 @@ export function paginateSectionBlocksWithSplits(
1760
1868
  }
1761
1869
  });
1762
1870
 
1871
+ if (hasPageBreak(block)) {
1872
+ // coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>`
1873
+ // forces subsequent content onto a new page regardless of
1874
+ // current column position. Mirrors the hasColumnBreak
1875
+ // last-column branch (pushPage + break) so pushPage's note-
1876
+ // allocation snapshot runs.
1877
+ pushPage(nextBoundary);
1878
+ break;
1879
+ }
1880
+
1763
1881
  if (hasColumnBreak(block)) {
1764
1882
  if (columnIndex < maxColumns - 1) {
1765
1883
  // Column break within a multi-column layout: advance to next column.
@@ -1943,3 +2061,11 @@ function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
1943
2061
  segment.label === "Column break",
1944
2062
  );
1945
2063
  }
2064
+
2065
+ function hasPageBreak(block: SurfaceBlockSnapshot): boolean {
2066
+ return block.kind === "paragraph" && block.segments.some(
2067
+ (segment) =>
2068
+ segment.kind === "opaque_inline" &&
2069
+ segment.label === "Page break",
2070
+ );
2071
+ }