postext 1.5.1 → 1.6.0

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 (83) hide show
  1. package/dist/__tests__/exports.test.js +4 -0
  2. package/dist/__tests__/exports.test.js.map +1 -1
  3. package/dist/__tests__/pipeline/footnotes.test.d.ts +2 -0
  4. package/dist/__tests__/pipeline/footnotes.test.d.ts.map +1 -0
  5. package/dist/__tests__/pipeline/footnotes.test.js +203 -0
  6. package/dist/__tests__/pipeline/footnotes.test.js.map +1 -0
  7. package/dist/canvas-backend/decorations.d.ts +3 -1
  8. package/dist/canvas-backend/decorations.d.ts.map +1 -1
  9. package/dist/canvas-backend/decorations.js +19 -3
  10. package/dist/canvas-backend/decorations.js.map +1 -1
  11. package/dist/canvas-backend/index.d.ts.map +1 -1
  12. package/dist/canvas-backend/index.js +3 -2
  13. package/dist/canvas-backend/index.js.map +1 -1
  14. package/dist/columnRule.d.ts +16 -2
  15. package/dist/columnRule.d.ts.map +1 -1
  16. package/dist/columnRule.js +21 -2
  17. package/dist/columnRule.js.map +1 -1
  18. package/dist/defaults/footnotes.d.ts +5 -0
  19. package/dist/defaults/footnotes.d.ts.map +1 -0
  20. package/dist/defaults/footnotes.js +87 -0
  21. package/dist/defaults/footnotes.js.map +1 -0
  22. package/dist/defaults/index.d.ts +1 -0
  23. package/dist/defaults/index.d.ts.map +1 -1
  24. package/dist/defaults/index.js +9 -0
  25. package/dist/defaults/index.js.map +1 -1
  26. package/dist/defaults/shared.d.ts.map +1 -1
  27. package/dist/defaults/shared.js +17 -0
  28. package/dist/defaults/shared.js.map +1 -1
  29. package/dist/html-backend.d.ts.map +1 -1
  30. package/dist/html-backend.js +5 -2
  31. package/dist/html-backend.js.map +1 -1
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/knuthPlass/richAdapter.d.ts +1 -0
  37. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  38. package/dist/knuthPlass/richAdapter.js +2 -1
  39. package/dist/knuthPlass/richAdapter.js.map +1 -1
  40. package/dist/measure/cache.js +1 -1
  41. package/dist/measure/cache.js.map +1 -1
  42. package/dist/measure/rich.d.ts +3 -0
  43. package/dist/measure/rich.d.ts.map +1 -1
  44. package/dist/measure/rich.js +7 -5
  45. package/dist/measure/rich.js.map +1 -1
  46. package/dist/parse/blockParser.d.ts.map +1 -1
  47. package/dist/parse/blockParser.js +25 -9
  48. package/dist/parse/blockParser.js.map +1 -1
  49. package/dist/parse/inlineFormatting.d.ts +22 -0
  50. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  51. package/dist/parse/inlineFormatting.js +42 -2
  52. package/dist/parse/inlineFormatting.js.map +1 -1
  53. package/dist/parse/sourceMapping.d.ts.map +1 -1
  54. package/dist/parse/sourceMapping.js +19 -1
  55. package/dist/parse/sourceMapping.js.map +1 -1
  56. package/dist/parse/types.d.ts +14 -0
  57. package/dist/parse/types.d.ts.map +1 -1
  58. package/dist/pipeline/build.d.ts.map +1 -1
  59. package/dist/pipeline/build.js +320 -5
  60. package/dist/pipeline/build.js.map +1 -1
  61. package/dist/pipeline/config.d.ts.map +1 -1
  62. package/dist/pipeline/config.js +2 -1
  63. package/dist/pipeline/config.js.map +1 -1
  64. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  65. package/dist/pipeline/contentWarnings.js +27 -1
  66. package/dist/pipeline/contentWarnings.js.map +1 -1
  67. package/dist/pipeline/continuation.d.ts.map +1 -1
  68. package/dist/pipeline/continuation.js +15 -1
  69. package/dist/pipeline/continuation.js.map +1 -1
  70. package/dist/pipeline/footnotes.d.ts +56 -0
  71. package/dist/pipeline/footnotes.d.ts.map +1 -0
  72. package/dist/pipeline/footnotes.js +202 -0
  73. package/dist/pipeline/footnotes.js.map +1 -0
  74. package/dist/pipeline/measureContentBlock.d.ts +2 -0
  75. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  76. package/dist/pipeline/measureContentBlock.js +10 -0
  77. package/dist/pipeline/measureContentBlock.js.map +1 -1
  78. package/dist/types.d.ts +86 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/vdt.d.ts +44 -4
  81. package/dist/vdt.d.ts.map +1 -1
  82. package/dist/vdt.js.map +1 -1
  83. package/package.json +1 -1
@@ -11,13 +11,14 @@ import { resolveAllConfig, computeBaselineGrid, resolvedLocale } from './config'
11
11
  import { createHeadingLevelResolver, deriveSectionGeometryConfig, deriveSectionMeasureContext, headingIsHidden, headingIsNumbered, headingMarksFor, headingStyleOf, planHeadingSections, } from './headingStyles';
12
12
  import { computeOutline, hasTocDirective, headingNumberingOptions, headingTemplatesOf, outlineFromDoc, sameOutline } from './outline';
13
13
  import { expandTocDirectives } from './toc';
14
- import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
14
+ import { resolveBodyStyle, resolveBlockquoteStyle, resolveParagraphStyle } from './styles';
15
15
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, listBulletPosition, listItemGapPx, listItemSpacingPx, } from './lists';
16
16
  import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, sideColumnOf, sideColumns, sideUsedBottom, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, pageLayoutOf, } from './placement';
17
17
  import { chooseParagraphSplit } from './orphanWidow';
18
18
  import { applyStyleAttrs, computePageMetrics, isMarkerBlock, nextNonMarkerBlock, spaceLinesAfter, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
19
19
  import { measureContentBlock } from './measureContentBlock';
20
20
  import { planParagraphContainers } from './paragraphContainers';
21
+ import { appendChapterEndNotes, footnoteIdsOfLines, footnoteParagraphStyle, noteContentBlock, numberFootnotes, splitFootnoteDefinitions, withFootnoteStyle, } from './footnotes';
21
22
  import { planParts, derivePartMeasureContext } from './parts';
22
23
  import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
23
24
  import { layoutResourceBlock, planTableSlice } from './resourceLayout';
@@ -214,7 +215,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
214
215
  doc.metadataSources = fieldSources;
215
216
  // A configuration that leaves headings' inline marks off sets their
216
217
  // spans plain here, for the outline and the layout alike (EF-122).
217
- const parsedBlocks = headingMarksFor(parseMarkdownMemo(markdownBody), resolved);
218
+ // Footnote definitions (`[^id]: …`) leave the flow; the markers are
219
+ // numbered in citation order. With `placement: 'chapterEnd'` the notes
220
+ // come back as paragraphs after each chapter's last block.
221
+ const footnoteSplit = splitFootnoteDefinitions(headingMarksFor(parseMarkdownMemo(markdownBody), resolved));
222
+ const footnoteDefs = footnoteSplit.defs;
223
+ const footnoteNumbering = numberFootnotes(footnoteSplit.blocks, resolved.footnotes.numbering, resolved.footnotes.numbering === 'document' ? Math.max(0, Math.floor(continuation?.footnoteNumber ?? 0)) : 0);
224
+ const chapterEndNotes = resolved.footnotes.placement === 'chapterEnd' && footnoteNumbering.numbers.size > 0;
225
+ const parsedBlocks = chapterEndNotes
226
+ ? appendChapterEndNotes(footnoteSplit.blocks, footnoteNumbering, footnoteDefs)
227
+ : footnoteSplit.blocks;
218
228
  const headingStart = continuation?.headings;
219
229
  // `:::toc` expands into the entries of the book's outline — the one the
220
230
  // host supplied, else this document's own (page labels unknown on the
@@ -255,7 +265,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
255
265
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
256
266
  const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
257
267
  // `:::paragraphs{style="…"}` containers, resolved per content-block index.
258
- const paragraphContainers = planParagraphContainers(contentBlocks, resolved);
268
+ const paragraphContainers = planParagraphContainers(contentBlocks, chapterEndNotes ? withFootnoteStyle(resolved) : resolved);
259
269
  // `:::callout` ranges keyed by their start marker index (same rationale).
260
270
  const calloutPlan = planCallouts(contentBlocks);
261
271
  // `:::part` ranges: start/end marker indices and the enclosed blocks.
@@ -427,6 +437,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
427
437
  * past it does), and a rollback that replays the heading gates it on the
428
438
  * heading again (`gateCitedFloats`). */
429
439
  const citedLinesPlaced = (blockIdx, placed) => {
440
+ reserveCitedNotes(placed);
430
441
  // Side boxes waiting for the text after their fence stand beside it
431
442
  // before the floats this text cites take the side column.
432
443
  settleAwaitingSideBoxes(false);
@@ -1472,6 +1483,289 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1472
1483
  resourceNumbering,
1473
1484
  floatedIds,
1474
1485
  leftFlow: calloutsOutOfFlow,
1486
+ ...(footnoteNumbering.numbers.size > 0 ? { footnoteNumbers: footnoteNumbering.numbers } : {}),
1487
+ };
1488
+ // --- Footnotes at the column foot (`footnotes.placement: 'column'`) ------
1489
+ // A text block placed in a column reserves, at the column's foot, the
1490
+ // notes its lines cite for the first time: the column's box shrinks by
1491
+ // their height (as a bottom float band shrinks it), so the text above
1492
+ // keeps its positions and the grid. The flow counts that height before it
1493
+ // places lines (`notesPrefixCost`), so the line citing a note and the note
1494
+ // share a column. The notes are set in their reserved room once the pass
1495
+ // is placed (`setColumnNotes`).
1496
+ const columnNotes = resolved.footnotes.placement === 'column' && footnoteNumbering.numbers.size > 0;
1497
+ const noteStyle = columnNotes ? resolveParagraphStyle(footnoteParagraphStyle(resolved), resolved) : bodyStyle;
1498
+ const noteSpaceAbovePx = dimensionToPx(resolved.footnotes.spaceAbove, dpi, bodyStyle.fontSizePx);
1499
+ const noteSpaceBelowRulePx = dimensionToPx(resolved.footnotes.spaceBelowRule, dpi, bodyStyle.fontSizePx);
1500
+ const noteRulePx = resolved.footnotes.separator.enabled ? dimensionToPx(resolved.footnotes.separator.lineWidth, dpi) : 0;
1501
+ /** The separator zone above a column's first note. */
1502
+ const noteHeadPx = noteSpaceAbovePx + noteRulePx + noteSpaceBelowRulePx;
1503
+ /** Space between two notes. */
1504
+ const noteGapPx = Math.max(0, noteStyle.marginBottomPx);
1505
+ /** Notes set (reserved) in this pass: a note is set once, where it is
1506
+ * first cited. */
1507
+ const placedNotes = new Set();
1508
+ const noteMeasures = new Map();
1509
+ const measureNote = (id, width) => {
1510
+ const key = `${id}@${width.toFixed(2)}`;
1511
+ let m = noteMeasures.get(key);
1512
+ if (m === undefined) {
1513
+ const number = footnoteNumbering.numbers.get(id) ?? '?';
1514
+ m = measureContentBlock(noteContentBlock(footnoteDefs.get(id), id, number), 0, width, measureCtx, { styleOverride: noteStyle });
1515
+ noteMeasures.set(key, m);
1516
+ }
1517
+ return m;
1518
+ };
1519
+ const noteHeight = (id, width) => Math.max(1, measureNote(id, width)?.measured.lines.length ?? 1) * noteStyle.lineHeightPx;
1520
+ /** The notes of a column: ids in order, and the room reserved for them,
1521
+ * one slot per reservation (page coordinates). */
1522
+ const columnNotesOf = new Map();
1523
+ const columnOfNotes = new Map();
1524
+ /** Height `ids` (not yet set) add to `col`'s foot: the separator zone
1525
+ * when the column has no note yet, the notes and the gaps between. */
1526
+ const notesCost = (col, ids) => {
1527
+ if (ids.length === 0)
1528
+ return 0;
1529
+ const first = !columnNotesOf.has(col);
1530
+ let h = first ? noteHeadPx : 0;
1531
+ ids.forEach((id, i) => {
1532
+ if (!first || i > 0)
1533
+ h += noteGapPx;
1534
+ h += noteHeight(id, col.bbox.width);
1535
+ });
1536
+ return h;
1537
+ };
1538
+ /** Notes cited by blocks set outside the flow (the paragraphs of a box,
1539
+ * inline, floated or fixed): they wait for the next text the flow
1540
+ * places, and go to the foot of its column, in citation order. */
1541
+ const pendingNotes = [];
1542
+ /** How far `doc.blocks` was read for such citations. */
1543
+ let notesScanned = 0;
1544
+ const collectPendingNotes = () => {
1545
+ for (; notesScanned < doc.blocks.length; notesScanned++) {
1546
+ for (const id of footnoteIdsOfLines(doc.blocks[notesScanned].lines)) {
1547
+ if (!placedNotes.has(id) && !pendingNotes.includes(id))
1548
+ pendingNotes.push(id);
1549
+ }
1550
+ }
1551
+ };
1552
+ /** `cost[k]`: the notes the first `k` of `lines` cite for the first time
1553
+ * (after the pending ones) would add to `col`'s foot. Undefined when
1554
+ * there are none. */
1555
+ const notesPrefixCost = (col, lines) => {
1556
+ if (!columnNotes)
1557
+ return undefined;
1558
+ collectPendingNotes();
1559
+ const ids = [...pendingNotes];
1560
+ let any = ids.length > 0;
1561
+ const cost = [notesCost(col, ids)];
1562
+ for (const line of lines) {
1563
+ for (const seg of line.segments ?? []) {
1564
+ const id = seg.footnoteId;
1565
+ if (id !== undefined && !placedNotes.has(id) && !ids.includes(id)) {
1566
+ ids.push(id);
1567
+ any = true;
1568
+ }
1569
+ }
1570
+ cost.push(notesCost(col, ids));
1571
+ }
1572
+ return any ? cost : undefined;
1573
+ };
1574
+ /** Reserve at the placed block's column foot the pending notes and the
1575
+ * ones the block cites first. */
1576
+ const reserveCitedNotes = (placed) => {
1577
+ if (!columnNotes || placed.pageIndex < 0)
1578
+ return;
1579
+ collectPendingNotes();
1580
+ const cited = footnoteIdsOfLines(placed.lines).filter((id) => !placedNotes.has(id) && !pendingNotes.includes(id));
1581
+ if (cited.length === 0 && pendingNotes.length === 0)
1582
+ return;
1583
+ const page = doc.pages[placed.pageIndex];
1584
+ const col = page?.columns[placed.columnIndex];
1585
+ if (!page || !col)
1586
+ return;
1587
+ reserveNotes(page, col, [...pendingNotes.splice(0), ...cited], placed.contentIndex ?? 0);
1588
+ };
1589
+ /** Reserve `ids` at `col`'s foot. */
1590
+ const reserveNotes = (page, col, ids, anchor) => {
1591
+ const h = notesCost(col, ids);
1592
+ let bottom;
1593
+ const capped = uncappedBottoms.get(col);
1594
+ if (capped !== undefined) {
1595
+ // A column a band cap cut: the notes go under the cut, at the true
1596
+ // foot, as a bottom float does; the cut moves up only when they reach
1597
+ // above it.
1598
+ bottom = capped;
1599
+ uncappedBottoms.set(col, capped - h);
1600
+ const overlap = col.bbox.y + col.bbox.height - (capped - h);
1601
+ if (overlap > 0) {
1602
+ col.bbox.height = Math.max(0, col.bbox.height - overlap);
1603
+ col.availableHeight = Math.max(0, col.availableHeight - overlap);
1604
+ }
1605
+ }
1606
+ else {
1607
+ bottom = col.bbox.y + col.bbox.height;
1608
+ col.bbox.height = Math.max(0, col.bbox.height - h);
1609
+ col.availableHeight = Math.max(0, col.availableHeight - h);
1610
+ }
1611
+ let entry = columnNotesOf.get(col);
1612
+ if (!entry) {
1613
+ entry = { ids: [], slots: [] };
1614
+ columnNotesOf.set(col, entry);
1615
+ columnOfNotes.set(col, page);
1616
+ }
1617
+ entry.ids.push(...ids);
1618
+ entry.slots.push({ top: bottom - h, height: h, ids });
1619
+ for (const id of ids)
1620
+ placedNotes.add(id);
1621
+ for (const id of ids)
1622
+ noteAnchor.set(id, anchor);
1623
+ };
1624
+ /** Notes still pending when the flow ends (a box closes the document):
1625
+ * the foot of the column the flow ended in takes them. */
1626
+ const reserveLeftoverNotes = () => {
1627
+ if (!columnNotes)
1628
+ return;
1629
+ collectPendingNotes();
1630
+ if (pendingNotes.length === 0)
1631
+ return;
1632
+ const page = doc.pages[cursor.pageIndex];
1633
+ const col = page?.columns[cursor.columnIndex];
1634
+ if (page && col)
1635
+ reserveNotes(page, col, pendingNotes.splice(0), contentBlocks.length - 1);
1636
+ };
1637
+ /** `chapterEnd`: move the run of note paragraphs that closes a column
1638
+ * down to the column's true foot (under a band cap's cut, above any
1639
+ * bottom float band). */
1640
+ const dropChapterEndNotes = () => {
1641
+ for (const page of doc.pages) {
1642
+ for (const col of page.columns) {
1643
+ const blocks = col.blocks;
1644
+ let first = blocks.length;
1645
+ while (first > 0 && blocks[first - 1].footnoteNote !== undefined)
1646
+ first--;
1647
+ if (first === blocks.length)
1648
+ continue;
1649
+ const run = blocks.slice(first);
1650
+ const last = run[run.length - 1];
1651
+ const lastLine = last.lines[last.lines.length - 1];
1652
+ const foot = lastLine ? lastLine.bbox.y + lastLine.bbox.height : last.bbox.y + last.bbox.height;
1653
+ const d = trueBottom(col, uncappedBottoms) - foot;
1654
+ if (d < 0.5)
1655
+ continue;
1656
+ for (const b of run) {
1657
+ b.bbox = createBoundingBox(b.bbox.x, b.bbox.y + d, b.bbox.width, b.bbox.height);
1658
+ for (const line of b.lines) {
1659
+ line.bbox = createBoundingBox(line.bbox.x, line.bbox.y + d, line.bbox.width, line.bbox.height);
1660
+ line.baseline += d;
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+ };
1666
+ /** `chapterEnd`: the separator rule over the notes of each column, set
1667
+ * in the space above them (`spaceBelowRule` over the first note). */
1668
+ const ruleChapterEndNotes = () => {
1669
+ const sep = resolved.footnotes.separator;
1670
+ if (!sep.enabled || sep.width <= 0 || noteRulePx <= 0)
1671
+ return;
1672
+ const color = sep.color?.hex ?? resolved.footnotes.color?.hex ?? bodyStyle.color;
1673
+ for (const page of doc.pages) {
1674
+ for (const col of page.columns) {
1675
+ const at = col.blocks.findIndex((b, i) => b.footnoteNote !== undefined && col.blocks[i - 1]?.footnoteNote === undefined);
1676
+ if (at < 0)
1677
+ continue;
1678
+ const notes = col.blocks.slice(at).filter((b) => b.footnoteNote !== undefined);
1679
+ const firstLine = notes[0].lines[0];
1680
+ if (!firstLine)
1681
+ continue;
1682
+ const top = firstLine.bbox.y - noteSpaceBelowRulePx - noteRulePx;
1683
+ const last = notes[notes.length - 1];
1684
+ (page.footnoteAreas ??= []).push({
1685
+ columnIndex: col.index,
1686
+ bbox: createBoundingBox(col.bbox.x, top, col.bbox.width, last.bbox.y + last.bbox.height - top),
1687
+ noteIds: notes.map((b) => b.footnoteNote),
1688
+ rule: { x: col.bbox.x, y: top + noteRulePx / 2, width: col.bbox.width * sep.width, lineWidthPx: noteRulePx, color },
1689
+ });
1690
+ }
1691
+ }
1692
+ };
1693
+ /** Content index of the block citing each note (reading order). */
1694
+ const noteAnchor = new Map();
1695
+ /** Set the notes in the room reserved for them: the reservations of a
1696
+ * column that touch (the usual case) make one stack in citation order
1697
+ * under the separator; a float band set between two leaves each its own
1698
+ * room. */
1699
+ const setColumnNotes = () => {
1700
+ for (const [col, entry] of columnNotesOf) {
1701
+ const page = columnOfNotes.get(col);
1702
+ const regions = [];
1703
+ for (const slot of entry.slots) {
1704
+ const last = regions[regions.length - 1];
1705
+ if (last && Math.abs(slot.top + slot.height - last.top) < 0.5) {
1706
+ last.top = slot.top;
1707
+ last.ids.push(...slot.ids);
1708
+ }
1709
+ else {
1710
+ regions.push({ top: slot.top, bottom: slot.top + slot.height, ids: [...slot.ids] });
1711
+ }
1712
+ }
1713
+ const x = col.bbox.x;
1714
+ const width = col.bbox.width;
1715
+ regions.forEach((region, r) => {
1716
+ let y = region.top;
1717
+ if (r === 0) {
1718
+ const sep = resolved.footnotes.separator;
1719
+ const rule = sep.enabled && sep.width > 0 && noteRulePx > 0
1720
+ ? {
1721
+ x,
1722
+ y: y + noteSpaceAbovePx + noteRulePx / 2,
1723
+ width: width * sep.width,
1724
+ lineWidthPx: noteRulePx,
1725
+ color: sep.color?.hex ?? noteStyle.color,
1726
+ }
1727
+ : undefined;
1728
+ (page.footnoteAreas ??= []).push({
1729
+ columnIndex: col.index,
1730
+ bbox: createBoundingBox(x, region.top, width, region.bottom - region.top),
1731
+ noteIds: entry.ids,
1732
+ ...(rule ? { rule } : {}),
1733
+ });
1734
+ y += noteHeadPx;
1735
+ }
1736
+ region.ids.forEach((id, i) => {
1737
+ if (r > 0 || i > 0)
1738
+ y += noteGapPx;
1739
+ const m = measureNote(id, width);
1740
+ if (!m)
1741
+ return;
1742
+ const blk = createVDTBlock(`fn-${id}`, 'paragraph', noteStyle.fontString, noteStyle.color, noteStyle.textAlign);
1743
+ applyStyleAttrs(blk, noteStyle);
1744
+ blk.lines = resetLinePositions(raggedLooseLines(m.measured.lines, noteStyle.textAlign), noteStyle.lineHeightPx).map((line) => ({
1745
+ ...line,
1746
+ bbox: createBoundingBox(line.bbox.x + x, line.bbox.y + y, line.bbox.width, line.bbox.height),
1747
+ baseline: line.baseline + y,
1748
+ }));
1749
+ const h = blk.lines.length * noteStyle.lineHeightPx;
1750
+ blk.bbox = createBoundingBox(x, y, width, h);
1751
+ blk.pageIndex = page.index;
1752
+ blk.columnIndex = col.index;
1753
+ blk.contentIndex = noteAnchor.get(id) ?? 0;
1754
+ blk.footnoteNote = id;
1755
+ blk.dirty = false;
1756
+ blk.snappedToGrid = false;
1757
+ if (blk.lines.length > 0) {
1758
+ blk.sourceStart = blk.lines[0].sourceStart;
1759
+ blk.sourceEnd = blk.lines[blk.lines.length - 1].sourceEnd;
1760
+ }
1761
+ blk.sourceMap = m.absoluteSourceMap;
1762
+ blk.plainPrefixLen = m.prefixLen;
1763
+ (page.floats ??= []).push(blk);
1764
+ doc.blocks.push(blk);
1765
+ y += h;
1766
+ });
1767
+ });
1768
+ }
1475
1769
  };
1476
1770
  // Blocks inside a `:::part` measure with the part body typography.
1477
1771
  const partMeasureCtx = partPlan.byStart.size > 0
@@ -1530,6 +1824,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1530
1824
  blk.sourceTitle = flattenTitleBreaks(raw.text);
1531
1825
  }
1532
1826
  }
1827
+ if (raw.footnoteNote !== undefined)
1828
+ blk.footnoteNote = raw.footnoteNote;
1533
1829
  if (raw.toc?.kind === 'entry') {
1534
1830
  blk.tocEntry = raw.toc.pageIndex !== undefined ? { pageIndex: raw.toc.pageIndex } : {};
1535
1831
  }
@@ -3975,11 +4271,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
3975
4271
  tracking: looseTracking,
3976
4272
  };
3977
4273
  }
3978
- const effectiveAvailable = curCol.availableHeight - spacingBefore;
4274
+ // Footnotes the lines cite take room at the column's foot: the whole
4275
+ // block fits when its lines and all their notes do, and a split
4276
+ // keeps the lines whose notes fit under them.
4277
+ const noteCosts = vdtType === 'mathDisplay' ? undefined : notesPrefixCost(curCol, remainingLines);
4278
+ const effectiveAvailable = curCol.availableHeight - spacingBefore - (noteCosts ? noteCosts[noteCosts.length - 1] : 0);
3979
4279
  // A hair of tolerance: room of exactly N lines (grid arithmetic in
3980
4280
  // floats — a column cut by a band cap, a balancing extra line) must
3981
4281
  // hold N lines, not N - 1.
3982
- const linesPerAvailable = Math.floor((effectiveAvailable + 0.01) / style.lineHeightPx);
4282
+ let linesPerAvailable = Math.floor((curCol.availableHeight - spacingBefore + 0.01) / style.lineHeightPx);
4283
+ if (noteCosts) {
4284
+ const costOf = (k) => noteCosts[Math.min(k, noteCosts.length - 1)];
4285
+ while (linesPerAvailable > 0 && linesPerAvailable * style.lineHeightPx + costOf(linesPerAvailable) > curCol.availableHeight - spacingBefore + 0.01) {
4286
+ linesPerAvailable--;
4287
+ }
4288
+ }
3983
4289
  // Math display blocks carry their natural pixel height on the single
3984
4290
  // VDTLine; text blocks use the uniform body lineHeightPx per line.
3985
4291
  const totalRemainHeight = vdtType === 'mathDisplay'
@@ -4709,6 +5015,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
4709
5015
  // End of the document: level the closing band and place any floats still
4710
5016
  // pending (referenced on the last page) on pages appended after it.
4711
5017
  closeFlowSegment(contentBlocks.length);
5018
+ // The notes, in the room the flow reserved for them.
5019
+ reserveLeftoverNotes();
5020
+ setColumnNotes();
5021
+ // After the chapter (`chapterEnd`), the notes that close a column drop
5022
+ // to its foot: the room left over stays between the text and them.
5023
+ if (chapterEndNotes && resolved.footnotes.chapterEndAlign === 'foot')
5024
+ dropChapterEndNotes();
5025
+ if (chapterEndNotes)
5026
+ ruleChapterEndNotes();
4712
5027
  // A band that is still cut at the end of the pass (a trailing cap: the
4713
5028
  // closing band of a chapter or of the document) must hold what it took,
4714
5029
  // under the same rules as an uncut one. Two ways a cut too low breaks