@beyondwork/docx-react-component 1.0.71 → 1.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -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/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -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,160 @@
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
+ * 55 — coord-04 §1.19.d. L03 (`ca553b1c` 2026-04-23) graduated
931
+ * `SurfaceBlockSnapshot.paragraph.frameProperties` from the canonical
932
+ * `<w:framePr>` model (L01 parse + L02 domain shape already shipped).
933
+ * L04 now honors it: paragraphs carrying out-of-flow frame properties
934
+ * (ECMA-376 §17.3.1.11 — `hAnchor` / `vAnchor` / `xAlign` / `yAlign` /
935
+ * `xTwips` / `yTwips` positioning with text wrapping around) return
936
+ * 0 from `measureBlockHeight` so the pagination flow does not
937
+ * double-count them. The `dropCap="drop"` / `dropCap="margin"` case
938
+ * is excluded — those frame only the initial letter, leaving the
939
+ * rest of the paragraph in the main flow. L11 owns the absolute-
940
+ * positioned render.
941
+ *
942
+ * CCEP parity-lock corpus unaffected: no body-level `<w:framePr>`
943
+ * exists in the 6-doc lock (`EnvelopeAddress` style carries a
944
+ * framePr in `EU & Global Consultancy Services Agreement` but is
945
+ * never referenced by a paragraph). Change is a forward-looking
946
+ * correctness fix for the cross-layer chain now that L01→L02→L03→L04
947
+ * all emit/honor framePr end-to-end. Cache envelopes from v54
948
+ * invalidate because any future document with a real body framePr
949
+ * paginates differently.
950
+ */
951
+ export const LAYOUT_ENGINE_VERSION = 55 as const;
675
952
 
676
953
  /**
677
954
  * 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,12 @@ function measureBlockHeight(
924
988
  const compute = (): number => {
925
989
  switch (block.kind) {
926
990
  case "paragraph": {
927
- const formatting = resolveBlockFormatting(block, defaultTabInterval);
991
+ // §1.19.d out-of-flow framed paragraphs (ECMA-376 §17.3.1.11)
992
+ // contribute 0 to inline flow height; L11 owns the positioned render.
993
+ if (isOutOfFlowFrame(block.frameProperties)) {
994
+ return 0;
995
+ }
996
+ const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
928
997
  if (formatting) {
929
998
  // Provider path: sum per-line heights so canvas-backed measurements
930
999
  // that emit variable line heights (mixed inline font sizes, etc.)
@@ -963,6 +1032,7 @@ function measureBlockHeight(
963
1032
  measurementProvider,
964
1033
  cache,
965
1034
  defaultTabInterval,
1035
+ themeFonts,
966
1036
  );
967
1037
  case "sdt_block":
968
1038
  return Math.max(
@@ -976,6 +1046,7 @@ function measureBlockHeight(
976
1046
  measurementProvider,
977
1047
  cache,
978
1048
  defaultTabInterval,
1049
+ themeFonts,
979
1050
  ),
980
1051
  0,
981
1052
  ),
@@ -1008,6 +1079,7 @@ function measureTableHeight(
1008
1079
  measurementProvider?: LayoutMeasurementProvider,
1009
1080
  cache?: MeasurementCache,
1010
1081
  defaultTabInterval = 720,
1082
+ themeFonts?: LayoutThemeFonts,
1011
1083
  ): number {
1012
1084
  const TABLE_ROW_PADDING_TWIPS = 120;
1013
1085
  let totalHeight = 0;
@@ -1054,6 +1126,7 @@ function measureTableHeight(
1054
1126
  measurementProvider,
1055
1127
  cache,
1056
1128
  defaultTabInterval,
1129
+ themeFonts,
1057
1130
  );
1058
1131
  }
1059
1132
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
@@ -1100,6 +1173,17 @@ export function __resolveCellWidth(
1100
1173
  return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
1101
1174
  }
1102
1175
 
1176
+ /**
1177
+ * Exposed for coord §1.19.d framePr unit tests; not part of the
1178
+ * stable surface.
1179
+ */
1180
+ export function __test_measureBlockHeight(
1181
+ block: SurfaceBlockSnapshot | undefined,
1182
+ columnWidth: number,
1183
+ ): number {
1184
+ return measureBlockHeight(block, columnWidth);
1185
+ }
1186
+
1103
1187
  function resolveCellWidth(
1104
1188
  gridColumns: readonly number[],
1105
1189
  startColumn: number,
@@ -1327,6 +1411,14 @@ export function paginateSectionBlocksWithSplits(
1327
1411
  * `[0, maxColumns-1]` defensively.
1328
1412
  */
1329
1413
  startColumnIndex = 0,
1414
+ /**
1415
+ * Theme-font fallback. When an L03-resolved segment carries a theme
1416
+ * slot (`asciiTheme: "minorHAnsi"`) but no literal family, the
1417
+ * dominant-font resolver routes through these before falling to the
1418
+ * `DEFAULT_FONT_AVG_CHAR_WIDTH` factor. Source: the document's
1419
+ * `subParts.resolvedTheme` — threaded from `buildPageStackWithSplits`.
1420
+ */
1421
+ themeFonts?: LayoutThemeFonts,
1330
1422
  ): SectionPaginationResult {
1331
1423
  if (blocks.length === 0) {
1332
1424
  return {
@@ -1389,6 +1481,27 @@ export function paginateSectionBlocksWithSplits(
1389
1481
  // table is fully placed.
1390
1482
  const tableProgress = new Map<string, number>();
1391
1483
 
1484
+ // Contextual-spacing pair mask — precomputed once so the inner pagination
1485
+ // loop can fold suppression in O(1) per block without re-scanning neighbors.
1486
+ const contextualSpacing = computeContextualSpacingAdjustments(blocks);
1487
+ const applyContextualSpacingAdjustment = (
1488
+ baseHeight: number,
1489
+ block: SurfaceBlockSnapshot | undefined,
1490
+ index: number,
1491
+ ): number => {
1492
+ if (!block || block.kind !== "paragraph") return baseHeight;
1493
+ if (!contextualSpacing.before[index] && !contextualSpacing.after[index]) {
1494
+ return baseHeight;
1495
+ }
1496
+ const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
1497
+ if (!formatting) return baseHeight;
1498
+ let delta = 0;
1499
+ if (contextualSpacing.before[index]) delta += formatting.spacingBefore;
1500
+ if (contextualSpacing.after[index]) delta += formatting.spacingAfter;
1501
+ if (delta === 0) return baseHeight;
1502
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, baseHeight - delta);
1503
+ };
1504
+
1392
1505
  // P8.1b — per-page note tracking.
1393
1506
  // `pendingNoteKeys` parallels `reservedNotes` but is only snapshotted on
1394
1507
  // page-push (finalization), NOT on column break.
@@ -1510,31 +1623,42 @@ export function paginateSectionBlocksWithSplits(
1510
1623
  const columnWidth =
1511
1624
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
1512
1625
  getUsableColumnWidth(layout);
1513
- const baseHeight = measureBlockHeight(
1626
+ const rawBaseHeight = measureBlockHeight(
1514
1627
  block,
1515
1628
  columnWidth,
1516
1629
  measurementProvider,
1517
1630
  cache,
1518
1631
  defaultTabInterval,
1632
+ themeFonts,
1633
+ );
1634
+ const baseHeight = applyContextualSpacingAdjustment(
1635
+ rawBaseHeight,
1636
+ block,
1637
+ index,
1519
1638
  );
1520
1639
 
1521
1640
  // keepNext: this paragraph must stay with the next one on the same page
1522
1641
  const keepWithNextHeight =
1523
1642
  block.kind === "paragraph" && block.keepNext
1524
1643
  ? baseHeight +
1525
- measureBlockHeight(
1644
+ applyContextualSpacingAdjustment(
1645
+ measureBlockHeight(
1646
+ blocks[index + 1],
1647
+ columnWidth,
1648
+ measurementProvider,
1649
+ cache,
1650
+ defaultTabInterval,
1651
+ themeFonts,
1652
+ ),
1526
1653
  blocks[index + 1],
1527
- columnWidth,
1528
- measurementProvider,
1529
- cache,
1530
- defaultTabInterval,
1654
+ index + 1,
1531
1655
  )
1532
1656
  : baseHeight;
1533
1657
 
1534
1658
  // keepLines: the entire paragraph must fit on one page.
1535
1659
  // If it doesn't fit and there's already content on this page, break before it.
1536
1660
  const formatting = block.kind === "paragraph"
1537
- ? resolveBlockFormatting(block, defaultTabInterval)
1661
+ ? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
1538
1662
  : null;
1539
1663
  const keepLinesActive = formatting?.keepLines ?? false;
1540
1664
 
@@ -1760,6 +1884,16 @@ export function paginateSectionBlocksWithSplits(
1760
1884
  }
1761
1885
  });
1762
1886
 
1887
+ if (hasPageBreak(block)) {
1888
+ // coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>`
1889
+ // forces subsequent content onto a new page regardless of
1890
+ // current column position. Mirrors the hasColumnBreak
1891
+ // last-column branch (pushPage + break) so pushPage's note-
1892
+ // allocation snapshot runs.
1893
+ pushPage(nextBoundary);
1894
+ break;
1895
+ }
1896
+
1763
1897
  if (hasColumnBreak(block)) {
1764
1898
  if (columnIndex < maxColumns - 1) {
1765
1899
  // Column break within a multi-column layout: advance to next column.
@@ -1936,6 +2070,37 @@ function currentPageNoteIds(
1936
2070
  return notes;
1937
2071
  }
1938
2072
 
2073
+ /**
2074
+ * OOXML §17.3.1.11 — `<w:framePr>`.
2075
+ *
2076
+ * A paragraph carrying frame properties is rendered as a text frame:
2077
+ * it is positioned relative to the page/margin/text (per `hAnchor` /
2078
+ * `vAnchor` / `xAlign` / `yAlign` / `xTwips` / `yTwips`) and the main
2079
+ * text flow wraps around it (per `wrap`). Consequently the framed
2080
+ * paragraph's height MUST NOT contribute to inline block-height
2081
+ * accounting on the page — otherwise the paginator double-counts the
2082
+ * frame (once as inline block, once as the positioned render).
2083
+ *
2084
+ * The exception is `dropCap` (`drop` / `margin`): the frame wraps
2085
+ * only the initial letter; the rest of the paragraph remains in the
2086
+ * main flow, so the paragraph must continue to contribute its
2087
+ * (non-dropped) height. `dropCap="none"` or absent is a full-frame.
2088
+ *
2089
+ * L11 owns the absolute-positioned render; L04 only needs to keep
2090
+ * the frame out of the flow accounting.
2091
+ */
2092
+ function isOutOfFlowFrame(
2093
+ frameProperties:
2094
+ | Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["frameProperties"]
2095
+ | undefined,
2096
+ ): boolean {
2097
+ if (!frameProperties) return false;
2098
+ if (frameProperties.dropCap === "drop" || frameProperties.dropCap === "margin") {
2099
+ return false;
2100
+ }
2101
+ return true;
2102
+ }
2103
+
1939
2104
  function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
1940
2105
  return block.kind === "paragraph" && block.segments.some(
1941
2106
  (segment) =>
@@ -1943,3 +2108,11 @@ function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
1943
2108
  segment.label === "Column break",
1944
2109
  );
1945
2110
  }
2111
+
2112
+ function hasPageBreak(block: SurfaceBlockSnapshot): boolean {
2113
+ return block.kind === "paragraph" && block.segments.some(
2114
+ (segment) =>
2115
+ segment.kind === "opaque_inline" &&
2116
+ segment.label === "Page break",
2117
+ );
2118
+ }