@beyondwork/docx-react-component 1.0.71 → 1.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +964 -75
- package/package.json +1 -1
- package/src/api/public-types.ts +243 -1
- package/src/api/v3/_create.ts +16 -1
- package/src/api/v3/_runtime-handle.ts +2 -0
- package/src/api/v3/ai/evaluate.ts +113 -0
- package/src/api/v3/ai/outline.ts +140 -0
- package/src/api/v3/ai/replacement.ts +8 -0
- package/src/api/v3/ai/review.ts +342 -0
- package/src/api/v3/ai/stats.ts +62 -0
- package/src/api/v3/runtime/viewport.ts +181 -0
- package/src/api/v3/runtime/workflow.ts +114 -1
- package/src/api/v3/ui/_types.ts +35 -0
- package/src/api/v3/ui/index.ts +1 -0
- package/src/api/v3/ui/viewport.ts +112 -0
- package/src/compare/diff-engine.ts +2 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/table-structure-commands.ts +1 -0
- package/src/io/export/serialize-headers-footers.ts +1 -0
- package/src/io/export/serialize-main-document.ts +13 -0
- package/src/io/export/serialize-paragraph-formatting.ts +34 -0
- package/src/io/export/split-review-boundaries.ts +1 -0
- package/src/io/normalize/normalize-text.ts +11 -0
- package/src/io/ooxml/parse-main-document.ts +21 -5
- package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
- package/src/model/canonical-document.ts +401 -1
- package/src/runtime/formatting/formatting-context.ts +2 -1
- package/src/runtime/geometry/overlay-rects.ts +7 -10
- package/src/runtime/layout/layout-engine-version.ts +257 -1
- package/src/runtime/layout/paginated-layout-engine.ts +134 -8
- package/src/runtime/layout/resolved-formatting-state.ts +108 -13
- package/src/runtime/markdown-sanitizer.ts +21 -4
- package/src/runtime/render/render-kernel.ts +21 -1
- package/src/runtime/scopes/audit-bundle.ts +8 -0
- package/src/runtime/scopes/compiler-service.ts +1 -0
- package/src/runtime/scopes/enumerate-scopes.ts +61 -3
- package/src/runtime/scopes/replacement/apply.ts +49 -3
- package/src/runtime/scopes/semantic-scope-types.ts +8 -0
- package/src/runtime/surface-projection.ts +22 -0
- package/src/runtime/workflow/coordinator.ts +3 -0
- package/src/runtime/workflow/scope-writer.ts +34 -0
- package/src/session/export/embedded-reconstitute.ts +37 -3
- package/src/session/import/embedded-offload.ts +26 -1
- package/src/shell/media-previews.ts +8 -6
- package/src/ui/WordReviewEditor.tsx +1 -0
- package/src/ui/editor-surface-controller.tsx +11 -0
- package/src/ui/headless/selection-helpers.ts +2 -2
- package/src/ui/runtime-shortcut-dispatch.ts +4 -4
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
- package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
- package/src/ui-tailwind/index.ts +4 -2
- package/src/ui-tailwind/page-chrome-model.ts +5 -7
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
- package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
- package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|