postext 0.3.27 → 0.3.29
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 +78 -1
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- 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/colonBalancing.test.d.ts +2 -0
- package/dist/__tests__/pipeline/colonBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js +124 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js.map +1 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js +33 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts +2 -0
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/headingShortBand.test.js +89 -0
- package/dist/__tests__/pipeline/headingShortBand.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/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +2 -6
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts +15 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.js +104 -8
- package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
- 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 +3 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +21 -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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +531 -168
- 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/columnBalancing.d.ts +30 -5
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +70 -4
- package/dist/pipeline/columnBalancing.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 +38 -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';
|
|
@@ -22,7 +22,7 @@ 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 } from './columnBalancing';
|
|
25
|
+
import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
26
26
|
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
|
|
27
27
|
import { raggedUrlLines } from './raggedUrl';
|
|
28
28
|
export class BuildCancelledError extends Error {
|
|
@@ -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'`.
|
|
@@ -299,6 +303,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
299
303
|
* sends single-column floats to the least reserved column). */
|
|
300
304
|
const floatReserved = new Map();
|
|
301
305
|
const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
|
|
306
|
+
/** Content index of the block that first referenced the latest float
|
|
307
|
+
* reserved at the head of each column. */
|
|
308
|
+
const topFloatRefOf = new Map();
|
|
302
309
|
/** Kind of cap the column is under (`undefined` when uncapped). A cap that
|
|
303
310
|
* cannot be attributed to the active band is treated as a span cap — the
|
|
304
311
|
* conservative reading, which keeps the column's bottom off the slot list. */
|
|
@@ -362,6 +369,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
362
369
|
col.bbox.height = Math.max(0, col.bbox.height - need);
|
|
363
370
|
col.availableHeight = Math.max(0, col.availableHeight - need);
|
|
364
371
|
r.top += need;
|
|
372
|
+
topFloatRefOf.set(col, Math.max(topFloatRefOf.get(col) ?? -1, f.firstBlockIdx));
|
|
365
373
|
}
|
|
366
374
|
else {
|
|
367
375
|
const capped = uncappedBottoms.get(col);
|
|
@@ -436,14 +444,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
436
444
|
* cursor (bottom of the referencing column, top / bottom of the next
|
|
437
445
|
* empty columns; the band bottom for page-span floats). Runs before each
|
|
438
446
|
* block is placed, so a float lands in the first gap after its reference. */
|
|
439
|
-
const tryPlacePendingFloatsOnCurrentPage = () => {
|
|
447
|
+
const tryPlacePendingFloatsOnCurrentPage = (preferTop = false) => {
|
|
440
448
|
if (pendingFloats.length === 0)
|
|
441
449
|
return;
|
|
442
450
|
const page = doc.pages[cursor.pageIndex];
|
|
443
451
|
for (let i = 0; i < pendingFloats.length;) {
|
|
444
452
|
const f = pendingFloats[i];
|
|
445
453
|
let r = 'defer';
|
|
446
|
-
|
|
454
|
+
let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
|
|
455
|
+
// A page-span box comes next: the head of an empty column keeps the
|
|
456
|
+
// band cuttable under the float (the box then sits below both the
|
|
457
|
+
// text and the figure), where the referencing column's foot would
|
|
458
|
+
// wall the box off the page.
|
|
459
|
+
if (preferTop)
|
|
460
|
+
slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
|
|
461
|
+
for (const slot of slots) {
|
|
447
462
|
r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
|
|
448
463
|
if (r !== 'defer')
|
|
449
464
|
break;
|
|
@@ -457,6 +472,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
457
472
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
458
473
|
* content-flow column advances — parity / force-blank pages never get it. */
|
|
459
474
|
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
475
|
+
/** Whether content block `idx` opens a callout that will span the page
|
|
476
|
+
* in the current (multi-column) band — the floats placed right before
|
|
477
|
+
* it prefer the head of an empty column. */
|
|
478
|
+
const spanBoxAt = (idx) => {
|
|
479
|
+
const b = contentBlocks[idx];
|
|
480
|
+
if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
|
|
481
|
+
return false;
|
|
482
|
+
const plan = calloutPlan.get(idx);
|
|
483
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
484
|
+
if (!style || style.span !== 'page' || style.placement === 'fixed')
|
|
485
|
+
return false;
|
|
486
|
+
const page = doc.pages[cursor.pageIndex];
|
|
487
|
+
return bandColumns(page, currentBand(page, cursor)).length > 1;
|
|
488
|
+
};
|
|
460
489
|
/** Chapter barrier: place every pending float before the boundary — in
|
|
461
490
|
* the current page's free slots, then on fresh pages opened ahead of it
|
|
462
491
|
* (each force-places at least one float). The cursor is left on the last
|
|
@@ -527,6 +556,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
527
556
|
let activeCap = null;
|
|
528
557
|
/** True bottoms of capped columns (restored when the span block cuts). */
|
|
529
558
|
const uncappedBottoms = new Map();
|
|
559
|
+
/** Column balancing: height (px) the levers added inside each column so
|
|
560
|
+
* far — extra grid lines above headings / list ends and lines gained by
|
|
561
|
+
* loose paragraphs. Balancing only ever fills a column's bottom gap, so
|
|
562
|
+
* a placement rule that needs slack *after* a block (keep-colon-with-
|
|
563
|
+
* list) must see the column as it was before the levers filled it:
|
|
564
|
+
* otherwise the block that closed the column in the plain pass moves to
|
|
565
|
+
* the next column, the flow shifts on every later page, and the pass is
|
|
566
|
+
* discarded as a regression. */
|
|
567
|
+
const balanceExtraInColumn = new Map();
|
|
568
|
+
const addBalanceExtra = (col, px) => {
|
|
569
|
+
if (px > 0)
|
|
570
|
+
balanceExtraInColumn.set(col, (balanceExtraInColumn.get(col) ?? 0) + px);
|
|
571
|
+
};
|
|
530
572
|
const bandCapProposals = new Map();
|
|
531
573
|
const spanPlacedInBand = new Set();
|
|
532
574
|
const bandCapsApplied = new Set();
|
|
@@ -663,22 +705,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
663
705
|
* keep-with-next rollbacks may pull along (a callout is one unbreakable
|
|
664
706
|
* unit; its children never leave it). */
|
|
665
707
|
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) => {
|
|
708
|
+
/** Stamp a callout frame's content index and source range: the whole
|
|
709
|
+
* fence (opening to closing marker) for an unsplit box; for a fragment
|
|
710
|
+
* of a split box, from the fence start (first fragment) or the first
|
|
711
|
+
* child it holds, to the fence end (last fragment) or the last child. */
|
|
712
|
+
const stampCalloutSource = (frame, startIdx, plan, range) => {
|
|
673
713
|
const startBlock = contentBlocks[startIdx];
|
|
674
714
|
const endBlock = contentBlocks[plan.endIdx];
|
|
675
715
|
frame.contentIndex = startIdx;
|
|
676
|
-
|
|
677
|
-
|
|
716
|
+
const first = range && range.firstChildIdx > startIdx + 1 ? contentBlocks[range.firstChildIdx] : startBlock;
|
|
717
|
+
const last = range && range.lastChildIdx < plan.endIdx - 1 ? contentBlocks[range.lastChildIdx] : endBlock;
|
|
718
|
+
frame.sourceStart = first.sourceStart + bodyOffset;
|
|
719
|
+
frame.sourceEnd = last.sourceEnd + bodyOffset;
|
|
678
720
|
};
|
|
679
|
-
|
|
721
|
+
/** Shared tail of callout placement: stamp the frame's source range,
|
|
722
|
+
* convert the laid-out box to absolute coordinates at the frame's placed
|
|
723
|
+
* origin, and push frame + children — in that order — to `doc.blocks`
|
|
724
|
+
* and to the column the frame landed in. */
|
|
725
|
+
const commitCallout = (result, startIdx, plan, col, range) => {
|
|
680
726
|
const frame = result.frame;
|
|
681
|
-
stampCalloutSource(frame, startIdx, plan);
|
|
727
|
+
stampCalloutSource(frame, startIdx, plan, range);
|
|
682
728
|
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
683
729
|
doc.blocks.push(frame);
|
|
684
730
|
for (const child of result.children) {
|
|
@@ -688,6 +734,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
688
734
|
doc.blocks.push(child);
|
|
689
735
|
}
|
|
690
736
|
};
|
|
737
|
+
const makeCalloutLayouter = (startIdx, plan, style) => {
|
|
738
|
+
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
739
|
+
const realAt = [];
|
|
740
|
+
children.forEach((c, k) => {
|
|
741
|
+
if (c.type !== 'directive' && !isMarkerBlock(c))
|
|
742
|
+
realAt.push(k);
|
|
743
|
+
});
|
|
744
|
+
const layoutRange = (from, to, width, frameId, continuation) => {
|
|
745
|
+
let n = 0;
|
|
746
|
+
return layoutCallout({
|
|
747
|
+
style,
|
|
748
|
+
attrs: plan.attrs,
|
|
749
|
+
continuation,
|
|
750
|
+
children: children.slice(from, to),
|
|
751
|
+
childStartIdx: startIdx + 1 + from,
|
|
752
|
+
width,
|
|
753
|
+
ctx: measureCtx,
|
|
754
|
+
resolved,
|
|
755
|
+
containerId: plan.containerId,
|
|
756
|
+
frameId,
|
|
757
|
+
nextChildId: () => `${frameId}-c${n++}`,
|
|
758
|
+
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
759
|
+
});
|
|
760
|
+
};
|
|
761
|
+
return { children, childBase: startIdx + 1, realAt, layoutRange };
|
|
762
|
+
};
|
|
763
|
+
/** The longest leading fragment of the children from `from` on whose box
|
|
764
|
+
* is at most `roomPx` tall — at least one child, and at least one left
|
|
765
|
+
* for the rest. The full layout's child geometry picks the candidate
|
|
766
|
+
* (box bottom = child bottom + the box's tail below its last child);
|
|
767
|
+
* the candidate is then laid out for real and shortened while it does
|
|
768
|
+
* not fit. `null` when not even the first child fits. */
|
|
769
|
+
const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation) => {
|
|
770
|
+
const starts = L.realAt.filter((k) => k >= from);
|
|
771
|
+
if (starts.length < 2)
|
|
772
|
+
return null;
|
|
773
|
+
const full = L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
774
|
+
const lastChild = full.children[full.children.length - 1];
|
|
775
|
+
if (!lastChild)
|
|
776
|
+
return null;
|
|
777
|
+
const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
|
|
778
|
+
/** Frame-relative bottom of the last laid-out child before position `to`. */
|
|
779
|
+
const bottomBefore = (to) => {
|
|
780
|
+
let bottom = 0;
|
|
781
|
+
for (const c of full.children) {
|
|
782
|
+
if (c.contentIndex !== undefined && c.contentIndex < L.childBase + to) {
|
|
783
|
+
bottom = Math.max(bottom, c.bbox.y + c.bbox.height);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return bottom;
|
|
787
|
+
};
|
|
788
|
+
let j = starts.length - 1;
|
|
789
|
+
while (j >= 1 && bottomBefore(starts[j]) + tail > roomPx + 0.01)
|
|
790
|
+
j--;
|
|
791
|
+
for (; j >= 1; j--) {
|
|
792
|
+
const to = starts[j];
|
|
793
|
+
const result = L.layoutRange(from, to, width, frameId, continuation);
|
|
794
|
+
if (result.totalHeight <= roomPx + 0.01)
|
|
795
|
+
return { to, result };
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
};
|
|
799
|
+
/** Absolute content indices of the children in `[from, to)` of `L`, for
|
|
800
|
+
* the fragment's source range. */
|
|
801
|
+
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from, lastChildIdx: L.childBase + to - 1 });
|
|
802
|
+
const markFragment = (result, part, continued) => {
|
|
803
|
+
if (result.frame.callout) {
|
|
804
|
+
result.frame.callout.part = part;
|
|
805
|
+
result.frame.callout.continued = continued;
|
|
806
|
+
}
|
|
807
|
+
};
|
|
691
808
|
/**
|
|
692
809
|
* Place a `span: 'page'` `:::callout` in a multi-column layout as a span
|
|
693
810
|
* block (stage 1): the box is laid out at the page's content width and
|
|
@@ -716,6 +833,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
716
833
|
* overflowed instead (the box arrives elsewhere), the driver grows or
|
|
717
834
|
* drops the cap.
|
|
718
835
|
*
|
|
836
|
+
* Leaving level (`headings.balancing.beforeSpan`): a box that does not
|
|
837
|
+
* fit even after a level cut proposes a TRAILING cap instead — the band
|
|
838
|
+
* it leaves is cut level, the way a chapter's closing band is, and the
|
|
839
|
+
* page is marked as a forced break in that pass so balancing does not
|
|
840
|
+
* stretch its last column back to the page bottom. The cap is resolved
|
|
841
|
+
* after balancing (`resolveTrailingCaps`); when the box reaches the
|
|
842
|
+
* capped band it counts as delivered whether it fits there or moves on,
|
|
843
|
+
* and the cut columns stay cut (the polish round fills a column ending a
|
|
844
|
+
* line under the cap).
|
|
845
|
+
*
|
|
846
|
+
* Splitting (`keepTogether: false`): a box that does not fit a level (or
|
|
847
|
+
* capped, or nearly level — within one grid line) band breaks between
|
|
848
|
+
* its children: the longest fragment that fits closes the page flush
|
|
849
|
+
* with the band bottom, the rest opens the next page in a box without
|
|
850
|
+
* the title or icon, and splits again if it is still too tall. Such a box that
|
|
851
|
+
* fits whole only flush with the page bottom (no text below) is placed
|
|
852
|
+
* whole. When the band is uneven, the cap that levels it (span cap when
|
|
853
|
+
* the whole box fits after the cut, trailing cap otherwise) comes first;
|
|
854
|
+
* the fragment is cut in the capped pass.
|
|
855
|
+
*
|
|
719
856
|
* Geometry stays on the baseline grid: the cut line is the band's used
|
|
720
857
|
* bottom snapped UP to the next grid line (anchored at the content-area
|
|
721
858
|
* top, like every column start), and the span column's height is the
|
|
@@ -724,69 +861,197 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
724
861
|
* stay in the outer page bands: the band inherits the float-reduced top
|
|
725
862
|
* / bottom of the page's columns, so a span column never overlaps a float.
|
|
726
863
|
*/
|
|
727
|
-
const placeCalloutSpan = (startIdx, plan,
|
|
728
|
-
|
|
729
|
-
|
|
864
|
+
const placeCalloutSpan = (startIdx, plan, style, L, firstFrameId) => {
|
|
865
|
+
const splittable = !style.keepTogether;
|
|
866
|
+
const balancingCfg = resolved.headings.balancing;
|
|
867
|
+
const levelBefore = balancingCfg.enabled && balancingCfg.beforeSpan;
|
|
730
868
|
const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
|
|
731
869
|
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 };
|
|
870
|
+
const gridUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
871
|
+
const needFor = (spacing, height, margin) => Math.ceil((spacing + height + margin - 0.01) / baselineGrid) * baselineGrid;
|
|
872
|
+
const nearlyLevel = (cols) => {
|
|
873
|
+
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
874
|
+
return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
|
|
750
875
|
};
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
876
|
+
/** Level for the box: the text columns end level, and a column holding
|
|
877
|
+
* only a float band (a figure at its head, no text yet) counts as level
|
|
878
|
+
* when a band cap could not level it either — the figure was first
|
|
879
|
+
* referenced by the very block before the box, so a cut that spills
|
|
880
|
+
* that block into the figure's column leaves the figure no slot after
|
|
881
|
+
* its reference (it would fall off the page, and the box with it). The
|
|
882
|
+
* box then cuts under the text and the figure alike, the slack under
|
|
883
|
+
* the figure being the compositor's usual trade; a figure referenced
|
|
884
|
+
* earlier keeps the cap route, which flows text under it. */
|
|
885
|
+
const lastBlockBefore = () => {
|
|
886
|
+
let j = startIdx - 1;
|
|
887
|
+
while (j >= 0 && isMarkerBlock(contentBlocks[j]))
|
|
888
|
+
j--;
|
|
889
|
+
return j;
|
|
890
|
+
};
|
|
891
|
+
const levelForBox = (cols) => {
|
|
892
|
+
if (isBandLevel(cols))
|
|
893
|
+
return true;
|
|
894
|
+
const textCols = cols.filter((c) => c.blocks.length > 0);
|
|
895
|
+
const floatOnly = cols.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0);
|
|
896
|
+
if (textCols.length === 0 || textCols.length + floatOnly.length !== cols.length)
|
|
897
|
+
return false;
|
|
898
|
+
if (!isBandLevel(textCols))
|
|
899
|
+
return false;
|
|
900
|
+
const textBottom = bandUsedBottom(textCols);
|
|
901
|
+
const before = lastBlockBefore();
|
|
902
|
+
return floatOnly.every((c) => c.bbox.y <= textBottom + baselineGrid + 0.5 && topFloatRefOf.get(c) === before);
|
|
903
|
+
};
|
|
904
|
+
/** Position in the children the fragment to place starts at, and its
|
|
905
|
+
* 0-based index among the fragments (0 = the box, or its head). */
|
|
906
|
+
let from = 0;
|
|
907
|
+
let part = 0;
|
|
908
|
+
let frameId = firstFrameId;
|
|
909
|
+
/** The box already moved to a fresh page (or sits on an empty one):
|
|
910
|
+
* whatever does not fit there is force-placed and overflows. */
|
|
911
|
+
let forceHere = false;
|
|
912
|
+
for (;;) {
|
|
913
|
+
let page = doc.pages[cursor.pageIndex];
|
|
914
|
+
const continuation = part > 0;
|
|
915
|
+
const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
916
|
+
const result = layoutAt(page.contentArea.width);
|
|
917
|
+
/** Where the box would cut the current band, and whether it fits (room
|
|
918
|
+
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
919
|
+
* its slack below the cap). Null when the band is not level (or the
|
|
920
|
+
* cursor sits on a span column with no band below it) and
|
|
921
|
+
* `requireLevel` is set. */
|
|
922
|
+
const measureBand = (requireLevel) => {
|
|
923
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
924
|
+
if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
|
|
925
|
+
return null;
|
|
926
|
+
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
927
|
+
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
928
|
+
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
929
|
+
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
930
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
931
|
+
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
932
|
+
const needFlush = needFor(spacing, result.totalHeight, 0);
|
|
933
|
+
const roomFlush = cutY + needFlush <= bandBottom + 0.01;
|
|
934
|
+
return { cols, cutY, spacing, need, room, needFlush, roomFlush, roomPx: bandBottom - cutY };
|
|
935
|
+
};
|
|
936
|
+
// Is this band capped for this very box? Then it cuts at the band's
|
|
937
|
+
// used bottom (at most the cap) even when the last column is short.
|
|
938
|
+
const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
|
|
939
|
+
const capActive = cap !== undefined
|
|
940
|
+
&& activeCap !== null
|
|
941
|
+
&& activeCap.spanIndex === startIdx
|
|
942
|
+
&& activeCap.pageIndex === page.index
|
|
943
|
+
&& activeCap.band === currentBand(page, cursor);
|
|
944
|
+
const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
|
|
945
|
+
let action = null;
|
|
946
|
+
if (fit) {
|
|
947
|
+
if (fit.room)
|
|
948
|
+
action = { kind: 'whole', fit, need: fit.need, result };
|
|
949
|
+
else if (splittable && fit.roomFlush)
|
|
950
|
+
action = { kind: 'whole', fit, need: fit.needFlush, result };
|
|
951
|
+
}
|
|
952
|
+
if (!action && splittable) {
|
|
953
|
+
// A band uneven by no more than a grid line still takes a fragment
|
|
954
|
+
// cut at its used bottom: balancing fills the line the short column
|
|
955
|
+
// is left under the cut.
|
|
956
|
+
let band = fit;
|
|
957
|
+
if (!band) {
|
|
958
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
959
|
+
if (cols.length > 0 && nearlyLevel(cols))
|
|
960
|
+
band = measureBand(false);
|
|
961
|
+
}
|
|
962
|
+
if (band) {
|
|
963
|
+
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
|
|
964
|
+
if (fragment) {
|
|
965
|
+
action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (!action && forceHere && fit)
|
|
970
|
+
action = { kind: 'whole', fit, need: fit.need, result };
|
|
971
|
+
if (action) {
|
|
972
|
+
if (capActive) {
|
|
973
|
+
uncapBand(action.fit.cols, uncappedBottoms);
|
|
974
|
+
spanPlacedInBand.add(startIdx);
|
|
975
|
+
}
|
|
976
|
+
const placed = action.kind === 'whole' ? action.result : action.fragment.result;
|
|
977
|
+
const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
|
|
978
|
+
if (part > 0 || action.kind === 'split')
|
|
979
|
+
markFragment(placed, part, action.kind === 'split');
|
|
980
|
+
const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
|
|
981
|
+
commitCallout(placed, startIdx, plan, spanCol, part > 0 || action.kind === 'split' ? fragmentRange(L, from, to) : undefined);
|
|
982
|
+
// Floats first-referenced inside the box enqueue once its head is
|
|
983
|
+
// committed, in reading order (same as the inline path).
|
|
984
|
+
if (part === 0)
|
|
985
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
986
|
+
enqueueFloatsFor(i);
|
|
987
|
+
// The new band starts on the grid right below the span column; nothing
|
|
988
|
+
// to snap — `need` already bakes in `marginBottom`.
|
|
989
|
+
pendingSpacing = 0;
|
|
990
|
+
if (action.kind === 'whole')
|
|
991
|
+
return true;
|
|
992
|
+
// The rest opens the next page.
|
|
993
|
+
from = to;
|
|
994
|
+
part++;
|
|
995
|
+
frameId = `block-${blockIdCounter++}`;
|
|
996
|
+
const startPageIndex = cursor.pageIndex;
|
|
997
|
+
do {
|
|
998
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
999
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
1000
|
+
forceHere = true;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
// Nothing of the box lands in this band.
|
|
1004
|
+
if (part === 0) {
|
|
1005
|
+
let proposedSpan = false;
|
|
1006
|
+
if (!fit && cap === undefined && bandStart && registeredBand
|
|
1007
|
+
&& registeredBand.pageIndex === page.index
|
|
1008
|
+
&& registeredBand.band === currentBand(page, cursor)) {
|
|
1009
|
+
// Uneven band, no cap yet: propose one when a level cut would leave
|
|
1010
|
+
// room for the box plus the widow minimum of body lines below it
|
|
1011
|
+
// (or, for a splittable box, for the whole box flush with the
|
|
1012
|
+
// band bottom).
|
|
1013
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
1014
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
1015
|
+
const capBottom = bandTop(cols) + lines * baselineGrid;
|
|
1016
|
+
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
1017
|
+
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
1018
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1019
|
+
const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
|
|
1020
|
+
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
|
|
1021
|
+
if (fitsAfterCut) {
|
|
1022
|
+
bandCapProposals.set(startIdx, {
|
|
1023
|
+
kind: 'span',
|
|
1024
|
+
startContentIndex: bandStart.contentIndex,
|
|
1025
|
+
startPart: bandStart.part,
|
|
1026
|
+
lines,
|
|
1027
|
+
retries: 0,
|
|
1028
|
+
});
|
|
1029
|
+
proposedSpan = true;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (levelBefore && !capActive && !proposedSpan) {
|
|
1033
|
+
// The box leaves an uncapped band: level it behind the box (a
|
|
1034
|
+
// trailing cap, resolved after balancing) and keep balancing from
|
|
1035
|
+
// stretching its last column to the page bottom meanwhile.
|
|
1036
|
+
proposeTrailingCap(startIdx);
|
|
1037
|
+
markForcedBreak();
|
|
1038
|
+
}
|
|
1039
|
+
else if (capActive && cap.kind === 'trailing') {
|
|
1040
|
+
// Reached inside the band cut level for it: delivered even though
|
|
1041
|
+
// the box moves on — the columns stay cut.
|
|
1042
|
+
spanPlacedInBand.add(startIdx);
|
|
1043
|
+
}
|
|
783
1044
|
}
|
|
784
|
-
}
|
|
785
|
-
if (!fit || !fit.room) {
|
|
786
1045
|
// Open the next page (flushing pending floats into its bands). A page
|
|
787
1046
|
// holding only floats counts as occupied here — its float band is what
|
|
788
1047
|
// left no room — but a truly empty page is kept: the box then simply
|
|
789
1048
|
// does not fit a page and is force-placed (overflowing, like inline).
|
|
1049
|
+
// On a fresh page the whole box is force-placed above whenever the
|
|
1050
|
+
// page has a text column to cut; reaching this point there means it
|
|
1051
|
+
// has none — leave the box to the inline path (its head, if any, is
|
|
1052
|
+
// already committed: `true` keeps the rest from being placed twice).
|
|
1053
|
+
if (forceHere)
|
|
1054
|
+
return part > 0;
|
|
790
1055
|
const curPage = doc.pages[cursor.pageIndex];
|
|
791
1056
|
if (pageIsOccupied(curPage)) {
|
|
792
1057
|
pendingSpacing = 0;
|
|
@@ -795,26 +1060,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
795
1060
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
796
1061
|
} while (cursor.pageIndex === startPageIndex);
|
|
797
1062
|
page = doc.pages[cursor.pageIndex];
|
|
798
|
-
if (Math.abs(page.contentArea.width - result.width) > 0.01) {
|
|
799
|
-
result = layoutAt(page.contentArea.width);
|
|
800
|
-
}
|
|
801
1063
|
}
|
|
802
|
-
// A freshly opened page is level; force-place (overflow)
|
|
803
|
-
// is taller than the page.
|
|
804
|
-
|
|
805
|
-
if (!fit)
|
|
1064
|
+
// A freshly opened (or empty) page is level; force-place (overflow)
|
|
1065
|
+
// when the box is taller than the page.
|
|
1066
|
+
if (bandColumns(page, currentBand(page, cursor)).length === 0)
|
|
806
1067
|
return false; // no text column to cut — leave it to the inline path
|
|
1068
|
+
forceHere = true;
|
|
807
1069
|
}
|
|
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
1070
|
};
|
|
819
1071
|
/** Whether the flow ends at a chapter-level boundary right after block
|
|
820
1072
|
* `from` (skipping container markers): a chapter opener, a `:::part`, a
|
|
@@ -992,8 +1244,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
992
1244
|
* baked into the post-box grid snap. A box that does not fit moves to the
|
|
993
1245
|
* next column/page (like a resource), pulling a run of trailing headings
|
|
994
1246
|
* along (keep-with-next); a box taller than an empty column is placed
|
|
995
|
-
* anyway and overflows (the sandbox warns).
|
|
996
|
-
*
|
|
1247
|
+
* anyway and overflows (the sandbox warns). A splittable box
|
|
1248
|
+
* (`keepTogether: false`) instead leaves the longest run of its children
|
|
1249
|
+
* that fits in the column and continues — in a box of its own, without
|
|
1250
|
+
* the title or icon — at the top of the next one, splitting again if needed.
|
|
1251
|
+
* Returns the content index to rewind the main loop to when headings
|
|
1252
|
+
* were rolled back, else `undefined`.
|
|
997
1253
|
*
|
|
998
1254
|
* `span: 'page'` boxes in multi-column layouts take the span-block path
|
|
999
1255
|
* (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
|
|
@@ -1002,25 +1258,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1002
1258
|
*/
|
|
1003
1259
|
const placeCalloutInline = (startIdx, plan) => {
|
|
1004
1260
|
const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
|
|
1005
|
-
const
|
|
1006
|
-
const frameId = `block-${blockIdCounter++}`;
|
|
1261
|
+
const firstFrameId = `block-${blockIdCounter++}`;
|
|
1007
1262
|
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
|
-
};
|
|
1263
|
+
const L = makeCalloutLayouter(startIdx, plan, style);
|
|
1264
|
+
const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
|
|
1024
1265
|
// Fixed boxes leave the flow entirely.
|
|
1025
1266
|
if (placement === 'fixed') {
|
|
1026
1267
|
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
@@ -1033,61 +1274,88 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1033
1274
|
if (span === 'page'
|
|
1034
1275
|
&& placement === 'here'
|
|
1035
1276
|
&& bandColumns(page, currentBand(page, cursor)).length > 1
|
|
1036
|
-
&& placeCalloutSpan(startIdx, plan,
|
|
1277
|
+
&& placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
|
|
1037
1278
|
return undefined;
|
|
1038
1279
|
}
|
|
1039
1280
|
}
|
|
1040
|
-
|
|
1041
|
-
let
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (
|
|
1057
|
-
|
|
1281
|
+
const splittable = !style.keepTogether;
|
|
1282
|
+
let from = 0;
|
|
1283
|
+
let part = 0;
|
|
1284
|
+
let frameId = firstFrameId;
|
|
1285
|
+
for (;;) {
|
|
1286
|
+
let curCol = currentColumn(doc, cursor);
|
|
1287
|
+
const continuation = part > 0;
|
|
1288
|
+
const result = L.layoutRange(from, L.children.length, curCol.bbox.width, frameId, continuation);
|
|
1289
|
+
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
1290
|
+
const roomPx = curCol.availableHeight - spacing;
|
|
1291
|
+
let fragment = null;
|
|
1292
|
+
if (result.totalHeight > roomPx + 0.01) {
|
|
1293
|
+
// The (rest of the) box does not fit the column: a splittable box
|
|
1294
|
+
// leaves the head that fits here…
|
|
1295
|
+
if (splittable)
|
|
1296
|
+
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
|
|
1297
|
+
if (!fragment && curCol.blocks.length > 0) {
|
|
1298
|
+
// …otherwise it moves whole to the next column. Keep-with-next: a
|
|
1299
|
+
// run of headings at the column's tail travels with the box.
|
|
1300
|
+
// Skipped when the column holds nothing else (rolling back again
|
|
1301
|
+
// would loop) — the headings stay, orphaned. Only the head of a
|
|
1302
|
+
// box can roll headings back: a continuation always lands in a
|
|
1303
|
+
// fresh column.
|
|
1304
|
+
let run = 0;
|
|
1305
|
+
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
1306
|
+
if (isFreeHeading(curCol.blocks[j]))
|
|
1307
|
+
run++;
|
|
1308
|
+
else
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
pendingSpacing = 0;
|
|
1312
|
+
if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
|
|
1313
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1314
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1315
|
+
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
1316
|
+
}
|
|
1058
1317
|
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);
|
|
1318
|
+
curCol = currentColumn(doc, cursor);
|
|
1319
|
+
// Columns of different widths (oneAndHalf): re-lay out for the new one.
|
|
1320
|
+
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1066
1323
|
}
|
|
1324
|
+
// An empty column that is still too short: placed anyway, overflowing.
|
|
1067
1325
|
}
|
|
1326
|
+
// Floats first-referenced inside the box still enqueue in reading order
|
|
1327
|
+
// (only once the box is committed, so a keep-with-next replay does not
|
|
1328
|
+
// enqueue them twice).
|
|
1329
|
+
if (part === 0)
|
|
1330
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1331
|
+
enqueueFloatsFor(i);
|
|
1332
|
+
const placed = fragment ? fragment.result : result;
|
|
1333
|
+
const to = fragment ? fragment.to : L.children.length;
|
|
1334
|
+
if (part > 0 || fragment)
|
|
1335
|
+
markFragment(placed, part, fragment !== null);
|
|
1336
|
+
const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
|
|
1337
|
+
enterBand(startIdx, 0);
|
|
1338
|
+
placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1339
|
+
enterBand(startIdx, 0);
|
|
1340
|
+
curCol = currentColumn(doc, cursor);
|
|
1341
|
+
commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
|
|
1342
|
+
// Snap the flow after the box to the baseline grid, baking in at least
|
|
1343
|
+
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
1344
|
+
{
|
|
1345
|
+
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
1346
|
+
const naturalBottom = usedHeight + placed.marginBottomPx;
|
|
1347
|
+
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
1348
|
+
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
1349
|
+
}
|
|
1350
|
+
pendingSpacing = 0;
|
|
1351
|
+
if (!fragment)
|
|
1352
|
+
return undefined;
|
|
1353
|
+
// The rest continues at the top of the next column.
|
|
1354
|
+
from = to;
|
|
1355
|
+
part++;
|
|
1356
|
+
frameId = `block-${blockIdCounter++}`;
|
|
1357
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1068
1358
|
}
|
|
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
1359
|
};
|
|
1092
1360
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
1093
1361
|
if (options?.shouldCancel?.())
|
|
@@ -1099,7 +1367,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1099
1367
|
// order. Then enqueue the floats first-referenced in this block, so the
|
|
1100
1368
|
// next page opened while placing it (or any later block) reserves their
|
|
1101
1369
|
// band and the next iteration offers them the slots that follow.
|
|
1102
|
-
tryPlacePendingFloatsOnCurrentPage();
|
|
1370
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
|
|
1103
1371
|
enqueueFloatsFor(blockIdx);
|
|
1104
1372
|
// --- Directives ----------------------------------------------------
|
|
1105
1373
|
if (rawBlock.type === 'directive') {
|
|
@@ -1183,6 +1451,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1183
1451
|
createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
|
|
1184
1452
|
number: plan.number,
|
|
1185
1453
|
title: plan.title,
|
|
1454
|
+
palette: plan.palette,
|
|
1186
1455
|
titleSourceStart,
|
|
1187
1456
|
titleSourceEnd,
|
|
1188
1457
|
}, pageIndexOffset);
|
|
@@ -1218,7 +1487,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1218
1487
|
// slots, else on pages opened ahead of it — so no float escapes
|
|
1219
1488
|
// past it. The page stays balanceable (no forced break).
|
|
1220
1489
|
if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
|
|
1221
|
-
tryPlacePendingFloatsOnCurrentPage();
|
|
1490
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
|
|
1222
1491
|
drainPendingFloats();
|
|
1223
1492
|
}
|
|
1224
1493
|
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
@@ -1411,6 +1680,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1411
1680
|
// A justified line a link leaves with too few spaces is set ragged.
|
|
1412
1681
|
let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
|
|
1413
1682
|
let partIndex = 0;
|
|
1683
|
+
/** Times this block left an EMPTY short column (see `shortColumn`) —
|
|
1684
|
+
* bounded so a page whose columns are all short (footnotes, design
|
|
1685
|
+
* bands) cannot make it wander forever. */
|
|
1686
|
+
let shortColumnMoves = 0;
|
|
1414
1687
|
// "Keep with next" for colon-introduced lists: a paragraph ending in `:`
|
|
1415
1688
|
// followed directly by a list acts as a lead-in title — the colon-bearing
|
|
1416
1689
|
// line must share a column with the first list item. Only checked for the
|
|
@@ -1439,8 +1712,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1439
1712
|
// page top.
|
|
1440
1713
|
if (vdtType === 'heading') {
|
|
1441
1714
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1442
|
-
if (extraPx)
|
|
1715
|
+
if (extraPx) {
|
|
1443
1716
|
spacingBefore += extraPx;
|
|
1717
|
+
addBalanceExtra(curCol, extraPx);
|
|
1718
|
+
}
|
|
1444
1719
|
}
|
|
1445
1720
|
}
|
|
1446
1721
|
else if (vdtType === 'listItem') {
|
|
@@ -1454,10 +1729,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1454
1729
|
// handled inside the heading branch above (after margin collapsing).
|
|
1455
1730
|
if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
|
|
1456
1731
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1457
|
-
if (extraPx)
|
|
1732
|
+
if (extraPx) {
|
|
1458
1733
|
spacingBefore += extraPx;
|
|
1734
|
+
addBalanceExtra(curCol, extraPx);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
else if (partIndex === 0 && reservedOf(curCol).top > 0) {
|
|
1739
|
+
// Column balancing: extra grid lines between the float band at the
|
|
1740
|
+
// head of this column and its first block (the after-float lever).
|
|
1741
|
+
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1742
|
+
if (extraPx) {
|
|
1743
|
+
spacingBefore += extraPx;
|
|
1744
|
+
addBalanceExtra(curCol, extraPx);
|
|
1459
1745
|
}
|
|
1460
1746
|
}
|
|
1747
|
+
// A loose paragraph's extra line is balancing height too (it lands
|
|
1748
|
+
// whole in this column — loose candidates are never split parts).
|
|
1749
|
+
if (partIndex === 0 && tryLoose && looseLines !== undefined && typeof looseOutcome.get(blockIdx) === 'number') {
|
|
1750
|
+
addBalanceExtra(curCol, looseLines * style.lineHeightPx);
|
|
1751
|
+
}
|
|
1461
1752
|
const effectiveAvailable = curCol.availableHeight - spacingBefore;
|
|
1462
1753
|
const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
|
|
1463
1754
|
// Math display blocks carry their natural pixel height on the single
|
|
@@ -1501,7 +1792,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1501
1792
|
&& curCol.blocks.length > 0) {
|
|
1502
1793
|
const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1503
1794
|
const paragraphBottom = usedHeight + totalRemainHeight;
|
|
1504
|
-
|
|
1795
|
+
// Slack after the paragraph as the plain pass saw it: the height
|
|
1796
|
+
// balancing added above in this column is not room the list lost.
|
|
1797
|
+
const availableAfter = curCol.bbox.height - paragraphBottom + (balanceExtraInColumn.get(curCol) ?? 0);
|
|
1505
1798
|
const nextListKind = nextBlock?.listKind ?? 'unordered';
|
|
1506
1799
|
const nextListMarginDim = nextListKind === 'ordered'
|
|
1507
1800
|
? resolved.orderedLists.marginTop
|
|
@@ -1581,6 +1874,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1581
1874
|
// headingRunCount === curCol.blocks.length: fall through to place.
|
|
1582
1875
|
}
|
|
1583
1876
|
}
|
|
1877
|
+
// A block opening a column normally stays (no column can take it any
|
|
1878
|
+
// better), except in a column at least a line shorter than the content
|
|
1879
|
+
// area — the band under a page-span box, or a column cut by a float:
|
|
1880
|
+
// a heading that fits there alone, or a block that does not fit there
|
|
1881
|
+
// but would fit a full column, opens the next column instead (the move
|
|
1882
|
+
// count is bounded besides). A column cut by a band cap is short on
|
|
1883
|
+
// purpose — its content is meant to end at the cut.
|
|
1884
|
+
const shortColumn = shortColumnMoves < 4
|
|
1885
|
+
&& !uncappedBottoms.has(curCol)
|
|
1886
|
+
&& curCol.bbox.height < contentArea.height - baselineGrid;
|
|
1584
1887
|
// Block fits in current column
|
|
1585
1888
|
if (effectiveRemainHeight <= effectiveAvailable) {
|
|
1586
1889
|
// Heading keep-with-next: never leave a heading as the last block of a
|
|
@@ -1597,7 +1900,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1597
1900
|
&& resolved.headings.keepWithNext
|
|
1598
1901
|
&& !nextIsHeading
|
|
1599
1902
|
&& nextBlock !== null
|
|
1600
|
-
&& curCol.blocks.length > 0) {
|
|
1903
|
+
&& (curCol.blocks.length > 0 || shortColumn)) {
|
|
1601
1904
|
const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1602
1905
|
const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
|
|
1603
1906
|
const snappedBottom = shouldSnapToGrid
|
|
@@ -1609,6 +1912,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1609
1912
|
: 1;
|
|
1610
1913
|
const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx;
|
|
1611
1914
|
if (remainAfterHeading < minSpaceAfter) {
|
|
1915
|
+
if (curCol.blocks.length === 0)
|
|
1916
|
+
shortColumnMoves++;
|
|
1612
1917
|
// Roll back any immediately-preceding heading blocks in this
|
|
1613
1918
|
// column so they travel with this one.
|
|
1614
1919
|
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
@@ -1772,8 +2077,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1772
2077
|
}
|
|
1773
2078
|
// choice.splitAt === 0: fall through to push whole paragraph to next column
|
|
1774
2079
|
}
|
|
1775
|
-
// Cannot split — advance to next column if current has content
|
|
1776
|
-
|
|
2080
|
+
// Cannot split — advance to next column if current has content (or
|
|
2081
|
+
// the column is a short band that cannot hold the block at all).
|
|
2082
|
+
if (curCol.blocks.length > 0 || (shortColumn && effectiveRemainHeight <= contentArea.height)) {
|
|
2083
|
+
if (curCol.blocks.length === 0)
|
|
2084
|
+
shortColumnMoves++;
|
|
1777
2085
|
// Heading keep-with-next (no-fit variant): when a heading can't fit
|
|
1778
2086
|
// in the current column and the column's tail is a run of headings,
|
|
1779
2087
|
// pull those headings along so they don't remain stranded as orphans
|
|
@@ -1896,17 +2204,67 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1896
2204
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1897
2205
|
let applied = { lines: new Map(), loose: new Map() };
|
|
1898
2206
|
const failedLoose = new Set();
|
|
2207
|
+
const failedLines = new Set();
|
|
1899
2208
|
let converged = bestScore === 0;
|
|
2209
|
+
/**
|
|
2210
|
+
* A rejected pass moved content across a column break somewhere (a
|
|
2211
|
+
* split paragraph whose head no longer fits, a float that lost its slot,
|
|
2212
|
+
* a lead-in that left with its list…): every page after that point is
|
|
2213
|
+
* re-flowed, gaps open elsewhere and a span cap may miss its band. The
|
|
2214
|
+
* levers are meant to be local, so contain the damage: find the first
|
|
2215
|
+
* column whose content changed and blacklist the levers this pass newly
|
|
2216
|
+
* applied there (failing that, on its page; failing that, everywhere), so
|
|
2217
|
+
* the next proposal keeps the working levers before it and tries again
|
|
2218
|
+
* without the one that cascaded. Returns whether anything was blacklisted.
|
|
2219
|
+
*/
|
|
2220
|
+
const containCascade = (next, proposal) => {
|
|
2221
|
+
const div = firstDivergentColumn(best.doc, next.doc);
|
|
2222
|
+
if (!div)
|
|
2223
|
+
return false;
|
|
2224
|
+
const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
|
|
2225
|
+
const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
2226
|
+
if (newLines.length === 0 && newLoose.length === 0)
|
|
2227
|
+
return false;
|
|
2228
|
+
const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
|
|
2229
|
+
const blacklist = (cands) => {
|
|
2230
|
+
let hit = false;
|
|
2231
|
+
for (const k of newLines)
|
|
2232
|
+
if (!cands || cands.has(k)) {
|
|
2233
|
+
failedLines.add(k);
|
|
2234
|
+
hit = true;
|
|
2235
|
+
}
|
|
2236
|
+
for (const k of newLoose)
|
|
2237
|
+
if (!cands || cands.has(k)) {
|
|
2238
|
+
failedLoose.add(k);
|
|
2239
|
+
hit = true;
|
|
2240
|
+
}
|
|
2241
|
+
return hit;
|
|
2242
|
+
};
|
|
2243
|
+
const inColumn = gaps
|
|
2244
|
+
.filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
|
|
2245
|
+
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2246
|
+
if (blacklist(new Set(inColumn)))
|
|
2247
|
+
return true;
|
|
2248
|
+
const onPage = gaps
|
|
2249
|
+
.filter((g) => g.pageIndex === div.pageIndex)
|
|
2250
|
+
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2251
|
+
if (blacklist(new Set(onPage)))
|
|
2252
|
+
return true;
|
|
2253
|
+
return blacklist(null);
|
|
2254
|
+
};
|
|
1900
2255
|
const balance = () => {
|
|
1901
2256
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1902
2257
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
1903
2258
|
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
1904
2259
|
stretchAfterLists: balancing.stretchAfterLists,
|
|
1905
2260
|
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
2261
|
+
stretchAfterFloats: balancing.stretchAfterFloats,
|
|
2262
|
+
maxLinesAfterFloat: balancing.maxLinesAfterFloat,
|
|
1906
2263
|
looseParagraphs: balancing.looseParagraphs,
|
|
1907
2264
|
maxLooseParagraphs: balancing.maxLooseParagraphs,
|
|
1908
2265
|
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
1909
2266
|
failedLoose,
|
|
2267
|
+
failedLines,
|
|
1910
2268
|
});
|
|
1911
2269
|
if (!proposal.changed) {
|
|
1912
2270
|
// No stretch point can absorb the remaining gaps — stable.
|
|
@@ -1950,11 +2308,16 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1950
2308
|
converged = score === 0;
|
|
1951
2309
|
}
|
|
1952
2310
|
else {
|
|
1953
|
-
// Plateau or regression.
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
2311
|
+
// Plateau or regression. First contain a cascade: a lever that
|
|
2312
|
+
// moved content across a column break is blacklisted and the loop
|
|
2313
|
+
// retries without it. Otherwise retry when a loose candidate was
|
|
2314
|
+
// just blacklisted (the proposer falls through to the next one), or
|
|
2315
|
+
// when the new loose paragraphs gained their lines yet the layout
|
|
2316
|
+
// did not improve (the gain landed elsewhere — drop them too). A
|
|
2317
|
+
// pure spacing plateau means we're done: keep the best layout found
|
|
2318
|
+
// so far.
|
|
2319
|
+
if (containCascade(next, proposal))
|
|
2320
|
+
continue;
|
|
1958
2321
|
if (looseFailed.length > 0 || looseWon.length > 0) {
|
|
1959
2322
|
for (const k of looseWon)
|
|
1960
2323
|
failedLoose.add(k);
|