postext 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +0 -2
  2. package/dist/__tests__/canvasPageScale.test.d.ts +2 -0
  3. package/dist/__tests__/canvasPageScale.test.d.ts.map +1 -0
  4. package/dist/__tests__/canvasPageScale.test.js +48 -0
  5. package/dist/__tests__/canvasPageScale.test.js.map +1 -0
  6. package/dist/__tests__/columnBalancing.test.js +39 -2
  7. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  8. package/dist/__tests__/columnClip.test.d.ts +2 -0
  9. package/dist/__tests__/columnClip.test.d.ts.map +1 -0
  10. package/dist/__tests__/columnClip.test.js +27 -0
  11. package/dist/__tests__/columnClip.test.js.map +1 -0
  12. package/dist/__tests__/defaults/calloutStyles.test.js +5 -0
  13. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  14. package/dist/__tests__/defaults/tableStyles.test.d.ts +2 -0
  15. package/dist/__tests__/defaults/tableStyles.test.d.ts.map +1 -0
  16. package/dist/__tests__/defaults/tableStyles.test.js +56 -0
  17. package/dist/__tests__/defaults/tableStyles.test.js.map +1 -0
  18. package/dist/__tests__/design/paintOrder.test.d.ts +2 -0
  19. package/dist/__tests__/design/paintOrder.test.d.ts.map +1 -0
  20. package/dist/__tests__/design/paintOrder.test.js +75 -0
  21. package/dist/__tests__/design/paintOrder.test.js.map +1 -0
  22. package/dist/__tests__/design/slotContainers.test.d.ts +2 -0
  23. package/dist/__tests__/design/slotContainers.test.d.ts.map +1 -0
  24. package/dist/__tests__/design/slotContainers.test.js +70 -0
  25. package/dist/__tests__/design/slotContainers.test.js.map +1 -0
  26. package/dist/__tests__/exports.test.js +12 -0
  27. package/dist/__tests__/exports.test.js.map +1 -1
  28. package/dist/__tests__/html-backend.test.js +23 -0
  29. package/dist/__tests__/html-backend.test.js.map +1 -1
  30. package/dist/__tests__/parts.test.js +75 -0
  31. package/dist/__tests__/parts.test.js.map +1 -1
  32. package/dist/__tests__/pipeline/calloutNested.test.d.ts +2 -0
  33. package/dist/__tests__/pipeline/calloutNested.test.d.ts.map +1 -0
  34. package/dist/__tests__/pipeline/calloutNested.test.js +264 -0
  35. package/dist/__tests__/pipeline/calloutNested.test.js.map +1 -0
  36. package/dist/__tests__/pipeline/calloutOverflow.test.js +39 -3
  37. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -1
  38. package/dist/__tests__/pipeline/calloutSnapToGrid.test.d.ts +2 -0
  39. package/dist/__tests__/pipeline/calloutSnapToGrid.test.d.ts.map +1 -0
  40. package/dist/__tests__/pipeline/calloutSnapToGrid.test.js +72 -0
  41. package/dist/__tests__/pipeline/calloutSnapToGrid.test.js.map +1 -0
  42. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.d.ts +2 -0
  43. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.d.ts.map +1 -0
  44. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js +166 -0
  45. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js.map +1 -0
  46. package/dist/__tests__/pipeline/chips.test.d.ts +2 -0
  47. package/dist/__tests__/pipeline/chips.test.d.ts.map +1 -0
  48. package/dist/__tests__/pipeline/chips.test.js +230 -0
  49. package/dist/__tests__/pipeline/chips.test.js.map +1 -0
  50. package/dist/__tests__/pipeline/floatFirstSlot.test.js +53 -0
  51. package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
  52. package/dist/__tests__/pipeline/headingRunConverges.test.d.ts +2 -0
  53. package/dist/__tests__/pipeline/headingRunConverges.test.d.ts.map +1 -0
  54. package/dist/__tests__/pipeline/headingRunConverges.test.js +69 -0
  55. package/dist/__tests__/pipeline/headingRunConverges.test.js.map +1 -0
  56. package/dist/__tests__/pipeline/spanBlocks.test.js +3 -2
  57. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  58. package/dist/__tests__/pipeline/tableCellImages.test.js +87 -1
  59. package/dist/__tests__/pipeline/tableCellImages.test.js.map +1 -1
  60. package/dist/__tests__/pipeline/tableStyles.test.d.ts +2 -0
  61. package/dist/__tests__/pipeline/tableStyles.test.d.ts.map +1 -0
  62. package/dist/__tests__/pipeline/tableStyles.test.js +188 -0
  63. package/dist/__tests__/pipeline/tableStyles.test.js.map +1 -0
  64. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  65. package/dist/canvas-backend/blockRender.js +10 -1
  66. package/dist/canvas-backend/blockRender.js.map +1 -1
  67. package/dist/canvas-backend/chip.d.ts +10 -0
  68. package/dist/canvas-backend/chip.d.ts.map +1 -0
  69. package/dist/canvas-backend/chip.js +49 -0
  70. package/dist/canvas-backend/chip.js.map +1 -0
  71. package/dist/canvas-backend/index.d.ts.map +1 -1
  72. package/dist/canvas-backend/index.js +14 -27
  73. package/dist/canvas-backend/index.js.map +1 -1
  74. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  75. package/dist/canvas-backend/renderResourceBlock.js +48 -1
  76. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  77. package/dist/columnClip.d.ts +16 -0
  78. package/dist/columnClip.d.ts.map +1 -0
  79. package/dist/columnClip.js +37 -0
  80. package/dist/columnClip.js.map +1 -0
  81. package/dist/defaults/calloutStyles.d.ts +1 -0
  82. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  83. package/dist/defaults/calloutStyles.js +7 -0
  84. package/dist/defaults/calloutStyles.js.map +1 -1
  85. package/dist/defaults/chipStyles.d.ts +28 -0
  86. package/dist/defaults/chipStyles.d.ts.map +1 -0
  87. package/dist/defaults/chipStyles.js +102 -0
  88. package/dist/defaults/chipStyles.js.map +1 -0
  89. package/dist/defaults/index.d.ts +2 -1
  90. package/dist/defaults/index.d.ts.map +1 -1
  91. package/dist/defaults/index.js +18 -2
  92. package/dist/defaults/index.js.map +1 -1
  93. package/dist/defaults/parts.d.ts +1 -0
  94. package/dist/defaults/parts.d.ts.map +1 -1
  95. package/dist/defaults/parts.js +4 -0
  96. package/dist/defaults/parts.js.map +1 -1
  97. package/dist/defaults/shared.d.ts.map +1 -1
  98. package/dist/defaults/shared.js +43 -16
  99. package/dist/defaults/shared.js.map +1 -1
  100. package/dist/defaults/tableStyle.d.ts +17 -1
  101. package/dist/defaults/tableStyle.d.ts.map +1 -1
  102. package/dist/defaults/tableStyle.js +46 -0
  103. package/dist/defaults/tableStyle.js.map +1 -1
  104. package/dist/design/layout.d.ts.map +1 -1
  105. package/dist/design/layout.js +10 -6
  106. package/dist/design/layout.js.map +1 -1
  107. package/dist/html-backend.d.ts.map +1 -1
  108. package/dist/html-backend.js +72 -5
  109. package/dist/html-backend.js.map +1 -1
  110. package/dist/index.d.ts +7 -6
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +4 -3
  113. package/dist/index.js.map +1 -1
  114. package/dist/knuthPlass/richAdapter.d.ts +3 -1
  115. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  116. package/dist/knuthPlass/richAdapter.js +7 -4
  117. package/dist/knuthPlass/richAdapter.js.map +1 -1
  118. package/dist/measure/cache.d.ts.map +1 -1
  119. package/dist/measure/cache.js +7 -1
  120. package/dist/measure/cache.js.map +1 -1
  121. package/dist/measure/chipEdges.d.ts +5 -0
  122. package/dist/measure/chipEdges.d.ts.map +1 -0
  123. package/dist/measure/chipEdges.js +20 -0
  124. package/dist/measure/chipEdges.js.map +1 -0
  125. package/dist/measure/rich.d.ts +12 -0
  126. package/dist/measure/rich.d.ts.map +1 -1
  127. package/dist/measure/rich.js +128 -5
  128. package/dist/measure/rich.js.map +1 -1
  129. package/dist/parse/blockParser.d.ts.map +1 -1
  130. package/dist/parse/blockParser.js +10 -7
  131. package/dist/parse/blockParser.js.map +1 -1
  132. package/dist/parse/index.d.ts +3 -3
  133. package/dist/parse/index.d.ts.map +1 -1
  134. package/dist/parse/index.js +1 -1
  135. package/dist/parse/index.js.map +1 -1
  136. package/dist/parse/inlineFormatting.d.ts +28 -0
  137. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  138. package/dist/parse/inlineFormatting.js +52 -0
  139. package/dist/parse/inlineFormatting.js.map +1 -1
  140. package/dist/parse/inlineSnippet.d.ts +2 -1
  141. package/dist/parse/inlineSnippet.d.ts.map +1 -1
  142. package/dist/parse/inlineSnippet.js +6 -4
  143. package/dist/parse/inlineSnippet.js.map +1 -1
  144. package/dist/parse/sourceMapping.d.ts.map +1 -1
  145. package/dist/parse/sourceMapping.js +25 -1
  146. package/dist/parse/sourceMapping.js.map +1 -1
  147. package/dist/parse/types.d.ts +31 -0
  148. package/dist/parse/types.d.ts.map +1 -1
  149. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  150. package/dist/pipeline/bandCaps.js +2 -0
  151. package/dist/pipeline/bandCaps.js.map +1 -1
  152. package/dist/pipeline/build.d.ts.map +1 -1
  153. package/dist/pipeline/build.js +253 -64
  154. package/dist/pipeline/build.js.map +1 -1
  155. package/dist/pipeline/calloutLayout.d.ts +52 -4
  156. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  157. package/dist/pipeline/calloutLayout.js +155 -21
  158. package/dist/pipeline/calloutLayout.js.map +1 -1
  159. package/dist/pipeline/chips.d.ts +27 -0
  160. package/dist/pipeline/chips.d.ts.map +1 -0
  161. package/dist/pipeline/chips.js +56 -0
  162. package/dist/pipeline/chips.js.map +1 -0
  163. package/dist/pipeline/columnBalancing.d.ts +1 -1
  164. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  165. package/dist/pipeline/columnBalancing.js +87 -1
  166. package/dist/pipeline/columnBalancing.js.map +1 -1
  167. package/dist/pipeline/config.d.ts.map +1 -1
  168. package/dist/pipeline/config.js +3 -1
  169. package/dist/pipeline/config.js.map +1 -1
  170. package/dist/pipeline/headerFooter.d.ts +0 -4
  171. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  172. package/dist/pipeline/headerFooter.js +42 -15
  173. package/dist/pipeline/headerFooter.js.map +1 -1
  174. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  175. package/dist/pipeline/measureContentBlock.js +6 -1
  176. package/dist/pipeline/measureContentBlock.js.map +1 -1
  177. package/dist/pipeline/partPalette.d.ts +6 -0
  178. package/dist/pipeline/partPalette.d.ts.map +1 -0
  179. package/dist/pipeline/partPalette.js +96 -0
  180. package/dist/pipeline/partPalette.js.map +1 -0
  181. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  182. package/dist/pipeline/resourceLayout.js +35 -11
  183. package/dist/pipeline/resourceLayout.js.map +1 -1
  184. package/dist/types.d.ts +141 -6
  185. package/dist/types.d.ts.map +1 -1
  186. package/dist/vdt.d.ts +88 -2
  187. package/dist/vdt.d.ts.map +1 -1
  188. package/dist/vdt.js +17 -0
  189. package/dist/vdt.js.map +1 -1
  190. package/package.json +1 -1
@@ -24,6 +24,7 @@ import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
24
24
  import { enumerateCurrentPageSlots, measureFloatBand, measureSideStack, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
25
25
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
26
26
  import { defaultResourceTypes } from '../defaults/resourceTypes';
27
+ import { pickTableStyle } from '../defaults/tableStyle';
27
28
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
28
29
  import { proposeBalanceLines, collectColumnGaps, firstDivergentColumn, gapLinesIn, pageSegments, MAX_BALANCING_PASSES, MAX_BALANCING_PASSES_PER_DOCUMENT, balanceKey } from './columnBalancing';
29
30
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCapsGen, drainPasses, resolveTrailingCapsGen, bandCapLinesAroundZone, } from './bandCaps';
@@ -371,6 +372,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
371
372
  : undefined;
372
373
  /** Row count of a table resource (0 for anything else). */
373
374
  const tableRowCount = (resourceId) => resourceById.get(resourceId)?.table?.model.rows.length ?? 0;
375
+ /** What a table resource does when taller than the page — its own
376
+ * (named) style's `overflow`. */
377
+ const tableOverflow = (resourceId) => pickTableStyle(resolved, resourceById.get(resourceId)?.table?.styleId).overflow;
374
378
  /** The slice a pending float stands for: the whole resource, or — for the
375
379
  * rest of a split table — its remaining rows, laid out as the closing
376
380
  * slice (no marker; the note). */
@@ -505,7 +509,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
505
509
  * The slice of a table float that fits a fresh band of `avail` px, when
506
510
  * the whole (rest of the) table does not: the leading rows within the
507
511
  * band, cut where `planTableSlice` allows, verified against the band
508
- * geometry and shrunk row by row until it fits. `tableStyle.overflow`
512
+ * geometry and shrunk row by row until it fits. The table style's `overflow`
509
513
  * decides what becomes of the rows left over — a rest float to continue
510
514
  * on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
511
515
  * that the table is dropped. Returns null for a figure or a table with
@@ -517,7 +521,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
517
521
  const rowCount = tableRowCount(f.resourceId);
518
522
  if (rowCount === 0)
519
523
  return null;
520
- const overflow = resolved.tableStyle.overflow;
524
+ const overflow = tableOverflow(f.resourceId);
521
525
  if (overflow === 'hide')
522
526
  return 'skip';
523
527
  const metrics = tableMetrics(f.resourceId, width, rotated);
@@ -814,7 +818,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
814
818
  // table that clips or hides when too tall keeps to fresh pages.
815
819
  if (position !== 'top' || pageSpan || c.blocks.length > 0)
816
820
  return 'defer';
817
- if (resolved.tableStyle.overflow !== 'split')
821
+ if (tableOverflow(f.resourceId) !== 'split')
818
822
  return 'defer';
819
823
  const split = splitTableFloat(f, width, position, targetCols, page.contentArea, c.availableHeight - (hasBand ? minTextPx : 0), 'strict');
820
824
  if (!split || split === 'none' || split === 'skip')
@@ -1097,7 +1101,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1097
1101
  * block is placed, so a float lands in the first gap after its reference.
1098
1102
  * `nextBlockIdx` is that block: a keep-together box it opens holds the
1099
1103
  * slots that would starve it (see `slotStarvesBox`). */
1100
- const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx) => {
1104
+ const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx, anyPosition = false) => {
1101
1105
  if (pendingFloats.length === 0)
1102
1106
  return;
1103
1107
  const page = doc.pages[cursor.pageIndex];
@@ -1109,7 +1113,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1109
1113
  }
1110
1114
  const f = pendingFloats[i];
1111
1115
  let r = 'defer';
1112
- let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
1116
+ // `anyPosition`: a figure or table may take a slot its position would
1117
+ // refuse (a head-of-page float offered the foot of the current page);
1118
+ // floated callouts keep their placement.
1119
+ const asked = anyPosition && !f.rotate && !f.callout && f.position !== 'auto' ? { ...f, position: 'auto' } : f;
1120
+ let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, asked, capKindOf);
1113
1121
  // The rest of a table cut on this page only takes the slots after
1114
1122
  // its previous slice in reading order (never the foot of the column
1115
1123
  // before it; a page-span rest waits for the next page).
@@ -1171,6 +1179,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1171
1179
  * float page so the boundary's own page break opens AFTER them. */
1172
1180
  const drainPendingFloats = () => {
1173
1181
  tryPlacePendingFloatsOnCurrentPage();
1182
+ // A float that would otherwise take a page of its own before the
1183
+ // boundary settles for a free slot of the current page — the foot of
1184
+ // its closing columns — whatever position it asked for: a chapter's
1185
+ // last page with room under its text beats a page holding one table.
1186
+ tryPlacePendingFloatsOnCurrentPage(false, undefined, true);
1174
1187
  let guard = 0;
1175
1188
  while ((pendingFloats.length > 0 || pendingSideBoxes.length > 0) && guard++ < 1000) {
1176
1189
  const before = floatsPlaced;
@@ -1411,11 +1424,47 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1411
1424
  tryPlacePendingFloatsOnCurrentPage();
1412
1425
  proposeTrailingCap(boundaryIndex);
1413
1426
  markForcedBreak();
1427
+ hugClosingText();
1414
1428
  drainPendingFloats();
1415
1429
  };
1430
+ /** On the closing page of a chapter nothing follows the page-span floats
1431
+ * set at the foot of its last band: they move up to sit right under the
1432
+ * band's text (one float gap below its grid-rounded bottom, stacked in
1433
+ * their order) instead of leaving a gap between the text and a table at
1434
+ * the page foot. */
1435
+ const hugClosingText = () => {
1436
+ const page = doc.pages[cursor.pageIndex];
1437
+ const floats = page.floats ?? [];
1438
+ // A side column holds its own band under a page-span float: leave it.
1439
+ if (floats.length === 0 || page.partInfo || sideColumns(page).length > 0)
1440
+ return;
1441
+ const cols = bandColumns(page, currentBand(page, cursor)).filter((c) => c.kind !== 'span' && c.kind !== 'side' && c.bbox.height > 0.5);
1442
+ if (cols.length === 0 || !cols.some((c) => c.blocks.length > 0))
1443
+ return;
1444
+ const textBottom = Math.max(...cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight)));
1445
+ const spanWidth = page.contentArea.width - 0.5;
1446
+ const below = floats.filter((b) => b.bbox.y >= textBottom - 0.5).sort((a, b) => a.bbox.y - b.bbox.y);
1447
+ let nextY = page.contentArea.y + Math.ceil((textBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid + floatGapPx;
1448
+ for (const b of below) {
1449
+ const rb = b.resourceBlock;
1450
+ // Only resource floats across the page move; anything else below the
1451
+ // text (a fixed or floated box) keeps its place and ends the run.
1452
+ if (!rb || rb.rotation || b.bbox.width < spanWidth)
1453
+ break;
1454
+ const dy = nextY - b.bbox.y;
1455
+ if (dy < -0.5) {
1456
+ b.bbox.y += dy;
1457
+ offsetResourceBlockToAbsolute(rb, 0, dy);
1458
+ }
1459
+ nextY = b.bbox.y + b.bbox.height + floatGapPx;
1460
+ }
1461
+ };
1416
1462
  /** Parity of the page break a closed `:::part` still owes (applied before
1417
1463
  * the next placed block). */
1418
1464
  let pendingPartBreak = null;
1465
+ /** Content index of the closing fence of a part set without a page
1466
+ * (`parts.page: false`): blocks up to it are skipped. */
1467
+ let skipPartUntil = null;
1419
1468
  /** `advanceToNextPageBoundary`, except that an empty part page is left
1420
1469
  * behind too (its opener design is content). No floats are reserved on
1421
1470
  * the page opened this way — parity padding may still follow it. */
@@ -1538,6 +1587,24 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1538
1587
  // A cut inside child `to.child` includes that child (its first
1539
1588
  // `to.line` lines); a cut at a child's head excludes it.
1540
1589
  const toChild = to.line > 0 ? to.child + 1 : to.child;
1590
+ // Nested boxes still open at `from` (their closing markers leading
1591
+ // the range close them): the fragment opens inside them.
1592
+ const open = [];
1593
+ for (let k = 0; k < from.child; k++) {
1594
+ const c = children[k];
1595
+ if (c.containerName !== 'callout')
1596
+ continue;
1597
+ if (c.type === 'containerStart')
1598
+ open.push({ idx: startIdx + 1 + k, block: c });
1599
+ else if (c.type === 'containerEnd')
1600
+ open.pop();
1601
+ }
1602
+ for (let k = from.child; k < children.length && open.length > 0; k++) {
1603
+ const c = children[k];
1604
+ if (c.type !== 'containerEnd' || c.containerId !== open[open.length - 1].block.containerId)
1605
+ break;
1606
+ open.pop();
1607
+ }
1541
1608
  return layoutCallout({
1542
1609
  style,
1543
1610
  attrs: plan.attrs,
@@ -1553,6 +1620,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1553
1620
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1554
1621
  ...(from.line > 0 ? { lineFrom: from.line } : {}),
1555
1622
  ...(to.line > 0 ? { lineTo: to.line } : {}),
1623
+ ...(open.length > 0 ? { openNested: open } : {}),
1556
1624
  mirrored,
1557
1625
  });
1558
1626
  };
@@ -1570,44 +1638,93 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1570
1638
  };
1571
1639
  /** The longest leading fragment of the box from cut `from` on whose box
1572
1640
  * is at most `roomPx` tall. Cuts fall between children or between the
1573
- * lines of a text child, and every fragment keeps at least the style's
1574
- * `splitMinLines` lines on its side of the cut (a figure or a display
1575
- * formula counts as one line). The full layout's geometry ranks the
1576
- * candidates (box bottom = content bottom at the cut + the box's tail
1577
- * below its last child); the deepest candidate that fits is laid out
1578
- * for real and taken when it truly fits. `null` when none does. */
1641
+ * lines of a text child. `splitMinLines` guards text: a cut between
1642
+ * children leaves on each side at least that many text lines or at least
1643
+ * one indivisible block (a figure, table, display formula or nested
1644
+ * box); a cut inside a text child counts every line on its side, a block
1645
+ * as one line, as before. A nested box that may split itself
1646
+ * (`keepTogether: false`, or taller than a full column) offers the cuts
1647
+ * inside it too, by its own `splitMinLines` over its own children; the
1648
+ * fragments on both sides redraw its frame. The full layout's geometry
1649
+ * ranks the candidates (box bottom = content bottom at the cut + the
1650
+ * tails of the boxes closed below it); the deepest candidate that fits
1651
+ * is laid out for real and taken when it truly fits. `null` when none
1652
+ * does. */
1579
1653
  const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines, mirrored = false) => {
1580
1654
  const full = L.layoutRange(from, L.end, width, frameId, continuation, mirrored);
1581
- const lastChild = full.children[full.children.length - 1];
1582
- if (!lastChild)
1655
+ /** Figures, tables and display formulas: never cut, not text lines. */
1656
+ const indivisible = (c) => c.type === 'resource' || c.type === 'mathDisplay';
1657
+ const unitBottom = (u) => {
1658
+ const b = u.kind === 'block' ? u.block.bbox : u.box.result.frame.bbox;
1659
+ return b.y + b.height;
1660
+ };
1661
+ /** Room a box keeps under its last item (padding, border, icon growth). */
1662
+ const tailOf = (r) => {
1663
+ const last = r.units[r.units.length - 1];
1664
+ return last ? r.frame.bbox.y + r.totalHeight - unitBottom(last) : 0;
1665
+ };
1666
+ if (full.units.length === 0)
1583
1667
  return null;
1584
- const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
1585
- const totalLines = full.children.reduce((n, c) => n + Math.max(1, c.lines.length), 0);
1586
- /** Candidate cuts with the head's content bottom and line count. */
1668
+ const tail = tailOf(full);
1669
+ /** Candidate cuts with the head's content bottom. */
1587
1670
  const candidates = [];
1588
- let linesBefore = 0;
1589
- for (let i = 0; i < full.children.length; i++) {
1590
- const c = full.children[i];
1591
- const k = (c.contentIndex ?? L.childBase) - L.childBase;
1592
- const cuttable = c.type !== 'resource' && c.type !== 'mathDisplay' && c.lines.length > 1;
1593
- // The first laid-out child of a continuation opens after `from.line`
1594
- // lines: cuts inside it are counted from the child's own head.
1595
- const lineBase = k === from.child ? from.line : 0;
1596
- if (cuttable) {
1597
- for (let l = 1; l < c.lines.length; l++) {
1598
- const line = c.lines[l - 1];
1599
- candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height, headLines: linesBefore + l });
1600
- }
1601
- }
1602
- linesBefore += Math.max(1, c.lines.length);
1603
- if (i < full.children.length - 1) {
1604
- candidates.push({ cut: { child: k + 1, line: 0 }, bottom: c.bbox.y + c.bbox.height, headLines: linesBefore });
1605
- }
1606
- }
1607
- const min = Math.max(1, minLines);
1671
+ /** Collect the cuts among `units` (one box's items; `below` = the tails
1672
+ * of the nested boxes around them), each side of a cut holding enough
1673
+ * by the box's `min` lines: a block counts as one line, a nested box
1674
+ * as one indivisible block. */
1675
+ const collect = (units, minLines, below) => {
1676
+ const min = Math.max(1, minLines);
1677
+ const lineCount = (u) => (u.kind === 'block' ? Math.max(1, u.block.lines.length) : 1);
1678
+ const isBlock = (u) => u.kind === 'box' || indivisible(u.block);
1679
+ const totalLines = units.reduce((n, u) => n + lineCount(u), 0);
1680
+ const totalBlocks = units.filter(isBlock).length;
1681
+ /** A side of a cut holds enough: `lines` (blocks included) of which
1682
+ * `blocks` are indivisible. Between children, one block suffices or
1683
+ * the text lines alone meet the minimum; inside a text child, the
1684
+ * lines do. */
1685
+ const holds = (lines, blocks, betweenChildren) => betweenChildren ? blocks > 0 || lines - blocks >= min : lines >= min;
1686
+ const bothHold = (lines, blocks, betweenChildren) => holds(lines, blocks, betweenChildren) && holds(totalLines - lines, totalBlocks - blocks, betweenChildren);
1687
+ let linesBefore = 0;
1688
+ let blocksBefore = 0;
1689
+ units.forEach((u, i) => {
1690
+ let next;
1691
+ if (u.kind === 'block') {
1692
+ const c = u.block;
1693
+ const k = (c.contentIndex ?? L.childBase) - L.childBase;
1694
+ next = k + 1;
1695
+ if (!indivisible(c) && c.lines.length > 1) {
1696
+ // The first laid-out child of a continuation opens after
1697
+ // `from.line` lines: cuts inside it are counted from the
1698
+ // child's own head.
1699
+ const lineBase = k === from.child ? from.line : 0;
1700
+ for (let l = 1; l < c.lines.length; l++) {
1701
+ const line = c.lines[l - 1];
1702
+ if (bothHold(linesBefore + l, blocksBefore, false)) {
1703
+ candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height + below });
1704
+ }
1705
+ }
1706
+ }
1707
+ }
1708
+ else {
1709
+ const { box } = u;
1710
+ if (box.endIdx !== undefined)
1711
+ next = box.endIdx + 1 - L.childBase;
1712
+ if (!box.style.keepTogether || tallerThanColumn(box.result)) {
1713
+ collect(box.result.units, box.style.splitMinLines, below + tailOf(box.result));
1714
+ }
1715
+ }
1716
+ linesBefore += lineCount(u);
1717
+ if (isBlock(u))
1718
+ blocksBefore++;
1719
+ if (i < units.length - 1 && next !== undefined && bothHold(linesBefore, blocksBefore, true)) {
1720
+ candidates.push({ cut: { child: next, line: 0 }, bottom: unitBottom(u) + below });
1721
+ }
1722
+ });
1723
+ };
1724
+ collect(full.units, minLines, 0);
1608
1725
  const insideGroup = (cut) => L.groups.some(([gs, ge]) => (cut.line === 0 ? gs < cut.child && cut.child <= ge : gs < cut.child && cut.child < ge));
1609
1726
  const viable = candidates
1610
- .filter((c) => c.headLines >= min && totalLines - c.headLines >= min && !insideGroup(c.cut))
1727
+ .filter((c) => !insideGroup(c.cut))
1611
1728
  .sort((a, b) => b.bottom - a.bottom);
1612
1729
  for (const c of viable) {
1613
1730
  if (c.bottom + tail > roomPx + 0.01)
@@ -1621,6 +1738,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1621
1738
  /** Absolute content indices of the children a fragment from cut `from`
1622
1739
  * to cut `to` of `L` touches, for the fragment's source range. */
1623
1740
  const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from.child, lastChildIdx: L.childBase + (to.line > 0 ? to.child : to.child - 1) });
1741
+ /** The (rest of the) box is taller than an empty, full-height column —
1742
+ * a whole page's content area for a page-span box: no column could
1743
+ * hold it, so even a keep-together box splits (with the rules of a
1744
+ * `keepTogether: false` one) rather than overflow. */
1745
+ const tallerThanColumn = (result) => result.totalHeight > contentArea.height + 0.01;
1624
1746
  const markFragment = (result, part, continued) => {
1625
1747
  if (result.frame.callout) {
1626
1748
  result.frame.callout.part = part;
@@ -1665,7 +1787,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1665
1787
  * and the cut columns stay cut (the polish round fills a column ending a
1666
1788
  * line under the cap).
1667
1789
  *
1668
- * Splitting (`keepTogether: false`): a box that does not fit a level (or
1790
+ * Splitting (`keepTogether: false`, or a keep-together box taller than
1791
+ * the page's content area): a box that does not fit a level (or
1669
1792
  * capped, or nearly level — within one grid line) band breaks between
1670
1793
  * its children: the longest fragment that fits closes the page flush
1671
1794
  * with the band bottom, the rest opens the next page in a box without
@@ -1684,7 +1807,6 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1684
1807
  * / bottom of the page's columns, so a span column never overlaps a float.
1685
1808
  */
1686
1809
  const placeCalloutSpan = (startIdx, plan, style, L, firstFrameId) => {
1687
- const splittable = !style.keepTogether;
1688
1810
  const balancingCfg = resolved.headings.balancing;
1689
1811
  const levelBefore = balancingCfg.enabled && balancingCfg.beforeSpan;
1690
1812
  const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
@@ -1791,6 +1913,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1791
1913
  const continuation = part > 0;
1792
1914
  const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation, mirroredOf(page));
1793
1915
  const result = layoutAt(page.contentArea.width);
1916
+ // A keep-together box (or the rest of it) taller than a whole page
1917
+ // splits like a `keepTogether: false` one instead of overflowing.
1918
+ const splittable = !style.keepTogether || tallerThanColumn(result);
1794
1919
  /** Where the box would cut the current band, and whether it fits (room
1795
1920
  * is measured against the columns' TRUE bottoms — a capped band keeps
1796
1921
  * its slack below the cap). Null when the band is not level (or the
@@ -2295,13 +2420,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2295
2420
  * Place a `:::callout` inline at the current column width as one atomic
2296
2421
  * unit: the frame block followed by its children in the same column. The
2297
2422
  * box's `marginTop` collapses with the pending spacing; `marginBottom` is
2298
- * baked into the post-box grid snap. A box that does not fit moves to the
2423
+ * baked into the post-box grid snap (or, with `snapToGrid: false`, left
2424
+ * exact as pending spacing). A box that does not fit moves to the
2299
2425
  * next column/page (like a resource), pulling a run of trailing headings
2300
- * along (keep-with-next); a box taller than an empty column is placed
2301
- * anyway and overflows (the sandbox warns). A splittable box
2302
- * (`keepTogether: false`) instead leaves the longest run of its children
2303
- * that fits in the column and continues — in a box of its own, without
2304
- * the title or icon — at the top of the next one, splitting again if needed.
2426
+ * along (keep-with-next). A splittable box (`keepTogether: false`, or a
2427
+ * keep-together one taller than a full column) instead leaves the
2428
+ * longest run of its children that fits in the column and continues — in
2429
+ * a box of its own, without the title or icon — at the top of the next
2430
+ * one, splitting again if needed; only a box no cut can split is placed
2431
+ * anyway in an empty column, overflowing (the sandbox warns).
2305
2432
  * Returns the content index to rewind the main loop to when headings
2306
2433
  * were rolled back, else `undefined`.
2307
2434
  *
@@ -2324,17 +2451,27 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2324
2451
  // Floated boxes leave it too: they take the first free band after
2325
2452
  // this point (the foot of the current page, or the head / foot of a
2326
2453
  // page the flow opens later) and the text after them fills the page.
2327
- // A side box always stacks beside the text it interrupts.
2328
- if ((placement === 'auto' || placement === 'top' || placement === 'bottom') && span !== 'side') {
2329
- enqueueCalloutFloat(startIdx, plan, style, L, placement, span);
2330
- return undefined;
2454
+ // A side box always stacks beside the text it interrupts. A box taller
2455
+ // than a full column, which a cut can split, does not float (no band
2456
+ // holds it whole): it stays in the flow where it occurs, like a `here`
2457
+ // box, and splits there. One no cut can split still floats whole.
2458
+ const floating = (placement === 'auto' || placement === 'top' || placement === 'bottom') && span !== 'side';
2459
+ if (floating) {
2460
+ const page = doc.pages[cursor.pageIndex];
2461
+ const width = span === 'page' ? page.contentArea.width : currentColumn(doc, cursor).bbox.width;
2462
+ const inFlow = tallerThanColumn(L.layoutRange(CUT_START, L.end, width, 'float-probe', false))
2463
+ && splitCalloutFragment(L, CUT_START, width, contentArea.height, 'float-probe', false, style.splitMinLines) !== null;
2464
+ if (!inFlow) {
2465
+ enqueueCalloutFloat(startIdx, plan, style, L, placement, span);
2466
+ return undefined;
2467
+ }
2331
2468
  }
2332
2469
  // Page-span boxes split a multi-column page into column bands (stage 1
2333
- // of span blocks). Floating placements keep the inline fallback.
2470
+ // of span blocks).
2334
2471
  {
2335
2472
  const page = doc.pages[cursor.pageIndex];
2336
2473
  if (span === 'page'
2337
- && placement === 'here'
2474
+ && (placement === 'here' || floating)
2338
2475
  && multiColumnBand(page)
2339
2476
  && placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
2340
2477
  return undefined;
@@ -2345,7 +2482,6 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2345
2482
  return undefined;
2346
2483
  }
2347
2484
  }
2348
- const splittable = !style.keepTogether;
2349
2485
  let from = CUT_START;
2350
2486
  let part = 0;
2351
2487
  let frameId = firstFrameId;
@@ -2368,14 +2504,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2368
2504
  let fragment = null;
2369
2505
  if (result.totalHeight > roomPx + 0.01) {
2370
2506
  // The (rest of the) box does not fit the column: a splittable box
2371
- // leaves the head that fits here…
2507
+ // leaves the head that fits here — a keep-together one too once
2508
+ // no full column could hold it whole…
2509
+ const splittable = !style.keepTogether || tallerThanColumn(result);
2372
2510
  if (splittable)
2373
2511
  fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines, mirroredOf(doc.pages[cursor.pageIndex]));
2374
2512
  // …otherwise it moves whole to the next column — also out of an
2375
2513
  // EMPTY column that float bands or a band cap have cut short, when
2376
2514
  // a full column would hold it (bounded, so a run of short columns
2377
2515
  // cannot make it wander forever). Only a box taller than a full
2378
- // column is placed anyway, overflowing (a layout warning says so).
2516
+ // column that no cut can split is placed anyway, overflowing (a
2517
+ // layout warning says so).
2379
2518
  const shortColumn = shortColumnMoves < 4
2380
2519
  && curCol.bbox.height < contentArea.height - baselineGrid
2381
2520
  && result.totalHeight <= contentArea.height + 0.01;
@@ -2443,13 +2582,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2443
2582
  commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
2444
2583
  // Snap the flow after the box to the baseline grid, baking in at least
2445
2584
  // `marginBottom` (grid wins, margin is a minimum — the resource rule).
2446
- {
2585
+ // An off-grid style (`snapToGrid: false`) keeps its exact margin
2586
+ // instead, as pending spacing that collapses with the next block's
2587
+ // top margin: the flow stays off the grid until the next snap point
2588
+ // (a snapped heading, a list tail), like after an unsnapped heading.
2589
+ if (style.snapToGrid) {
2447
2590
  const usedHeight = curCol.bbox.height - curCol.availableHeight;
2448
2591
  const naturalBottom = usedHeight + placed.marginBottomPx;
2449
2592
  const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
2450
2593
  curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
2451
2594
  }
2452
- pendingSpacing = 0;
2595
+ pendingSpacing = style.snapToGrid ? 0 : placed.marginBottomPx;
2453
2596
  if (!fragment)
2454
2597
  return undefined;
2455
2598
  // The rest continues at the top of the next column.
@@ -2542,6 +2685,24 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2542
2685
  // the next chapter's own parity rule) starts clean. A part page counts
2543
2686
  // as content even with an empty body — its opener design fills it — so
2544
2687
  // consecutive parts never share a page.
2688
+ // `parts.page: false`: a part opens no page and its body is not set —
2689
+ // it only takes effect (running heads, palette) from the next content.
2690
+ if (skipPartUntil !== null) {
2691
+ if (blockIdx >= skipPartUntil)
2692
+ skipPartUntil = null;
2693
+ continue;
2694
+ }
2695
+ if (!resolved.parts.page && rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
2696
+ const plan = partPlan.byStart.get(blockIdx);
2697
+ if (plan) {
2698
+ const end = [...partPlan.byEnd].find(([, p]) => p === plan)?.[0] ?? blockIdx;
2699
+ const marks = (doc.partMarks ??= []);
2700
+ if (!marks.some((m) => m.afterContentIndex === end))
2701
+ marks.push({ afterContentIndex: end, number: plan.number, title: plan.title, ...(plan.palette && Object.keys(plan.palette).length > 0 ? { palette: plan.palette } : {}) });
2702
+ skipPartUntil = end;
2703
+ continue;
2704
+ }
2705
+ }
2545
2706
  if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
2546
2707
  const plan = partPlan.byStart.get(blockIdx);
2547
2708
  if (plan) {
@@ -2921,7 +3082,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2921
3082
  addBalanceExtra(curCol, looseLines * style.lineHeightPx);
2922
3083
  }
2923
3084
  const effectiveAvailable = curCol.availableHeight - spacingBefore;
2924
- const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
3085
+ // A hair of tolerance: room of exactly N lines (grid arithmetic in
3086
+ // floats — a column cut by a band cap, a balancing extra line) must
3087
+ // hold N lines, not N - 1.
3088
+ const linesPerAvailable = Math.floor((effectiveAvailable + 0.01) / style.lineHeightPx);
2925
3089
  // Math display blocks carry their natural pixel height on the single
2926
3090
  // VDTLine; text blocks use the uniform body lineHeightPx per line.
2927
3091
  const totalRemainHeight = vdtType === 'mathDisplay'
@@ -3069,12 +3233,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
3069
3233
  // run of consecutive headings ends in a pushed heading, any preceding
3070
3234
  // headings already placed in this column are rolled back and re-placed
3071
3235
  // with it in the next column — otherwise the earlier headings would be
3072
- // left behind as their own orphans.
3236
+ // left behind as their own orphans. A run that already opens a
3237
+ // full-height (or capped) column stays: it would open the next column
3238
+ // the same way and be pushed on again, round after round.
3073
3239
  if (vdtType === 'heading'
3074
3240
  && resolved.headings.keepWithNext
3075
3241
  && !nextIsHeading
3076
3242
  && nextBlock !== null
3077
- && (curCol.blocks.length > 0 || shortColumn)) {
3243
+ && (curCol.blocks.length > trailingHeadingRun(curCol) || shortColumn)) {
3078
3244
  const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
3079
3245
  const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
3080
3246
  const snappedBottom = shouldSnapToGrid
@@ -3179,14 +3345,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
3179
3345
  // Page-spanning heading: reserve the same vertical band in every
3180
3346
  // other column on this page so body text under the opener band
3181
3347
  // starts below it in ALL columns, not just the one it was placed in.
3348
+ // A column still untouched moves its head below the band (as a top
3349
+ // float does), so a float offered that column's head lands under
3350
+ // the opener instead of over it; the text starts where it did.
3182
3351
  if (vdtType === 'heading' && headingLevel !== undefined) {
3183
3352
  const lvl = headingLevels.forBlock(rawBlock);
3184
3353
  if (lvl?.span === 'page') {
3185
3354
  const page = doc.pages[cursor.pageIndex];
3186
3355
  for (const otherCol of page.columns) {
3187
- if (otherCol !== curCol) {
3188
- otherCol.availableHeight = Math.max(0, otherCol.availableHeight - h);
3356
+ if (otherCol === curCol)
3357
+ continue;
3358
+ if (otherCol.blocks.length === 0 && otherCol.availableHeight >= otherCol.bbox.height - 0.01) {
3359
+ const shift = Math.min(h, otherCol.bbox.height);
3360
+ otherCol.bbox.y += shift;
3361
+ otherCol.bbox.height -= shift;
3189
3362
  }
3363
+ otherCol.availableHeight = Math.max(0, otherCol.availableHeight - h);
3190
3364
  }
3191
3365
  }
3192
3366
  }
@@ -3279,10 +3453,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
3279
3453
  // they don't remain stranded at the column's bottom. Mirrors the
3280
3454
  // rollback inside the "fits" path. A block leaving a column that
3281
3455
  // holds nothing but headings stays put instead (rolling back would
3282
- // loop): the headings then open the next column with it.
3456
+ // loop): the headings then open the next column with it. The same
3457
+ // holds for a heading leaving a full-height (or capped) column that
3458
+ // holds nothing but headings: the run would open the next column
3459
+ // just as it opens this one and fail the same way — a `breakBefore`
3460
+ // heading in it would even open a fresh page every round, forever.
3461
+ // Only a short column (under a float, a page-span box) lets the run
3462
+ // move on to a taller one.
3463
+ const headingRun = trailingHeadingRun(curCol);
3464
+ const runFillsColumn = headingRun > 0 && headingRun === curCol.blocks.length;
3283
3465
  const strands = partIndex === 0 && vdtType !== 'heading'
3284
- && trailingHeadingRun(curCol) > 0 && trailingHeadingRun(curCol) < curCol.blocks.length;
3285
- if (resolved.headings.keepWithNext && (vdtType === 'heading' || strands)) {
3466
+ && headingRun > 0 && !runFillsColumn;
3467
+ const pullsRun = vdtType === 'heading' && (!runFillsColumn || shortColumn);
3468
+ if (resolved.headings.keepWithNext && (pullsRun || strands)) {
3286
3469
  const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
3287
3470
  if (rolledBack.length > 0) {
3288
3471
  blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
@@ -3823,6 +4006,12 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0) {
3823
4006
  best = trailing.result;
3824
4007
  for (const [i, cap] of trailing.caps)
3825
4008
  bandCaps.set(i, cap);
4009
+ // The capped layout is a new problem: the polish round gets its own
4010
+ // pass budget and fresh segments — the first round may have spent
4011
+ // every attempt, or blacklisted the very lever (a heading, a formula)
4012
+ // that now fills the line the cut left short.
4013
+ balancingPasses = 0;
4014
+ segments = [];
3826
4015
  resetSegments();
3827
4016
  yield* balance();
3828
4017
  }