postext 0.3.26 → 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__/columnBalancing.test.js +46 -0
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/continuation.test.d.ts +2 -0
- package/dist/__tests__/continuation.test.d.ts.map +1 -0
- package/dist/__tests__/continuation.test.js +124 -0
- package/dist/__tests__/continuation.test.js.map +1 -0
- package/dist/__tests__/defaults/calloutStyles.test.js +62 -3
- 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__/exports.test.js +1 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/parts.test.js +82 -1
- package/dist/__tests__/parts.test.js.map +1 -1
- package/dist/__tests__/pipeline/calloutLayout.test.js +171 -0
- package/dist/__tests__/pipeline/calloutLayout.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/raggedUrl.test.d.ts +2 -0
- package/dist/__tests__/pipeline/raggedUrl.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/raggedUrl.test.js +22 -0
- package/dist/__tests__/pipeline/raggedUrl.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/__tests__/pipeline/trailingBandZone.test.d.ts +2 -0
- package/dist/__tests__/pipeline/trailingBandZone.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/trailingBandZone.test.js +77 -0
- package/dist/__tests__/pipeline/trailingBandZone.test.js.map +1 -0
- package/dist/canvas-backend/blockRender.js +1 -1
- package/dist/canvas-backend/blockRender.js.map +1 -1
- package/dist/defaults/calloutStyles.d.ts +17 -1
- package/dist/defaults/calloutStyles.d.ts.map +1 -1
- package/dist/defaults/calloutStyles.js +77 -4
- 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/defaults/shared.d.ts.map +1 -1
- package/dist/defaults/shared.js +12 -0
- package/dist/defaults/shared.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/html-backend.js +1 -1
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/numbering.d.ts +3 -1
- package/dist/numbering.d.ts.map +1 -1
- package/dist/numbering.js +4 -1
- package/dist/numbering.js.map +1 -1
- package/dist/pipeline/bandCaps.d.ts +21 -1
- package/dist/pipeline/bandCaps.d.ts.map +1 -1
- package/dist/pipeline/bandCaps.js +29 -3
- package/dist/pipeline/bandCaps.js.map +1 -1
- package/dist/pipeline/build.d.ts +17 -0
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +413 -172
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/buildHelpers.d.ts +1 -1
- package/dist/pipeline/buildHelpers.d.ts.map +1 -1
- package/dist/pipeline/buildHelpers.js +2 -2
- package/dist/pipeline/buildHelpers.js.map +1 -1
- package/dist/pipeline/calloutLayout.d.ts +14 -3
- package/dist/pipeline/calloutLayout.d.ts.map +1 -1
- package/dist/pipeline/calloutLayout.js +153 -48
- package/dist/pipeline/calloutLayout.js.map +1 -1
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +17 -1
- package/dist/pipeline/columnBalancing.js.map +1 -1
- package/dist/pipeline/continuation.d.ts +7 -0
- package/dist/pipeline/continuation.d.ts.map +1 -0
- package/dist/pipeline/continuation.js +38 -0
- package/dist/pipeline/continuation.js.map +1 -0
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +16 -8
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/index.d.ts +2 -1
- package/dist/pipeline/index.d.ts.map +1 -1
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/index.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 +13 -10
- package/dist/pipeline/placeholders.d.ts.map +1 -1
- package/dist/pipeline/placeholders.js +17 -15
- package/dist/pipeline/placeholders.js.map +1 -1
- package/dist/pipeline/placement.d.ts +3 -2
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +13 -8
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/pipeline/raggedUrl.d.ts +15 -0
- package/dist/pipeline/raggedUrl.d.ts.map +1 -0
- package/dist/pipeline/raggedUrl.js +28 -0
- package/dist/pipeline/raggedUrl.js.map +1 -0
- package/dist/pipeline/resourceNumbering.d.ts +27 -19
- package/dist/pipeline/resourceNumbering.d.ts.map +1 -1
- package/dist/pipeline/resourceNumbering.js +19 -17
- package/dist/pipeline/resourceNumbering.js.map +1 -1
- package/dist/types.d.ts +128 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +27 -1
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/dist/worker/client.d.ts +4 -0
- package/dist/worker/client.d.ts.map +1 -1
- package/dist/worker/client.js +4 -0
- package/dist/worker/client.js.map +1 -1
- package/dist/worker/layout.worker.js +14 -0
- package/dist/worker/layout.worker.js.map +1 -1
- package/dist/worker/protocol.d.ts +5 -0
- package/dist/worker/protocol.d.ts.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';
|
|
@@ -22,8 +22,9 @@ import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsSt
|
|
|
22
22
|
import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
|
|
23
23
|
import { defaultResourceTypes } from '../defaults/resourceTypes';
|
|
24
24
|
import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
|
|
25
|
-
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES
|
|
26
|
-
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, } from './bandCaps';
|
|
25
|
+
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
26
|
+
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
|
|
27
|
+
import { raggedUrlLines } from './raggedUrl';
|
|
27
28
|
export class BuildCancelledError extends Error {
|
|
28
29
|
constructor() {
|
|
29
30
|
super('Build cancelled');
|
|
@@ -106,13 +107,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
106
107
|
const baselineGrid = computeBaselineGrid(resolved);
|
|
107
108
|
// Create document
|
|
108
109
|
const doc = createVDTDocument(resolved, baselineGrid);
|
|
110
|
+
// A chapter laid out after the pages before it: shift parity and carry the
|
|
111
|
+
// counters over (see `PostextContent.continuation`).
|
|
112
|
+
const continuation = content.continuation;
|
|
113
|
+
const pageIndexOffset = Math.max(0, Math.floor(continuation?.pageIndexOffset ?? 0));
|
|
114
|
+
if (pageIndexOffset > 0)
|
|
115
|
+
doc.pageIndexOffset = pageIndexOffset;
|
|
116
|
+
if (continuation?.headings && continuation.headings.h1 > 0)
|
|
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;
|
|
109
122
|
const pageMetrics = computePageMetrics(resolved);
|
|
110
123
|
const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
|
|
111
124
|
// Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
|
|
112
125
|
const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
|
|
113
126
|
doc.trimOffset = trimOffset;
|
|
114
127
|
// Create first page
|
|
115
|
-
const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
128
|
+
const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx, pageIndexOffset);
|
|
116
129
|
doc.pages.push(firstPage);
|
|
117
130
|
// Extract frontmatter, then parse the remaining markdown body
|
|
118
131
|
const { metadata: frontmatterMeta, content: markdownBody, contentOffset: bodyOffset } = extractFrontmatter(content.markdown);
|
|
@@ -124,15 +137,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
124
137
|
headingTemplates[lvl.level] = lvl.numberingTemplate;
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
|
-
const
|
|
140
|
+
const headingStart = continuation?.headings;
|
|
141
|
+
const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates, headingStart ? [headingStart.h1, headingStart.h2, headingStart.h3, headingStart.h4, headingStart.h5, headingStart.h6] : undefined);
|
|
128
142
|
// Resource numbering — computed up front (before the placement loop) so that
|
|
129
143
|
// captions and inline `:ref`s can resolve their rendered number strings
|
|
130
144
|
// before measurement. Numbering follows order of first reference in the
|
|
131
145
|
// document.
|
|
132
146
|
const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
|
|
133
147
|
const resources = content.resources ?? [];
|
|
134
|
-
const headingContext = computeHeadingContext(contentBlocks);
|
|
135
|
-
const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext);
|
|
148
|
+
const headingContext = computeHeadingContext(contentBlocks, headingStart);
|
|
149
|
+
const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext, continuation ? { counters: continuation.resourceCounters, numbered: continuation.resourceNumbers } : undefined);
|
|
136
150
|
// Lookups threaded into block-kind resolution + measurement.
|
|
137
151
|
const resourceById = new Map();
|
|
138
152
|
for (const r of resources)
|
|
@@ -533,7 +547,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
533
547
|
for (const [spanIndex, cap] of bandCaps) {
|
|
534
548
|
if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
|
|
535
549
|
continue;
|
|
536
|
-
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
|
|
550
|
+
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone);
|
|
537
551
|
activeCap = { spanIndex, pageIndex: page.index, band };
|
|
538
552
|
bandCapsApplied.add(spanIndex);
|
|
539
553
|
break;
|
|
@@ -550,8 +564,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
550
564
|
* the cap delivered when it is reached inside the capped band (nothing
|
|
551
565
|
* spilled past the cut); the columns are NOT uncapped afterwards, so
|
|
552
566
|
* column balancing does not stretch them back to the page bottom.
|
|
567
|
+
*
|
|
568
|
+
* A chapter-closing fixed box passes the `zone` it will take at the
|
|
569
|
+
* bottom of the band: when the level cut would run into it, the columns
|
|
570
|
+
* under the box are cut at the zone's top instead and the others take
|
|
571
|
+
* the displaced text (see {@link bandCapLinesAroundZone}) — the box then
|
|
572
|
+
* fits on the page, under its columns, rather than opening a page alone.
|
|
553
573
|
*/
|
|
554
|
-
const proposeTrailingCap = (boundaryIndex) => {
|
|
574
|
+
const proposeTrailingCap = (boundaryIndex, zone) => {
|
|
555
575
|
const balancingCfg = resolved.headings.balancing;
|
|
556
576
|
if (!balancingCfg.enabled || !balancingCfg.trailing)
|
|
557
577
|
return;
|
|
@@ -576,14 +596,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
576
596
|
if (!bandStart || !registeredBand || registeredBand.pageIndex !== page.index || registeredBand.band !== band)
|
|
577
597
|
return;
|
|
578
598
|
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
579
|
-
|
|
599
|
+
const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
|
|
600
|
+
if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
|
|
580
601
|
return;
|
|
581
602
|
bandCapProposals.set(boundaryIndex, {
|
|
582
603
|
kind: 'trailing',
|
|
583
604
|
startContentIndex: bandStart.contentIndex,
|
|
584
605
|
startPart: bandStart.part,
|
|
585
|
-
lines: bandCapLines(cols, baselineGrid),
|
|
606
|
+
lines: aroundZone ?? bandCapLines(cols, baselineGrid),
|
|
586
607
|
retries: 0,
|
|
608
|
+
...(aroundZone !== null ? { zone } : {}),
|
|
587
609
|
});
|
|
588
610
|
};
|
|
589
611
|
/** Close the flow at a chapter-level boundary (block `boundaryIndex`):
|
|
@@ -617,11 +639,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
617
639
|
// Page-numbering segments. The implicit first segment comes from
|
|
618
640
|
// `cfg.page.pageNumbering`; `:::numbering` directives append more,
|
|
619
641
|
// each applied at the next page boundary.
|
|
642
|
+
// A continued document starts where the previous page left off.
|
|
620
643
|
const pageNumberSegments = [
|
|
621
644
|
{
|
|
622
645
|
startPageIndex: 0,
|
|
623
|
-
format: resolved.page.pageNumbering.format,
|
|
624
|
-
startAt: resolved.page.pageNumbering.startAt,
|
|
646
|
+
format: continuation?.pageNumbering?.format ?? resolved.page.pageNumbering.format,
|
|
647
|
+
startAt: continuation?.pageNumbering?.startAt ?? resolved.page.pageNumbering.startAt,
|
|
625
648
|
},
|
|
626
649
|
];
|
|
627
650
|
let pendingNumberingChange = null;
|
|
@@ -644,22 +667,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
644
667
|
* keep-with-next rollbacks may pull along (a callout is one unbreakable
|
|
645
668
|
* unit; its children never leave it). */
|
|
646
669
|
const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
|
|
647
|
-
/**
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
|
|
652
|
-
* fence, opening to closing marker). */
|
|
653
|
-
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) => {
|
|
654
675
|
const startBlock = contentBlocks[startIdx];
|
|
655
676
|
const endBlock = contentBlocks[plan.endIdx];
|
|
656
677
|
frame.contentIndex = startIdx;
|
|
657
|
-
|
|
658
|
-
|
|
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;
|
|
659
682
|
};
|
|
660
|
-
|
|
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) => {
|
|
661
688
|
const frame = result.frame;
|
|
662
|
-
stampCalloutSource(frame, startIdx, plan);
|
|
689
|
+
stampCalloutSource(frame, startIdx, plan, range);
|
|
663
690
|
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
664
691
|
doc.blocks.push(frame);
|
|
665
692
|
for (const child of result.children) {
|
|
@@ -669,6 +696,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
669
696
|
doc.blocks.push(child);
|
|
670
697
|
}
|
|
671
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
|
+
};
|
|
672
770
|
/**
|
|
673
771
|
* Place a `span: 'page'` `:::callout` in a multi-column layout as a span
|
|
674
772
|
* block (stage 1): the box is laid out at the page's content width and
|
|
@@ -697,6 +795,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
697
795
|
* overflowed instead (the box arrives elsewhere), the driver grows or
|
|
698
796
|
* drops the cap.
|
|
699
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
|
+
*
|
|
700
818
|
* Geometry stays on the baseline grid: the cut line is the band's used
|
|
701
819
|
* bottom snapped UP to the next grid line (anchored at the content-area
|
|
702
820
|
* top, like every column start), and the span column's height is the
|
|
@@ -705,69 +823,169 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
705
823
|
* stay in the outer page bands: the band inherits the float-reduced top
|
|
706
824
|
* / bottom of the page's columns, so a span column never overlaps a float.
|
|
707
825
|
*/
|
|
708
|
-
const placeCalloutSpan = (startIdx, plan,
|
|
709
|
-
|
|
710
|
-
|
|
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;
|
|
711
830
|
const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
|
|
712
831
|
const minRoomPx = minLines * bodyStyle.lineHeightPx;
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const measureBand = (requireLevel) => {
|
|
719
|
-
const cols = bandColumns(page, currentBand(page, cursor));
|
|
720
|
-
if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
|
|
721
|
-
return null;
|
|
722
|
-
const usedBottom = bandUsedBottom(cols);
|
|
723
|
-
const cutY = page.contentArea.y
|
|
724
|
-
+ Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
725
|
-
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
726
|
-
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
727
|
-
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
728
|
-
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
729
|
-
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
730
|
-
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;
|
|
731
837
|
};
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
+
}
|
|
764
978
|
}
|
|
765
|
-
}
|
|
766
|
-
if (!fit || !fit.room) {
|
|
767
979
|
// Open the next page (flushing pending floats into its bands). A page
|
|
768
980
|
// holding only floats counts as occupied here — its float band is what
|
|
769
981
|
// left no room — but a truly empty page is kept: the box then simply
|
|
770
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;
|
|
771
989
|
const curPage = doc.pages[cursor.pageIndex];
|
|
772
990
|
if (pageIsOccupied(curPage)) {
|
|
773
991
|
pendingSpacing = 0;
|
|
@@ -776,26 +994,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
776
994
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
777
995
|
} while (cursor.pageIndex === startPageIndex);
|
|
778
996
|
page = doc.pages[cursor.pageIndex];
|
|
779
|
-
if (Math.abs(page.contentArea.width - result.width) > 0.01) {
|
|
780
|
-
result = layoutAt(page.contentArea.width);
|
|
781
|
-
}
|
|
782
997
|
}
|
|
783
|
-
// A freshly opened page is level; force-place (overflow)
|
|
784
|
-
// is taller than the page.
|
|
785
|
-
|
|
786
|
-
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)
|
|
787
1001
|
return false; // no text column to cut — leave it to the inline path
|
|
1002
|
+
forceHere = true;
|
|
788
1003
|
}
|
|
789
|
-
const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
|
|
790
|
-
commitCallout(result, startIdx, plan, spanCol);
|
|
791
|
-
// Floats first-referenced inside the box enqueue once it is committed,
|
|
792
|
-
// in reading order (same as the inline path).
|
|
793
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
794
|
-
enqueueFloatsFor(i);
|
|
795
|
-
// The new band starts on the grid right below the span column; nothing
|
|
796
|
-
// to snap — `need` already bakes in `marginBottom`.
|
|
797
|
-
pendingSpacing = 0;
|
|
798
|
-
return true;
|
|
799
1004
|
};
|
|
800
1005
|
/** Whether the flow ends at a chapter-level boundary right after block
|
|
801
1006
|
* `from` (skipping container markers): a chapter opener, a `:::part`, a
|
|
@@ -846,7 +1051,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
846
1051
|
};
|
|
847
1052
|
const snapDown = (page, v) => page.contentArea.y + Math.floor((v - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
848
1053
|
const snapUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
849
|
-
|
|
1054
|
+
/** The box laid out for `page` and the zone it takes there. */
|
|
1055
|
+
const zoneOn = (page) => {
|
|
850
1056
|
const ref = anchor.to === 'page' ? designFrames.page
|
|
851
1057
|
: anchor.to === 'bleed' ? designFrames.bleed
|
|
852
1058
|
: page.contentArea;
|
|
@@ -864,10 +1070,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
864
1070
|
const rect = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
|
|
865
1071
|
const zoneTop = snapDown(page, rect.y - result.marginTopPx);
|
|
866
1072
|
const zoneBottom = snapUp(page, rect.y + rect.height + result.marginBottomPx);
|
|
1073
|
+
const meetsX = (col) => rect.x < col.bbox.x + col.bbox.width - 0.5 && rect.x + rect.width > col.bbox.x + 0.5;
|
|
1074
|
+
return { cols, result, rect, zoneTop, zoneBottom, meetsX };
|
|
1075
|
+
};
|
|
1076
|
+
const attempt = (page, force) => {
|
|
1077
|
+
const { cols, result, rect, zoneTop, zoneBottom, meetsX } = zoneOn(page);
|
|
867
1078
|
const cuts = [];
|
|
868
1079
|
for (const col of cols) {
|
|
869
|
-
|
|
870
|
-
if (!meetsX)
|
|
1080
|
+
if (!meetsX(col))
|
|
871
1081
|
continue;
|
|
872
1082
|
const colTop = col.bbox.y;
|
|
873
1083
|
const colBottom = trueBottom(col, uncappedBottoms);
|
|
@@ -903,10 +1113,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
903
1113
|
}
|
|
904
1114
|
return { rect, result, cuts };
|
|
905
1115
|
};
|
|
906
|
-
// A box that closes the chapter levels the columns above it first
|
|
1116
|
+
// A box that closes the chapter levels the columns above it first —
|
|
1117
|
+
// around the zone it takes on the current page.
|
|
907
1118
|
if (nextIsBarrier(plan.endIdx + 1)) {
|
|
908
1119
|
tryPlacePendingFloatsOnCurrentPage();
|
|
909
|
-
|
|
1120
|
+
const here = zoneOn(doc.pages[cursor.pageIndex]);
|
|
1121
|
+
const columns = here.cols.flatMap((c, i) => (here.meetsX(c) ? [i] : []));
|
|
1122
|
+
proposeTrailingCap(startIdx, columns.length > 0 ? { top: here.zoneTop, columns } : undefined);
|
|
910
1123
|
}
|
|
911
1124
|
let page = doc.pages[cursor.pageIndex];
|
|
912
1125
|
let fit = attempt(page, false);
|
|
@@ -965,8 +1178,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
965
1178
|
* baked into the post-box grid snap. A box that does not fit moves to the
|
|
966
1179
|
* next column/page (like a resource), pulling a run of trailing headings
|
|
967
1180
|
* along (keep-with-next); a box taller than an empty column is placed
|
|
968
|
-
* anyway and overflows (the sandbox warns).
|
|
969
|
-
*
|
|
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`.
|
|
970
1187
|
*
|
|
971
1188
|
* `span: 'page'` boxes in multi-column layouts take the span-block path
|
|
972
1189
|
* (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
|
|
@@ -975,25 +1192,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
975
1192
|
*/
|
|
976
1193
|
const placeCalloutInline = (startIdx, plan) => {
|
|
977
1194
|
const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
|
|
978
|
-
const
|
|
979
|
-
const frameId = `block-${blockIdCounter++}`;
|
|
1195
|
+
const firstFrameId = `block-${blockIdCounter++}`;
|
|
980
1196
|
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
return layoutCallout({
|
|
984
|
-
style,
|
|
985
|
-
attrs: plan.attrs,
|
|
986
|
-
children,
|
|
987
|
-
childStartIdx: startIdx + 1,
|
|
988
|
-
width,
|
|
989
|
-
ctx: measureCtx,
|
|
990
|
-
resolved,
|
|
991
|
-
containerId: plan.containerId,
|
|
992
|
-
frameId,
|
|
993
|
-
nextChildId: () => `${frameId}-c${n++}`,
|
|
994
|
-
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
995
|
-
});
|
|
996
|
-
};
|
|
1197
|
+
const L = makeCalloutLayouter(startIdx, plan, style);
|
|
1198
|
+
const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
|
|
997
1199
|
// Fixed boxes leave the flow entirely.
|
|
998
1200
|
if (placement === 'fixed') {
|
|
999
1201
|
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
@@ -1006,65 +1208,93 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1006
1208
|
if (span === 'page'
|
|
1007
1209
|
&& placement === 'here'
|
|
1008
1210
|
&& bandColumns(page, currentBand(page, cursor)).length > 1
|
|
1009
|
-
&& placeCalloutSpan(startIdx, plan,
|
|
1211
|
+
&& placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
|
|
1010
1212
|
return undefined;
|
|
1011
1213
|
}
|
|
1012
1214
|
}
|
|
1013
|
-
|
|
1014
|
-
let
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
|
|
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
|
+
}
|
|
1031
1251
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
1038
|
-
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
|
+
}
|
|
1039
1257
|
}
|
|
1258
|
+
// An empty column that is still too short: placed anyway, overflowing.
|
|
1040
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);
|
|
1041
1292
|
}
|
|
1042
|
-
// Floats first-referenced inside the box still enqueue in reading order
|
|
1043
|
-
// (only once the box is committed, so a keep-with-next replay does not
|
|
1044
|
-
// enqueue them twice).
|
|
1045
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1046
|
-
enqueueFloatsFor(i);
|
|
1047
|
-
const frame = result.frame;
|
|
1048
|
-
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
1049
|
-
enterBand(startIdx, 0);
|
|
1050
|
-
placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1051
|
-
enterBand(startIdx, 0);
|
|
1052
|
-
curCol = currentColumn(doc, cursor);
|
|
1053
|
-
commitCallout(result, startIdx, plan, curCol);
|
|
1054
|
-
// Snap the flow after the box to the baseline grid, baking in at least
|
|
1055
|
-
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
1056
|
-
{
|
|
1057
|
-
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
1058
|
-
const naturalBottom = usedHeight + result.marginBottomPx;
|
|
1059
|
-
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
1060
|
-
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
1061
|
-
}
|
|
1062
|
-
pendingSpacing = 0;
|
|
1063
|
-
return undefined;
|
|
1064
1293
|
};
|
|
1065
1294
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
1066
1295
|
if (options?.shouldCancel?.())
|
|
1067
1296
|
throw new BuildCancelledError();
|
|
1297
|
+
options?.onProgress?.({ pass: 1, blocks: blockIdx, totalBlocks: contentBlocks.length, pages: doc.pages.length });
|
|
1068
1298
|
const rawBlock = contentBlocks[blockIdx];
|
|
1069
1299
|
// Floats whose reference landed in an earlier iteration take the first
|
|
1070
1300
|
// free slot of the current page now — after their reference in reading
|
|
@@ -1155,9 +1385,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1155
1385
|
createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
|
|
1156
1386
|
number: plan.number,
|
|
1157
1387
|
title: plan.title,
|
|
1388
|
+
palette: plan.palette,
|
|
1158
1389
|
titleSourceStart,
|
|
1159
1390
|
titleSourceEnd,
|
|
1160
|
-
});
|
|
1391
|
+
}, pageIndexOffset);
|
|
1161
1392
|
flushPendingNumberingAtBoundary();
|
|
1162
1393
|
continue;
|
|
1163
1394
|
}
|
|
@@ -1380,7 +1611,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1380
1611
|
// List items may split too — orphan/widow protection per-list is gated by
|
|
1381
1612
|
// `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
|
|
1382
1613
|
const canSplit = vdtType === 'paragraph' || vdtType === 'blockquote' || vdtType === 'listItem';
|
|
1383
|
-
|
|
1614
|
+
// A justified line a link leaves with too few spaces is set ragged.
|
|
1615
|
+
let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
|
|
1384
1616
|
let partIndex = 0;
|
|
1385
1617
|
// "Keep with next" for colon-introduced lists: a paragraph ending in `:`
|
|
1386
1618
|
// followed directly by a list acts as a lead-in title — the colon-bearing
|
|
@@ -1831,7 +2063,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1831
2063
|
return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
|
|
1832
2064
|
}
|
|
1833
2065
|
export function buildDocument(content, config, cache, options) {
|
|
1834
|
-
|
|
2066
|
+
// Each pass reports its own progress, numbered in build order.
|
|
2067
|
+
let passIndex = 0;
|
|
2068
|
+
const onProgress = options?.onProgress;
|
|
2069
|
+
const passOptions = onProgress
|
|
2070
|
+
? { ...options, onProgress: (p) => onProgress({ ...p, pass: passIndex }) }
|
|
2071
|
+
: options;
|
|
2072
|
+
const runPass = (hints) => {
|
|
2073
|
+
passIndex++;
|
|
2074
|
+
return buildDocumentPass(content, config, cache, passOptions, hints);
|
|
2075
|
+
};
|
|
1835
2076
|
// --- Band caps (page-span blocks mid-page) -----------------------------
|
|
1836
2077
|
// A span block that arrived in an uneven band proposes a cap; the driver
|
|
1837
2078
|
// re-places the document with it (and grows / drops caps whose band
|