postext 0.3.27 → 0.3.28
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/dist/__tests__/defaults/calloutStyles.test.js +4 -2
- package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
- package/dist/__tests__/defaults/headingsBalancing.test.d.ts +2 -0
- package/dist/__tests__/defaults/headingsBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/headingsBalancing.test.js +17 -0
- package/dist/__tests__/defaults/headingsBalancing.test.js.map +1 -0
- package/dist/__tests__/parts.test.js +82 -1
- package/dist/__tests__/parts.test.js.map +1 -1
- package/dist/__tests__/pipeline/calloutSplit.test.d.ts +2 -0
- package/dist/__tests__/pipeline/calloutSplit.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/calloutSplit.test.js +156 -0
- package/dist/__tests__/pipeline/calloutSplit.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBlocks.test.js +4 -2
- package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
- package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts +2 -0
- package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/spanLeaveLevel.test.js +103 -0
- package/dist/__tests__/pipeline/spanLeaveLevel.test.js.map +1 -0
- package/dist/defaults/calloutStyles.d.ts.map +1 -1
- package/dist/defaults/calloutStyles.js +3 -3
- package/dist/defaults/calloutStyles.js.map +1 -1
- package/dist/defaults/headings.d.ts +1 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +7 -0
- package/dist/defaults/headings.js.map +1 -1
- package/dist/design/layout.d.ts +6 -1
- package/dist/design/layout.d.ts.map +1 -1
- package/dist/design/layout.js +38 -2
- package/dist/design/layout.js.map +1 -1
- package/dist/design/placeholders.d.ts +4 -0
- package/dist/design/placeholders.d.ts.map +1 -1
- package/dist/design/placeholders.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +355 -152
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/calloutLayout.d.ts +9 -3
- package/dist/pipeline/calloutLayout.d.ts.map +1 -1
- package/dist/pipeline/calloutLayout.js +7 -5
- package/dist/pipeline/calloutLayout.js.map +1 -1
- package/dist/pipeline/continuation.d.ts.map +1 -1
- package/dist/pipeline/continuation.js +14 -2
- package/dist/pipeline/continuation.js.map +1 -1
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +7 -1
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/parts.d.ts +9 -0
- package/dist/pipeline/parts.d.ts.map +1 -1
- package/dist/pipeline/parts.js +15 -0
- package/dist/pipeline/parts.js.map +1 -1
- package/dist/pipeline/placeholders.d.ts +12 -9
- package/dist/pipeline/placeholders.d.ts.map +1 -1
- package/dist/pipeline/placeholders.js +13 -12
- package/dist/pipeline/placeholders.js.map +1 -1
- package/dist/pipeline/placement.d.ts +1 -0
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +1 -0
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/types.d.ts +30 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +14 -1
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
package/dist/pipeline/build.js
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
|
|
|
11
11
|
import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
|
|
12
12
|
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
|
|
13
13
|
import { chooseParagraphSplit } from './orphanWidow';
|
|
14
|
-
import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
|
|
14
|
+
import { applyStyleAttrs, computePageMetrics, isMarkerBlock, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
|
|
15
15
|
import { measureContentBlock } from './measureContentBlock';
|
|
16
16
|
import { planParagraphContainers } from './paragraphContainers';
|
|
17
17
|
import { planParts, derivePartMeasureContext } from './parts';
|
|
@@ -115,6 +115,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
115
115
|
doc.pageIndexOffset = pageIndexOffset;
|
|
116
116
|
if (continuation?.headings && continuation.headings.h1 > 0)
|
|
117
117
|
doc.chapterOrdinalOffset = continuation.headings.h1;
|
|
118
|
+
// The part the preceding chapters left open: running heads and palette
|
|
119
|
+
// overrides apply from the first page until this document opens its own.
|
|
120
|
+
if (continuation?.part)
|
|
121
|
+
doc.partStart = continuation.part;
|
|
118
122
|
const pageMetrics = computePageMetrics(resolved);
|
|
119
123
|
const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
|
|
120
124
|
// Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
|
|
@@ -663,22 +667,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
663
667
|
* keep-with-next rollbacks may pull along (a callout is one unbreakable
|
|
664
668
|
* unit; its children never leave it). */
|
|
665
669
|
const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
|
|
666
|
-
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
|
|
671
|
-
* fence, opening to closing marker). */
|
|
672
|
-
const stampCalloutSource = (frame, startIdx, plan) => {
|
|
670
|
+
/** Stamp a callout frame's content index and source range: the whole
|
|
671
|
+
* fence (opening to closing marker) for an unsplit box; for a fragment
|
|
672
|
+
* of a split box, from the fence start (first fragment) or the first
|
|
673
|
+
* child it holds, to the fence end (last fragment) or the last child. */
|
|
674
|
+
const stampCalloutSource = (frame, startIdx, plan, range) => {
|
|
673
675
|
const startBlock = contentBlocks[startIdx];
|
|
674
676
|
const endBlock = contentBlocks[plan.endIdx];
|
|
675
677
|
frame.contentIndex = startIdx;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
+
const first = range && range.firstChildIdx > startIdx + 1 ? contentBlocks[range.firstChildIdx] : startBlock;
|
|
679
|
+
const last = range && range.lastChildIdx < plan.endIdx - 1 ? contentBlocks[range.lastChildIdx] : endBlock;
|
|
680
|
+
frame.sourceStart = first.sourceStart + bodyOffset;
|
|
681
|
+
frame.sourceEnd = last.sourceEnd + bodyOffset;
|
|
678
682
|
};
|
|
679
|
-
|
|
683
|
+
/** Shared tail of callout placement: stamp the frame's source range,
|
|
684
|
+
* convert the laid-out box to absolute coordinates at the frame's placed
|
|
685
|
+
* origin, and push frame + children — in that order — to `doc.blocks`
|
|
686
|
+
* and to the column the frame landed in. */
|
|
687
|
+
const commitCallout = (result, startIdx, plan, col, range) => {
|
|
680
688
|
const frame = result.frame;
|
|
681
|
-
stampCalloutSource(frame, startIdx, plan);
|
|
689
|
+
stampCalloutSource(frame, startIdx, plan, range);
|
|
682
690
|
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
683
691
|
doc.blocks.push(frame);
|
|
684
692
|
for (const child of result.children) {
|
|
@@ -688,6 +696,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
688
696
|
doc.blocks.push(child);
|
|
689
697
|
}
|
|
690
698
|
};
|
|
699
|
+
const makeCalloutLayouter = (startIdx, plan, style) => {
|
|
700
|
+
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
701
|
+
const realAt = [];
|
|
702
|
+
children.forEach((c, k) => {
|
|
703
|
+
if (c.type !== 'directive' && !isMarkerBlock(c))
|
|
704
|
+
realAt.push(k);
|
|
705
|
+
});
|
|
706
|
+
const layoutRange = (from, to, width, frameId, continuation) => {
|
|
707
|
+
let n = 0;
|
|
708
|
+
return layoutCallout({
|
|
709
|
+
style,
|
|
710
|
+
attrs: plan.attrs,
|
|
711
|
+
continuation,
|
|
712
|
+
children: children.slice(from, to),
|
|
713
|
+
childStartIdx: startIdx + 1 + from,
|
|
714
|
+
width,
|
|
715
|
+
ctx: measureCtx,
|
|
716
|
+
resolved,
|
|
717
|
+
containerId: plan.containerId,
|
|
718
|
+
frameId,
|
|
719
|
+
nextChildId: () => `${frameId}-c${n++}`,
|
|
720
|
+
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
return { children, childBase: startIdx + 1, realAt, layoutRange };
|
|
724
|
+
};
|
|
725
|
+
/** The longest leading fragment of the children from `from` on whose box
|
|
726
|
+
* is at most `roomPx` tall — at least one child, and at least one left
|
|
727
|
+
* for the rest. The full layout's child geometry picks the candidate
|
|
728
|
+
* (box bottom = child bottom + the box's tail below its last child);
|
|
729
|
+
* the candidate is then laid out for real and shortened while it does
|
|
730
|
+
* not fit. `null` when not even the first child fits. */
|
|
731
|
+
const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation) => {
|
|
732
|
+
const starts = L.realAt.filter((k) => k >= from);
|
|
733
|
+
if (starts.length < 2)
|
|
734
|
+
return null;
|
|
735
|
+
const full = L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
736
|
+
const lastChild = full.children[full.children.length - 1];
|
|
737
|
+
if (!lastChild)
|
|
738
|
+
return null;
|
|
739
|
+
const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
|
|
740
|
+
/** Frame-relative bottom of the last laid-out child before position `to`. */
|
|
741
|
+
const bottomBefore = (to) => {
|
|
742
|
+
let bottom = 0;
|
|
743
|
+
for (const c of full.children) {
|
|
744
|
+
if (c.contentIndex !== undefined && c.contentIndex < L.childBase + to) {
|
|
745
|
+
bottom = Math.max(bottom, c.bbox.y + c.bbox.height);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return bottom;
|
|
749
|
+
};
|
|
750
|
+
let j = starts.length - 1;
|
|
751
|
+
while (j >= 1 && bottomBefore(starts[j]) + tail > roomPx + 0.01)
|
|
752
|
+
j--;
|
|
753
|
+
for (; j >= 1; j--) {
|
|
754
|
+
const to = starts[j];
|
|
755
|
+
const result = L.layoutRange(from, to, width, frameId, continuation);
|
|
756
|
+
if (result.totalHeight <= roomPx + 0.01)
|
|
757
|
+
return { to, result };
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
};
|
|
761
|
+
/** Absolute content indices of the children in `[from, to)` of `L`, for
|
|
762
|
+
* the fragment's source range. */
|
|
763
|
+
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from, lastChildIdx: L.childBase + to - 1 });
|
|
764
|
+
const markFragment = (result, part, continued) => {
|
|
765
|
+
if (result.frame.callout) {
|
|
766
|
+
result.frame.callout.part = part;
|
|
767
|
+
result.frame.callout.continued = continued;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
691
770
|
/**
|
|
692
771
|
* Place a `span: 'page'` `:::callout` in a multi-column layout as a span
|
|
693
772
|
* block (stage 1): the box is laid out at the page's content width and
|
|
@@ -716,6 +795,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
716
795
|
* overflowed instead (the box arrives elsewhere), the driver grows or
|
|
717
796
|
* drops the cap.
|
|
718
797
|
*
|
|
798
|
+
* Leaving level (`headings.balancing.beforeSpan`): a box that does not
|
|
799
|
+
* fit even after a level cut proposes a TRAILING cap instead — the band
|
|
800
|
+
* it leaves is cut level, the way a chapter's closing band is, and the
|
|
801
|
+
* page is marked as a forced break in that pass so balancing does not
|
|
802
|
+
* stretch its last column back to the page bottom. The cap is resolved
|
|
803
|
+
* after balancing (`resolveTrailingCaps`); when the box reaches the
|
|
804
|
+
* capped band it counts as delivered whether it fits there or moves on,
|
|
805
|
+
* and the cut columns stay cut (the polish round fills a column ending a
|
|
806
|
+
* line under the cap).
|
|
807
|
+
*
|
|
808
|
+
* Splitting (`keepTogether: false`): a box that does not fit a level (or
|
|
809
|
+
* capped, or nearly level — within one grid line) band breaks between
|
|
810
|
+
* its children: the longest fragment that fits closes the page flush
|
|
811
|
+
* with the band bottom, the rest opens the next page in a box without
|
|
812
|
+
* the title or icon, and splits again if it is still too tall. Such a box that
|
|
813
|
+
* fits whole only flush with the page bottom (no text below) is placed
|
|
814
|
+
* whole. When the band is uneven, the cap that levels it (span cap when
|
|
815
|
+
* the whole box fits after the cut, trailing cap otherwise) comes first;
|
|
816
|
+
* the fragment is cut in the capped pass.
|
|
817
|
+
*
|
|
719
818
|
* Geometry stays on the baseline grid: the cut line is the band's used
|
|
720
819
|
* bottom snapped UP to the next grid line (anchored at the content-area
|
|
721
820
|
* top, like every column start), and the span column's height is the
|
|
@@ -724,69 +823,169 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
724
823
|
* stay in the outer page bands: the band inherits the float-reduced top
|
|
725
824
|
* / bottom of the page's columns, so a span column never overlaps a float.
|
|
726
825
|
*/
|
|
727
|
-
const placeCalloutSpan = (startIdx, plan,
|
|
728
|
-
|
|
729
|
-
|
|
826
|
+
const placeCalloutSpan = (startIdx, plan, style, L, firstFrameId) => {
|
|
827
|
+
const splittable = !style.keepTogether;
|
|
828
|
+
const balancingCfg = resolved.headings.balancing;
|
|
829
|
+
const levelBefore = balancingCfg.enabled && balancingCfg.beforeSpan;
|
|
730
830
|
const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
|
|
731
831
|
const minRoomPx = minLines * bodyStyle.lineHeightPx;
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const measureBand = (requireLevel) => {
|
|
738
|
-
const cols = bandColumns(page, currentBand(page, cursor));
|
|
739
|
-
if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
|
|
740
|
-
return null;
|
|
741
|
-
const usedBottom = bandUsedBottom(cols);
|
|
742
|
-
const cutY = page.contentArea.y
|
|
743
|
-
+ Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
744
|
-
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
745
|
-
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
746
|
-
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
747
|
-
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
748
|
-
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
749
|
-
return { cols, cutY, need, spacing, room };
|
|
832
|
+
const gridUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
833
|
+
const needFor = (spacing, height, margin) => Math.ceil((spacing + height + margin - 0.01) / baselineGrid) * baselineGrid;
|
|
834
|
+
const nearlyLevel = (cols) => {
|
|
835
|
+
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
836
|
+
return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
|
|
750
837
|
};
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
838
|
+
/** Position in the children the fragment to place starts at, and its
|
|
839
|
+
* 0-based index among the fragments (0 = the box, or its head). */
|
|
840
|
+
let from = 0;
|
|
841
|
+
let part = 0;
|
|
842
|
+
let frameId = firstFrameId;
|
|
843
|
+
/** The box already moved to a fresh page (or sits on an empty one):
|
|
844
|
+
* whatever does not fit there is force-placed and overflows. */
|
|
845
|
+
let forceHere = false;
|
|
846
|
+
for (;;) {
|
|
847
|
+
let page = doc.pages[cursor.pageIndex];
|
|
848
|
+
const continuation = part > 0;
|
|
849
|
+
const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
850
|
+
const result = layoutAt(page.contentArea.width);
|
|
851
|
+
/** Where the box would cut the current band, and whether it fits (room
|
|
852
|
+
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
853
|
+
* its slack below the cap). Null when the band is not level (or the
|
|
854
|
+
* cursor sits on a span column with no band below it) and
|
|
855
|
+
* `requireLevel` is set. */
|
|
856
|
+
const measureBand = (requireLevel) => {
|
|
857
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
858
|
+
if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
|
|
859
|
+
return null;
|
|
860
|
+
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
861
|
+
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
862
|
+
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
863
|
+
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
864
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
865
|
+
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
866
|
+
const needFlush = needFor(spacing, result.totalHeight, 0);
|
|
867
|
+
const roomFlush = cutY + needFlush <= bandBottom + 0.01;
|
|
868
|
+
return { cols, cutY, spacing, need, room, needFlush, roomFlush, roomPx: bandBottom - cutY };
|
|
869
|
+
};
|
|
870
|
+
// Is this band capped for this very box? Then it cuts at the band's
|
|
871
|
+
// used bottom (at most the cap) even when the last column is short.
|
|
872
|
+
const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
|
|
873
|
+
const capActive = cap !== undefined
|
|
874
|
+
&& activeCap !== null
|
|
875
|
+
&& activeCap.spanIndex === startIdx
|
|
876
|
+
&& activeCap.pageIndex === page.index
|
|
877
|
+
&& activeCap.band === currentBand(page, cursor);
|
|
878
|
+
const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
|
|
879
|
+
let action = null;
|
|
880
|
+
if (fit) {
|
|
881
|
+
if (fit.room)
|
|
882
|
+
action = { kind: 'whole', fit, need: fit.need, result };
|
|
883
|
+
else if (splittable && fit.roomFlush)
|
|
884
|
+
action = { kind: 'whole', fit, need: fit.needFlush, result };
|
|
885
|
+
}
|
|
886
|
+
if (!action && splittable) {
|
|
887
|
+
// A band uneven by no more than a grid line still takes a fragment
|
|
888
|
+
// cut at its used bottom: balancing fills the line the short column
|
|
889
|
+
// is left under the cut.
|
|
890
|
+
let band = fit;
|
|
891
|
+
if (!band) {
|
|
892
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
893
|
+
if (cols.length > 0 && nearlyLevel(cols))
|
|
894
|
+
band = measureBand(false);
|
|
895
|
+
}
|
|
896
|
+
if (band) {
|
|
897
|
+
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
|
|
898
|
+
if (fragment) {
|
|
899
|
+
action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (!action && forceHere && fit)
|
|
904
|
+
action = { kind: 'whole', fit, need: fit.need, result };
|
|
905
|
+
if (action) {
|
|
906
|
+
if (capActive) {
|
|
907
|
+
uncapBand(action.fit.cols, uncappedBottoms);
|
|
908
|
+
spanPlacedInBand.add(startIdx);
|
|
909
|
+
}
|
|
910
|
+
const placed = action.kind === 'whole' ? action.result : action.fragment.result;
|
|
911
|
+
const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
|
|
912
|
+
if (part > 0 || action.kind === 'split')
|
|
913
|
+
markFragment(placed, part, action.kind === 'split');
|
|
914
|
+
const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
|
|
915
|
+
commitCallout(placed, startIdx, plan, spanCol, part > 0 || action.kind === 'split' ? fragmentRange(L, from, to) : undefined);
|
|
916
|
+
// Floats first-referenced inside the box enqueue once its head is
|
|
917
|
+
// committed, in reading order (same as the inline path).
|
|
918
|
+
if (part === 0)
|
|
919
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
920
|
+
enqueueFloatsFor(i);
|
|
921
|
+
// The new band starts on the grid right below the span column; nothing
|
|
922
|
+
// to snap — `need` already bakes in `marginBottom`.
|
|
923
|
+
pendingSpacing = 0;
|
|
924
|
+
if (action.kind === 'whole')
|
|
925
|
+
return true;
|
|
926
|
+
// The rest opens the next page.
|
|
927
|
+
from = to;
|
|
928
|
+
part++;
|
|
929
|
+
frameId = `block-${blockIdCounter++}`;
|
|
930
|
+
const startPageIndex = cursor.pageIndex;
|
|
931
|
+
do {
|
|
932
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
933
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
934
|
+
forceHere = true;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
// Nothing of the box lands in this band.
|
|
938
|
+
if (part === 0) {
|
|
939
|
+
let proposedSpan = false;
|
|
940
|
+
if (!fit && cap === undefined && bandStart && registeredBand
|
|
941
|
+
&& registeredBand.pageIndex === page.index
|
|
942
|
+
&& registeredBand.band === currentBand(page, cursor)) {
|
|
943
|
+
// Uneven band, no cap yet: propose one when a level cut would leave
|
|
944
|
+
// room for the box plus the widow minimum of body lines below it
|
|
945
|
+
// (or, for a splittable box, for the whole box flush with the
|
|
946
|
+
// band bottom).
|
|
947
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
948
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
949
|
+
const capBottom = bandTop(cols) + lines * baselineGrid;
|
|
950
|
+
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
951
|
+
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
952
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
953
|
+
const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
|
|
954
|
+
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
|
|
955
|
+
if (fitsAfterCut) {
|
|
956
|
+
bandCapProposals.set(startIdx, {
|
|
957
|
+
kind: 'span',
|
|
958
|
+
startContentIndex: bandStart.contentIndex,
|
|
959
|
+
startPart: bandStart.part,
|
|
960
|
+
lines,
|
|
961
|
+
retries: 0,
|
|
962
|
+
});
|
|
963
|
+
proposedSpan = true;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (levelBefore && !capActive && !proposedSpan) {
|
|
967
|
+
// The box leaves an uncapped band: level it behind the box (a
|
|
968
|
+
// trailing cap, resolved after balancing) and keep balancing from
|
|
969
|
+
// stretching its last column to the page bottom meanwhile.
|
|
970
|
+
proposeTrailingCap(startIdx);
|
|
971
|
+
markForcedBreak();
|
|
972
|
+
}
|
|
973
|
+
else if (capActive && cap.kind === 'trailing') {
|
|
974
|
+
// Reached inside the band cut level for it: delivered even though
|
|
975
|
+
// the box moves on — the columns stay cut.
|
|
976
|
+
spanPlacedInBand.add(startIdx);
|
|
977
|
+
}
|
|
783
978
|
}
|
|
784
|
-
}
|
|
785
|
-
if (!fit || !fit.room) {
|
|
786
979
|
// Open the next page (flushing pending floats into its bands). A page
|
|
787
980
|
// holding only floats counts as occupied here — its float band is what
|
|
788
981
|
// left no room — but a truly empty page is kept: the box then simply
|
|
789
982
|
// does not fit a page and is force-placed (overflowing, like inline).
|
|
983
|
+
// On a fresh page the whole box is force-placed above whenever the
|
|
984
|
+
// page has a text column to cut; reaching this point there means it
|
|
985
|
+
// has none — leave the box to the inline path (its head, if any, is
|
|
986
|
+
// already committed: `true` keeps the rest from being placed twice).
|
|
987
|
+
if (forceHere)
|
|
988
|
+
return part > 0;
|
|
790
989
|
const curPage = doc.pages[cursor.pageIndex];
|
|
791
990
|
if (pageIsOccupied(curPage)) {
|
|
792
991
|
pendingSpacing = 0;
|
|
@@ -795,26 +994,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
795
994
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
796
995
|
} while (cursor.pageIndex === startPageIndex);
|
|
797
996
|
page = doc.pages[cursor.pageIndex];
|
|
798
|
-
if (Math.abs(page.contentArea.width - result.width) > 0.01) {
|
|
799
|
-
result = layoutAt(page.contentArea.width);
|
|
800
|
-
}
|
|
801
997
|
}
|
|
802
|
-
// A freshly opened page is level; force-place (overflow)
|
|
803
|
-
// is taller than the page.
|
|
804
|
-
|
|
805
|
-
if (!fit)
|
|
998
|
+
// A freshly opened (or empty) page is level; force-place (overflow)
|
|
999
|
+
// when the box is taller than the page.
|
|
1000
|
+
if (bandColumns(page, currentBand(page, cursor)).length === 0)
|
|
806
1001
|
return false; // no text column to cut — leave it to the inline path
|
|
1002
|
+
forceHere = true;
|
|
807
1003
|
}
|
|
808
|
-
const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
|
|
809
|
-
commitCallout(result, startIdx, plan, spanCol);
|
|
810
|
-
// Floats first-referenced inside the box enqueue once it is committed,
|
|
811
|
-
// in reading order (same as the inline path).
|
|
812
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
813
|
-
enqueueFloatsFor(i);
|
|
814
|
-
// The new band starts on the grid right below the span column; nothing
|
|
815
|
-
// to snap — `need` already bakes in `marginBottom`.
|
|
816
|
-
pendingSpacing = 0;
|
|
817
|
-
return true;
|
|
818
1004
|
};
|
|
819
1005
|
/** Whether the flow ends at a chapter-level boundary right after block
|
|
820
1006
|
* `from` (skipping container markers): a chapter opener, a `:::part`, a
|
|
@@ -992,8 +1178,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
992
1178
|
* baked into the post-box grid snap. A box that does not fit moves to the
|
|
993
1179
|
* next column/page (like a resource), pulling a run of trailing headings
|
|
994
1180
|
* along (keep-with-next); a box taller than an empty column is placed
|
|
995
|
-
* anyway and overflows (the sandbox warns).
|
|
996
|
-
*
|
|
1181
|
+
* anyway and overflows (the sandbox warns). A splittable box
|
|
1182
|
+
* (`keepTogether: false`) instead leaves the longest run of its children
|
|
1183
|
+
* that fits in the column and continues — in a box of its own, without
|
|
1184
|
+
* the title or icon — at the top of the next one, splitting again if needed.
|
|
1185
|
+
* Returns the content index to rewind the main loop to when headings
|
|
1186
|
+
* were rolled back, else `undefined`.
|
|
997
1187
|
*
|
|
998
1188
|
* `span: 'page'` boxes in multi-column layouts take the span-block path
|
|
999
1189
|
* (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
|
|
@@ -1002,25 +1192,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1002
1192
|
*/
|
|
1003
1193
|
const placeCalloutInline = (startIdx, plan) => {
|
|
1004
1194
|
const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
|
|
1005
|
-
const
|
|
1006
|
-
const frameId = `block-${blockIdCounter++}`;
|
|
1195
|
+
const firstFrameId = `block-${blockIdCounter++}`;
|
|
1007
1196
|
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
return layoutCallout({
|
|
1011
|
-
style,
|
|
1012
|
-
attrs: plan.attrs,
|
|
1013
|
-
children,
|
|
1014
|
-
childStartIdx: startIdx + 1,
|
|
1015
|
-
width,
|
|
1016
|
-
ctx: measureCtx,
|
|
1017
|
-
resolved,
|
|
1018
|
-
containerId: plan.containerId,
|
|
1019
|
-
frameId,
|
|
1020
|
-
nextChildId: () => `${frameId}-c${n++}`,
|
|
1021
|
-
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
1022
|
-
});
|
|
1023
|
-
};
|
|
1197
|
+
const L = makeCalloutLayouter(startIdx, plan, style);
|
|
1198
|
+
const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
|
|
1024
1199
|
// Fixed boxes leave the flow entirely.
|
|
1025
1200
|
if (placement === 'fixed') {
|
|
1026
1201
|
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
@@ -1033,61 +1208,88 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1033
1208
|
if (span === 'page'
|
|
1034
1209
|
&& placement === 'here'
|
|
1035
1210
|
&& bandColumns(page, currentBand(page, cursor)).length > 1
|
|
1036
|
-
&& placeCalloutSpan(startIdx, plan,
|
|
1211
|
+
&& placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
|
|
1037
1212
|
return undefined;
|
|
1038
1213
|
}
|
|
1039
1214
|
}
|
|
1040
|
-
|
|
1041
|
-
let
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (
|
|
1057
|
-
|
|
1215
|
+
const splittable = !style.keepTogether;
|
|
1216
|
+
let from = 0;
|
|
1217
|
+
let part = 0;
|
|
1218
|
+
let frameId = firstFrameId;
|
|
1219
|
+
for (;;) {
|
|
1220
|
+
let curCol = currentColumn(doc, cursor);
|
|
1221
|
+
const continuation = part > 0;
|
|
1222
|
+
const result = L.layoutRange(from, L.children.length, curCol.bbox.width, frameId, continuation);
|
|
1223
|
+
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
1224
|
+
const roomPx = curCol.availableHeight - spacing;
|
|
1225
|
+
let fragment = null;
|
|
1226
|
+
if (result.totalHeight > roomPx + 0.01) {
|
|
1227
|
+
// The (rest of the) box does not fit the column: a splittable box
|
|
1228
|
+
// leaves the head that fits here…
|
|
1229
|
+
if (splittable)
|
|
1230
|
+
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
|
|
1231
|
+
if (!fragment && curCol.blocks.length > 0) {
|
|
1232
|
+
// …otherwise it moves whole to the next column. Keep-with-next: a
|
|
1233
|
+
// run of headings at the column's tail travels with the box.
|
|
1234
|
+
// Skipped when the column holds nothing else (rolling back again
|
|
1235
|
+
// would loop) — the headings stay, orphaned. Only the head of a
|
|
1236
|
+
// box can roll headings back: a continuation always lands in a
|
|
1237
|
+
// fresh column.
|
|
1238
|
+
let run = 0;
|
|
1239
|
+
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
1240
|
+
if (isFreeHeading(curCol.blocks[j]))
|
|
1241
|
+
run++;
|
|
1242
|
+
else
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
pendingSpacing = 0;
|
|
1246
|
+
if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
|
|
1247
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1248
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1249
|
+
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
1250
|
+
}
|
|
1058
1251
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
1065
|
-
result = layoutAt(curCol.bbox.width);
|
|
1252
|
+
curCol = currentColumn(doc, cursor);
|
|
1253
|
+
// Columns of different widths (oneAndHalf): re-lay out for the new one.
|
|
1254
|
+
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1066
1257
|
}
|
|
1258
|
+
// An empty column that is still too short: placed anyway, overflowing.
|
|
1067
1259
|
}
|
|
1260
|
+
// Floats first-referenced inside the box still enqueue in reading order
|
|
1261
|
+
// (only once the box is committed, so a keep-with-next replay does not
|
|
1262
|
+
// enqueue them twice).
|
|
1263
|
+
if (part === 0)
|
|
1264
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1265
|
+
enqueueFloatsFor(i);
|
|
1266
|
+
const placed = fragment ? fragment.result : result;
|
|
1267
|
+
const to = fragment ? fragment.to : L.children.length;
|
|
1268
|
+
if (part > 0 || fragment)
|
|
1269
|
+
markFragment(placed, part, fragment !== null);
|
|
1270
|
+
const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
|
|
1271
|
+
enterBand(startIdx, 0);
|
|
1272
|
+
placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1273
|
+
enterBand(startIdx, 0);
|
|
1274
|
+
curCol = currentColumn(doc, cursor);
|
|
1275
|
+
commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
|
|
1276
|
+
// Snap the flow after the box to the baseline grid, baking in at least
|
|
1277
|
+
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
1278
|
+
{
|
|
1279
|
+
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
1280
|
+
const naturalBottom = usedHeight + placed.marginBottomPx;
|
|
1281
|
+
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
1282
|
+
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
1283
|
+
}
|
|
1284
|
+
pendingSpacing = 0;
|
|
1285
|
+
if (!fragment)
|
|
1286
|
+
return undefined;
|
|
1287
|
+
// The rest continues at the top of the next column.
|
|
1288
|
+
from = to;
|
|
1289
|
+
part++;
|
|
1290
|
+
frameId = `block-${blockIdCounter++}`;
|
|
1291
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1068
1292
|
}
|
|
1069
|
-
// Floats first-referenced inside the box still enqueue in reading order
|
|
1070
|
-
// (only once the box is committed, so a keep-with-next replay does not
|
|
1071
|
-
// enqueue them twice).
|
|
1072
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1073
|
-
enqueueFloatsFor(i);
|
|
1074
|
-
const frame = result.frame;
|
|
1075
|
-
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
1076
|
-
enterBand(startIdx, 0);
|
|
1077
|
-
placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1078
|
-
enterBand(startIdx, 0);
|
|
1079
|
-
curCol = currentColumn(doc, cursor);
|
|
1080
|
-
commitCallout(result, startIdx, plan, curCol);
|
|
1081
|
-
// Snap the flow after the box to the baseline grid, baking in at least
|
|
1082
|
-
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
1083
|
-
{
|
|
1084
|
-
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
1085
|
-
const naturalBottom = usedHeight + result.marginBottomPx;
|
|
1086
|
-
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
1087
|
-
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
1088
|
-
}
|
|
1089
|
-
pendingSpacing = 0;
|
|
1090
|
-
return undefined;
|
|
1091
1293
|
};
|
|
1092
1294
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
1093
1295
|
if (options?.shouldCancel?.())
|
|
@@ -1183,6 +1385,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1183
1385
|
createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
|
|
1184
1386
|
number: plan.number,
|
|
1185
1387
|
title: plan.title,
|
|
1388
|
+
palette: plan.palette,
|
|
1186
1389
|
titleSourceStart,
|
|
1187
1390
|
titleSourceEnd,
|
|
1188
1391
|
}, pageIndexOffset);
|