postext 1.24.0 → 1.25.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 (331) hide show
  1. package/dist/__tests__/bundle/cjkRulesPin.test.d.ts +2 -0
  2. package/dist/__tests__/bundle/cjkRulesPin.test.d.ts.map +1 -0
  3. package/dist/__tests__/bundle/cjkRulesPin.test.js +78 -0
  4. package/dist/__tests__/bundle/cjkRulesPin.test.js.map +1 -0
  5. package/dist/__tests__/bundle/designOverflowPin.test.js +4 -2
  6. package/dist/__tests__/bundle/designOverflowPin.test.js.map +1 -1
  7. package/dist/__tests__/bundle/flowColumnsPin.test.d.ts +2 -0
  8. package/dist/__tests__/bundle/flowColumnsPin.test.d.ts.map +1 -0
  9. package/dist/__tests__/bundle/flowColumnsPin.test.js +40 -0
  10. package/dist/__tests__/bundle/flowColumnsPin.test.js.map +1 -0
  11. package/dist/__tests__/bundle/gridBalancingPin.test.d.ts +2 -0
  12. package/dist/__tests__/bundle/gridBalancingPin.test.d.ts.map +1 -0
  13. package/dist/__tests__/bundle/gridBalancingPin.test.js +53 -0
  14. package/dist/__tests__/bundle/gridBalancingPin.test.js.map +1 -0
  15. package/dist/__tests__/bundle/inlineTableSplitPin.test.d.ts +2 -0
  16. package/dist/__tests__/bundle/inlineTableSplitPin.test.d.ts.map +1 -0
  17. package/dist/__tests__/bundle/inlineTableSplitPin.test.js +43 -0
  18. package/dist/__tests__/bundle/inlineTableSplitPin.test.js.map +1 -0
  19. package/dist/__tests__/bundle/openerHeadFloatsPin.test.d.ts +2 -0
  20. package/dist/__tests__/bundle/openerHeadFloatsPin.test.d.ts.map +1 -0
  21. package/dist/__tests__/bundle/openerHeadFloatsPin.test.js +50 -0
  22. package/dist/__tests__/bundle/openerHeadFloatsPin.test.js.map +1 -0
  23. package/dist/__tests__/bundle.test.js +11 -1
  24. package/dist/__tests__/bundle.test.js.map +1 -1
  25. package/dist/__tests__/cjk/annotationsRender.test.js +5 -2
  26. package/dist/__tests__/cjk/annotationsRender.test.js.map +1 -1
  27. package/dist/__tests__/cjk/circledNumbers.test.d.ts +2 -0
  28. package/dist/__tests__/cjk/circledNumbers.test.d.ts.map +1 -0
  29. package/dist/__tests__/cjk/circledNumbers.test.js +83 -0
  30. package/dist/__tests__/cjk/circledNumbers.test.js.map +1 -0
  31. package/dist/__tests__/cjk/gridBalancing.test.d.ts +2 -0
  32. package/dist/__tests__/cjk/gridBalancing.test.d.ts.map +1 -0
  33. package/dist/__tests__/cjk/gridBalancing.test.js +227 -0
  34. package/dist/__tests__/cjk/gridBalancing.test.js.map +1 -0
  35. package/dist/__tests__/cjk/justifyRender.test.js +2 -1
  36. package/dist/__tests__/cjk/justifyRender.test.js.map +1 -1
  37. package/dist/__tests__/cjk/titleBreaks.test.d.ts +2 -0
  38. package/dist/__tests__/cjk/titleBreaks.test.d.ts.map +1 -0
  39. package/dist/__tests__/cjk/titleBreaks.test.js +76 -0
  40. package/dist/__tests__/cjk/titleBreaks.test.js.map +1 -0
  41. package/dist/__tests__/cjk/wordJoiner.test.js +13 -4
  42. package/dist/__tests__/cjk/wordJoiner.test.js.map +1 -1
  43. package/dist/__tests__/codeBlocks.test.js +1 -1
  44. package/dist/__tests__/design/cjkDesignText.test.d.ts +2 -0
  45. package/dist/__tests__/design/cjkDesignText.test.d.ts.map +1 -0
  46. package/dist/__tests__/design/cjkDesignText.test.js +130 -0
  47. package/dist/__tests__/design/cjkDesignText.test.js.map +1 -0
  48. package/dist/__tests__/design/cjkDesignTextRender.test.d.ts +2 -0
  49. package/dist/__tests__/design/cjkDesignTextRender.test.d.ts.map +1 -0
  50. package/dist/__tests__/design/cjkDesignTextRender.test.js +100 -0
  51. package/dist/__tests__/design/cjkDesignTextRender.test.js.map +1 -0
  52. package/dist/__tests__/exports.test.js +49 -0
  53. package/dist/__tests__/exports.test.js.map +1 -1
  54. package/dist/__tests__/hardBreaks.test.js +1 -1
  55. package/dist/__tests__/headingInlineMarks.test.js +1 -1
  56. package/dist/__tests__/headingNumberSpans.test.d.ts +2 -0
  57. package/dist/__tests__/headingNumberSpans.test.d.ts.map +1 -0
  58. package/dist/__tests__/headingNumberSpans.test.js +96 -0
  59. package/dist/__tests__/headingNumberSpans.test.js.map +1 -0
  60. package/dist/__tests__/mathSizeMigration.test.js +2 -2
  61. package/dist/__tests__/multipleColumns.test.js +207 -0
  62. package/dist/__tests__/multipleColumns.test.js.map +1 -1
  63. package/dist/__tests__/parse/citations.test.js +29 -0
  64. package/dist/__tests__/parse/citations.test.js.map +1 -1
  65. package/dist/__tests__/pipeline/calloutInlineResourceGap.test.js +10 -6
  66. package/dist/__tests__/pipeline/calloutInlineResourceGap.test.js.map +1 -1
  67. package/dist/__tests__/pipeline/calloutOverflow.test.js +4 -2
  68. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -1
  69. package/dist/__tests__/pipeline/calloutSplitMinLinesChild.test.js +1 -1
  70. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js +6 -5
  71. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js.map +1 -1
  72. package/dist/__tests__/pipeline/colonListRoom.test.js +1 -1
  73. package/dist/__tests__/pipeline/compoundBreaks.test.js +1 -1
  74. package/dist/__tests__/pipeline/dashBreaks.test.js +1 -1
  75. package/dist/__tests__/pipeline/floatCitingPage.test.d.ts +2 -0
  76. package/dist/__tests__/pipeline/floatCitingPage.test.d.ts.map +1 -0
  77. package/dist/__tests__/pipeline/floatCitingPage.test.js +241 -0
  78. package/dist/__tests__/pipeline/floatCitingPage.test.js.map +1 -0
  79. package/dist/__tests__/pipeline/floatOverInlineResource.test.js +18 -6
  80. package/dist/__tests__/pipeline/floatOverInlineResource.test.js.map +1 -1
  81. package/dist/__tests__/pipeline/floatPlacement.test.js +3 -3
  82. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
  83. package/dist/__tests__/pipeline/flowColumns.test.d.ts +2 -0
  84. package/dist/__tests__/pipeline/flowColumns.test.d.ts.map +1 -0
  85. package/dist/__tests__/pipeline/flowColumns.test.js +261 -0
  86. package/dist/__tests__/pipeline/flowColumns.test.js.map +1 -0
  87. package/dist/__tests__/pipeline/headingKeepFitEps.test.d.ts +2 -0
  88. package/dist/__tests__/pipeline/headingKeepFitEps.test.d.ts.map +1 -0
  89. package/dist/__tests__/pipeline/headingKeepFitEps.test.js +83 -0
  90. package/dist/__tests__/pipeline/headingKeepFitEps.test.js.map +1 -0
  91. package/dist/__tests__/pipeline/headingSplitOrphans.test.js +1 -1
  92. package/dist/__tests__/pipeline/inlineFigureSpace.test.js +1 -1
  93. package/dist/__tests__/pipeline/inlineTableSplit.test.d.ts +2 -0
  94. package/dist/__tests__/pipeline/inlineTableSplit.test.d.ts.map +1 -0
  95. package/dist/__tests__/pipeline/inlineTableSplit.test.js +238 -0
  96. package/dist/__tests__/pipeline/inlineTableSplit.test.js.map +1 -0
  97. package/dist/__tests__/pipeline/paragraphContainerTail.test.js +1 -1
  98. package/dist/__tests__/pipeline/raggedOptimal.test.js +1 -1
  99. package/dist/__tests__/pipeline/resolvedPaletteCopy.test.js +3 -2
  100. package/dist/__tests__/pipeline/resolvedPaletteCopy.test.js.map +1 -1
  101. package/dist/__tests__/pipeline/sideBoxAfterText.test.d.ts +2 -0
  102. package/dist/__tests__/pipeline/sideBoxAfterText.test.d.ts.map +1 -0
  103. package/dist/__tests__/pipeline/sideBoxAfterText.test.js +163 -0
  104. package/dist/__tests__/pipeline/sideBoxAfterText.test.js.map +1 -0
  105. package/dist/__tests__/prepareFonts.test.d.ts +2 -0
  106. package/dist/__tests__/prepareFonts.test.d.ts.map +1 -0
  107. package/dist/__tests__/prepareFonts.test.js +358 -0
  108. package/dist/__tests__/prepareFonts.test.js.map +1 -0
  109. package/dist/__tests__/svgFonts.test.d.ts +2 -0
  110. package/dist/__tests__/svgFonts.test.d.ts.map +1 -0
  111. package/dist/__tests__/svgFonts.test.js +286 -0
  112. package/dist/__tests__/svgFonts.test.js.map +1 -0
  113. package/dist/__tests__/verseLines.test.js +1 -1
  114. package/dist/__tests__/vertical/measurePaint.test.js +16 -0
  115. package/dist/__tests__/vertical/measurePaint.test.js.map +1 -1
  116. package/dist/bundle/adapters.d.ts +31 -7
  117. package/dist/bundle/adapters.d.ts.map +1 -1
  118. package/dist/bundle/adapters.js +113 -34
  119. package/dist/bundle/adapters.js.map +1 -1
  120. package/dist/bundle/codec.d.ts.map +1 -1
  121. package/dist/bundle/codec.js +1 -0
  122. package/dist/bundle/codec.js.map +1 -1
  123. package/dist/bundle/configVersion.d.ts +127 -10
  124. package/dist/bundle/configVersion.d.ts.map +1 -1
  125. package/dist/bundle/configVersion.js +260 -9
  126. package/dist/bundle/configVersion.js.map +1 -1
  127. package/dist/bundle/index.d.ts +3 -3
  128. package/dist/bundle/index.d.ts.map +1 -1
  129. package/dist/bundle/index.js +2 -2
  130. package/dist/bundle/index.js.map +1 -1
  131. package/dist/bundle/manifest.d.ts.map +1 -1
  132. package/dist/bundle/manifest.js +3 -2
  133. package/dist/bundle/manifest.js.map +1 -1
  134. package/dist/bundle/types.d.ts +4 -0
  135. package/dist/bundle/types.d.ts.map +1 -1
  136. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  137. package/dist/canvas-backend/headerFooter.js +3 -1
  138. package/dist/canvas-backend/headerFooter.js.map +1 -1
  139. package/dist/columnRule.d.ts.map +1 -1
  140. package/dist/columnRule.js +5 -2
  141. package/dist/columnRule.js.map +1 -1
  142. package/dist/configWarnings.js +1 -1
  143. package/dist/configWarnings.js.map +1 -1
  144. package/dist/defaults/cjk.d.ts +2 -1
  145. package/dist/defaults/cjk.d.ts.map +1 -1
  146. package/dist/defaults/cjk.js +17 -1
  147. package/dist/defaults/cjk.js.map +1 -1
  148. package/dist/defaults/diagramStyle.d.ts.map +1 -1
  149. package/dist/defaults/diagramStyle.js +6 -0
  150. package/dist/defaults/diagramStyle.js.map +1 -1
  151. package/dist/defaults/headings.d.ts.map +1 -1
  152. package/dist/defaults/headings.js +3 -0
  153. package/dist/defaults/headings.js.map +1 -1
  154. package/dist/defaults/index.d.ts +1 -1
  155. package/dist/defaults/index.d.ts.map +1 -1
  156. package/dist/defaults/index.js +1 -1
  157. package/dist/defaults/index.js.map +1 -1
  158. package/dist/defaults/layout.d.ts +5 -0
  159. package/dist/defaults/layout.d.ts.map +1 -1
  160. package/dist/defaults/layout.js +32 -0
  161. package/dist/defaults/layout.js.map +1 -1
  162. package/dist/defaults/tableStyle.d.ts.map +1 -1
  163. package/dist/defaults/tableStyle.js +6 -0
  164. package/dist/defaults/tableStyle.js.map +1 -1
  165. package/dist/design/cjkText.d.ts +34 -0
  166. package/dist/design/cjkText.d.ts.map +1 -0
  167. package/dist/design/cjkText.js +82 -0
  168. package/dist/design/cjkText.js.map +1 -0
  169. package/dist/design/layout.d.ts.map +1 -1
  170. package/dist/design/layout.js +103 -5
  171. package/dist/design/layout.js.map +1 -1
  172. package/dist/design/richText.d.ts +24 -0
  173. package/dist/design/richText.d.ts.map +1 -1
  174. package/dist/design/richText.js +37 -1
  175. package/dist/design/richText.js.map +1 -1
  176. package/dist/fonts/buildWithFonts.d.ts +19 -0
  177. package/dist/fonts/buildWithFonts.d.ts.map +1 -0
  178. package/dist/fonts/buildWithFonts.js +16 -0
  179. package/dist/fonts/buildWithFonts.js.map +1 -0
  180. package/dist/fonts/faces.d.ts +88 -0
  181. package/dist/fonts/faces.d.ts.map +1 -0
  182. package/dist/fonts/faces.js +264 -0
  183. package/dist/fonts/faces.js.map +1 -0
  184. package/dist/fonts/fontSet.d.ts +34 -0
  185. package/dist/fonts/fontSet.d.ts.map +1 -0
  186. package/dist/fonts/fontSet.js +173 -0
  187. package/dist/fonts/fontSet.js.map +1 -0
  188. package/dist/fonts/prepare.d.ts +120 -0
  189. package/dist/fonts/prepare.d.ts.map +1 -0
  190. package/dist/fonts/prepare.js +375 -0
  191. package/dist/fonts/prepare.js.map +1 -0
  192. package/dist/html-backend.d.ts +17 -0
  193. package/dist/html-backend.d.ts.map +1 -1
  194. package/dist/html-backend.js +87 -3
  195. package/dist/html-backend.js.map +1 -1
  196. package/dist/index.d.ts +25 -5
  197. package/dist/index.d.ts.map +1 -1
  198. package/dist/index.js +18 -2
  199. package/dist/index.js.map +1 -1
  200. package/dist/measure/cache.d.ts.map +1 -1
  201. package/dist/measure/cache.js +32 -2
  202. package/dist/measure/cache.js.map +1 -1
  203. package/dist/measure/canvas.d.ts +15 -0
  204. package/dist/measure/canvas.d.ts.map +1 -1
  205. package/dist/measure/canvas.js +68 -4
  206. package/dist/measure/canvas.js.map +1 -1
  207. package/dist/measure/cjk.d.ts.map +1 -1
  208. package/dist/measure/cjk.js +4 -1
  209. package/dist/measure/cjk.js.map +1 -1
  210. package/dist/measure/cjkClasses.d.ts +17 -0
  211. package/dist/measure/cjkClasses.d.ts.map +1 -1
  212. package/dist/measure/cjkClasses.js +41 -1
  213. package/dist/measure/cjkClasses.js.map +1 -1
  214. package/dist/measure/cjkCompose.d.ts.map +1 -1
  215. package/dist/measure/cjkCompose.js +119 -7
  216. package/dist/measure/cjkCompose.js.map +1 -1
  217. package/dist/measure/cjkPunctuation.d.ts +18 -1
  218. package/dist/measure/cjkPunctuation.d.ts.map +1 -1
  219. package/dist/measure/cjkPunctuation.js +13 -1
  220. package/dist/measure/cjkPunctuation.js.map +1 -1
  221. package/dist/measure/font.d.ts +8 -0
  222. package/dist/measure/font.d.ts.map +1 -1
  223. package/dist/measure/font.js +13 -2
  224. package/dist/measure/font.js.map +1 -1
  225. package/dist/measure/fontString.d.ts +14 -0
  226. package/dist/measure/fontString.d.ts.map +1 -0
  227. package/dist/measure/fontString.js +55 -0
  228. package/dist/measure/fontString.js.map +1 -0
  229. package/dist/measure/index.d.ts +4 -1
  230. package/dist/measure/index.d.ts.map +1 -1
  231. package/dist/measure/index.js +3 -1
  232. package/dist/measure/index.js.map +1 -1
  233. package/dist/measure/types.d.ts +4 -0
  234. package/dist/measure/types.d.ts.map +1 -1
  235. package/dist/measure/types.js.map +1 -1
  236. package/dist/parse/citations.d.ts.map +1 -1
  237. package/dist/parse/citations.js +7 -3
  238. package/dist/parse/citations.js.map +1 -1
  239. package/dist/pipeline/build.d.ts +36 -0
  240. package/dist/pipeline/build.d.ts.map +1 -1
  241. package/dist/pipeline/build.js +1065 -193
  242. package/dist/pipeline/build.js.map +1 -1
  243. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  244. package/dist/pipeline/buildBlockKind.js +20 -6
  245. package/dist/pipeline/buildBlockKind.js.map +1 -1
  246. package/dist/pipeline/buildMeasurement.d.ts +6 -0
  247. package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
  248. package/dist/pipeline/buildMeasurement.js +3 -2
  249. package/dist/pipeline/buildMeasurement.js.map +1 -1
  250. package/dist/pipeline/calloutLayout.d.ts +59 -0
  251. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  252. package/dist/pipeline/calloutLayout.js +287 -65
  253. package/dist/pipeline/calloutLayout.js.map +1 -1
  254. package/dist/pipeline/columnBalancing.d.ts +20 -0
  255. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  256. package/dist/pipeline/columnBalancing.js +22 -4
  257. package/dist/pipeline/columnBalancing.js.map +1 -1
  258. package/dist/pipeline/columnsGroup.d.ts +61 -0
  259. package/dist/pipeline/columnsGroup.d.ts.map +1 -0
  260. package/dist/pipeline/columnsGroup.js +88 -0
  261. package/dist/pipeline/columnsGroup.js.map +1 -0
  262. package/dist/pipeline/config.d.ts +28 -0
  263. package/dist/pipeline/config.d.ts.map +1 -1
  264. package/dist/pipeline/config.js +67 -8
  265. package/dist/pipeline/config.js.map +1 -1
  266. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  267. package/dist/pipeline/contentWarnings.js +28 -0
  268. package/dist/pipeline/contentWarnings.js.map +1 -1
  269. package/dist/pipeline/floatPlacement.d.ts +16 -2
  270. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  271. package/dist/pipeline/floatPlacement.js +12 -4
  272. package/dist/pipeline/floatPlacement.js.map +1 -1
  273. package/dist/pipeline/floatSlots.d.ts +9 -3
  274. package/dist/pipeline/floatSlots.d.ts.map +1 -1
  275. package/dist/pipeline/floatSlots.js +15 -6
  276. package/dist/pipeline/floatSlots.js.map +1 -1
  277. package/dist/pipeline/flowColumns.d.ts +41 -0
  278. package/dist/pipeline/flowColumns.d.ts.map +1 -0
  279. package/dist/pipeline/flowColumns.js +116 -0
  280. package/dist/pipeline/flowColumns.js.map +1 -0
  281. package/dist/pipeline/measureContentBlock.d.ts +13 -1
  282. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  283. package/dist/pipeline/measureContentBlock.js +20 -5
  284. package/dist/pipeline/measureContentBlock.js.map +1 -1
  285. package/dist/pipeline/partPalette.d.ts +3 -0
  286. package/dist/pipeline/partPalette.d.ts.map +1 -1
  287. package/dist/pipeline/partPalette.js +5 -0
  288. package/dist/pipeline/partPalette.js.map +1 -1
  289. package/dist/pipeline/placement.d.ts +19 -0
  290. package/dist/pipeline/placement.d.ts.map +1 -1
  291. package/dist/pipeline/placement.js +5 -2
  292. package/dist/pipeline/placement.js.map +1 -1
  293. package/dist/pipeline/resourceLayout.d.ts +26 -0
  294. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  295. package/dist/pipeline/resourceLayout.js +53 -3
  296. package/dist/pipeline/resourceLayout.js.map +1 -1
  297. package/dist/pipeline/toc.d.ts.map +1 -1
  298. package/dist/pipeline/toc.js +6 -1
  299. package/dist/pipeline/toc.js.map +1 -1
  300. package/dist/svg/fontRegistry.d.ts +66 -0
  301. package/dist/svg/fontRegistry.d.ts.map +1 -0
  302. package/dist/svg/fontRegistry.js +294 -0
  303. package/dist/svg/fontRegistry.js.map +1 -0
  304. package/dist/svg/fonts.d.ts +140 -0
  305. package/dist/svg/fonts.d.ts.map +1 -0
  306. package/dist/svg/fonts.js +642 -0
  307. package/dist/svg/fonts.js.map +1 -0
  308. package/dist/svg/image.d.ts +44 -0
  309. package/dist/svg/image.d.ts.map +1 -0
  310. package/dist/svg/image.js +67 -0
  311. package/dist/svg/image.js.map +1 -0
  312. package/dist/types.d.ts +138 -2
  313. package/dist/types.d.ts.map +1 -1
  314. package/dist/util/stableHash.d.ts +20 -0
  315. package/dist/util/stableHash.d.ts.map +1 -0
  316. package/dist/util/stableHash.js +59 -0
  317. package/dist/util/stableHash.js.map +1 -0
  318. package/dist/vdt.d.ts +118 -3
  319. package/dist/vdt.d.ts.map +1 -1
  320. package/dist/vdt.js.map +1 -1
  321. package/dist/worker/client.d.ts +8 -0
  322. package/dist/worker/client.d.ts.map +1 -1
  323. package/dist/worker/client.js +22 -0
  324. package/dist/worker/client.js.map +1 -1
  325. package/dist/worker/fonts.d.ts +1 -13
  326. package/dist/worker/fonts.d.ts.map +1 -1
  327. package/dist/worker/fonts.js +3 -61
  328. package/dist/worker/fonts.js.map +1 -1
  329. package/dist/worker/layout.worker.js +23 -16
  330. package/dist/worker/layout.worker.js.map +1 -1
  331. package/package.json +1 -1
@@ -15,7 +15,10 @@ import { stampCentralBaselines } from './verticalMetrics';
15
15
  import { orientLinesToFrames } from './mirrorFrame';
16
16
  import { verseMarginTopPx } from './buildBlockKind';
17
17
  import { verseLinesSettings, versesLineByLine } from './verseLines';
18
- import { resolveAllConfig, computeBaselineGrid, resolvedDirection, resolvedLocale } from './config';
18
+ import { resolveAllConfig, computeBaselineGrid, resolvedDirection, resolvedLocale, validateConfigCache } from './config';
19
+ import { resolveDebugConfig } from '../defaults/debug';
20
+ import { defaultFontSet, documentFontFaces, fontFallbacks } from '../fonts/faces';
21
+ import { syncFontSet } from '../fonts/fontSet';
19
22
  import { asciiDigits } from '../arabicNumerals';
20
23
  import { getMeasureDirection, setMeasureDirection, shiftLineX } from '../measure/bidiLines';
21
24
  import { createHeadingLevelResolver, deriveSectionGeometryConfig, sectionWritingMode, deriveSectionMeasureContext, headingIsHidden, headingIsNumbered, headingMarksFor, headingStyleOf, numberTitlesFor, planHeadingSections, } from './headingStyles';
@@ -25,12 +28,13 @@ import { bookCitationContexts, needsCitationContext } from '../citations/context
25
28
  import { anchorTargetsOf, hasAnchorRefs, printsAnchorPages } from './crossRefs';
26
29
  import { applyStatementNumbering, numberStatements, outlineLookup } from './statementNumbering';
27
30
  import { SPAN_EMBED_STYLE_ID, isPageSpanStrip, spanEmbedStyle, wrapPageSpanEmbeds } from './spanEmbeds';
31
+ import { flowColumnsStyles, isFlowColumnsStyle, wrapFlowColumns } from './flowColumns';
28
32
  import { anchorOutline, computeOutline, hasIndexDirective, hasTocDirective, headingNumberingOptions, headingTemplatesOf, outlineFromDoc, sameOutline } from './outline';
29
33
  import { expandTocDirectives } from './toc';
30
34
  import { expandIndexDirectives, locateIndexMarks } from './indexDirective';
31
35
  import { resolveBodyStyle, resolveBlockquoteStyle, resolveParagraphStyle } from './styles';
32
36
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, listBulletPosition, listItemGapPx, listItemSpacingPx, mirrorListMarker, } from './lists';
33
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, sideColumnOf, sideColumns, sideUsedBottom, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, closeBandAt, pageLayoutOf, } from './placement';
37
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, sideColumnOf, sideColumns, sideUsedBottom, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, closeBandAt, pageLayoutOf, FIT_EPS, } from './placement';
34
38
  import { chooseParagraphSplit } from './orphanWidow';
35
39
  import { layoutComicPage, comicPageLayoutWarnings } from '../comics/layoutPage';
36
40
  import { comicStripExtent, comicStripOffset, comicStripPlacement, comicStripWidth, layoutComicStrip } from '../comics/strip';
@@ -41,7 +45,8 @@ import { containerStyle, paragraphStyleIdOf, planParagraphContainers } from './p
41
45
  import { appendChapterEndNotes, footnoteIdsOfLines, footnoteParagraphStyle, noteContentBlock, noteNumberPositionOf, numberFootnotes, footnoteFormatOf, numberFootnotesByPlacement, sameFootnoteNumbers, splitFootnoteDefinitions, withFootnoteStyle, } from './footnotes';
42
46
  import { planParts, derivePartMeasureContext } from './parts';
43
47
  import { layoutCallout, offsetBlock, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
44
- import { layoutCaptionText, layoutResourceBlock, planTableSlice } from './resourceLayout';
48
+ import { fillGalley, galleyBreaks } from './columnsGroup';
49
+ import { chooseTableSlice, layoutCaptionText, layoutResourceBlock, MIN_TAIL_ROWS } from './resourceLayout';
45
50
  import { computeFloatPlan, floatedResourceIds, floatColumnCount, } from './floatPlacement';
46
51
  import { enumerateCurrentPageSlots, measureFloatBand, floatGapAbove, floatGapBelow, measureSideStack, clearSideObstacles, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
47
52
  import { computeHeadingContext, computeResourceNumberingState, } from './resourceNumbering';
@@ -55,12 +60,13 @@ import { buildLineNumbers, lineNumberWarnings } from './lineNumbers';
55
60
  import { buildHeadersAndFooters, defaultOpenerTitle, headingDesignBoxes, measureDefaultOpenerHeight, measureHeadingDesign } from './headerFooter';
56
61
  import { flowColorValues } from './partPalette';
57
62
  import { chapterNumberCounter, leadingBoldText } from './placeholders';
58
- import { proposeBalanceLines, collectColumnGaps, firstDivergentColumn, gapLinesIn, boxRoomIn, boxLeverKeys, pageSegments, MAX_BALANCING_PASSES, MAX_BALANCING_PASSES_PER_DOCUMENT, balanceKey, candidateKey, flexFigureKey, flexFloatKey } from './columnBalancing';
63
+ import { proposeBalanceLines, GRID_LINE_LEVERS, collectColumnGaps, firstDivergentColumn, gapLinesIn, boxRoomIn, boxLeverKeys, pageSegments, MAX_BALANCING_PASSES, MAX_BALANCING_PASSES_PER_DOCUMENT, balanceKey, candidateKey, flexFigureKey, flexFloatKey } from './columnBalancing';
59
64
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, bareBandTop, resolveBandCapsGen, drainPasses, resolveTrailingCapsGen, bandCapLinesAroundZone, } from './bandCaps';
60
65
  import { raggedLooseLines } from './raggedLines';
61
66
  import { cjkLooseLineWarnings, collectContentWarnings, designTruncationWarnings, dropCapWarnings, floatShrinkWarnings, joiningLetterSpacingWarnings, locateContentWarnings, wordOverflowWarnings } from './contentWarnings';
62
67
  import { dropCapSinkAfter } from './dropCap';
63
68
  import { mostlyJoiningScript } from '../measure/joining';
69
+ import { composesAsCjk } from '../measure/cjkCompose';
64
70
  import { annotateDocument } from '../cjkMarks';
65
71
  import { annotateArabicMarks } from '../arabicMarks';
66
72
  import { literalBreaksFor } from './hardBreaks';
@@ -71,9 +77,6 @@ import { paperByBlock, stampPagePaper } from './paper';
71
77
  import { headingLineSpanBand } from './lineSpan';
72
78
  import { columnCursorY, insetsBefore, minTextWidthPx, openColumnWrap, resolveCalloutWrap, resolveResourceWrap, runsBesideWrap, sameInsetsFrom, settleColumnWraps, transparentToWrap, wrapGapPx, wrapInsetSteps } from './textWrap';
73
79
  import { lineInsetsAt } from '../measure/types';
74
- /** Tolerance for "does this block fit" checks against a column's free
75
- * height, absorbing floating-point drift between grid multiples. */
76
- const FIT_EPS = 0.01;
77
80
  /** Room (px) under closing boxes a balancing pass must close to be kept on
78
81
  * that ground alone (see `boxRoomIn`). */
79
82
  const BOX_ROOM_EPS_PX = 0.5;
@@ -101,6 +104,16 @@ function floatPagesOf(doc) {
101
104
  }
102
105
  return out;
103
106
  }
107
+ /** Whether `pageIndex` opens a run of pages after an explicit break (the
108
+ * document's first page, the page after a chapter's opener break, a part
109
+ * or a `:::pagebreak`, past the blank pages parity put between them): no
110
+ * float heads such a page ahead of its citing line (#633). */
111
+ function opensAfterBreak(pages, pageIndex, forcedBreakPages) {
112
+ let p = pageIndex - 1;
113
+ while (p >= 0 && (pages[p]?.blankForParity || pages[p]?.blankForForce))
114
+ p--;
115
+ return p < 0 || forcedBreakPages.has(p);
116
+ }
104
117
  /** `{chapterNumber}` at each content block: the value of the last level-1
105
118
  * heading at or before it ('' before the first), by the counter the
106
119
  * running heads use over the placed blocks (`computeChapterNumbers`) —
@@ -148,7 +161,7 @@ function chapterNumbersOfContent(blocks, resolved, prefixes, values, ordinalOffs
148
161
  * line less (EF-129: asked for one more, it could only match the target by
149
162
  * falling back to its optimum, unchecked).
150
163
  */
151
- function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome) {
164
+ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome, offGrid) {
152
165
  const base = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, { styleOverride });
153
166
  if (!base)
154
167
  return null;
@@ -165,25 +178,39 @@ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverri
165
178
  // A paragraph mostly in a joining script (Arabic…) is not tracked: its
166
179
  // words take no letter-spacing (`measure/joining.ts`), so only the first
167
180
  // rung, word spacing alone, can gain it the line.
168
- const ladder = trackingLadder.length > 1 && mostlyJoiningScript(rawBlock.text) ? trackingLadder.slice(0, 1) : trackingLadder;
181
+ // On a character grid (#632) a paragraph the CJK composer sets keeps
182
+ // every character in its one-em cell: no tracking is added to its
183
+ // letters, and a line it breaks short may spread between its characters
184
+ // no more than `maxTracking` (on a grid of whole cells that refuses any
185
+ // line a character short, whose spread is an em over the line's gaps).
186
+ const onGrid = ctx.resolved.cjk.grid.enabled && composesAsCjk(rawBlock.text);
187
+ const ladder = trackingLadder.length > 1 && (onGrid || mostlyJoiningScript(rawBlock.text)) ? trackingLadder.slice(0, 1) : trackingLadder;
188
+ const gridCap = onGrid ? { gridTrackingEm: ctx.resolved.headings.balancing.maxTracking / 1000 } : {};
169
189
  const maxWordSpacing = ctx.resolved.bodyText.maxWordSpacing + 1e-9;
170
190
  // A CJK line spread past its tracking cap (`cjkLoose`) is past the
171
191
  // limit too.
172
192
  const withinLimit = (lines) => lines.every((l) => l.isLastLine || ((l.justifiedSpaceRatio === undefined || l.justifiedSpaceRatio <= maxWordSpacing) && !l.cjkLoose));
193
+ const gains = (loose) => loose !== null
194
+ && loose.measured.lines.length === target
195
+ && !loose.measured.lastLineRunt
196
+ && withinLimit(loose.measured.lines);
173
197
  for (const tracking of ladder) {
174
198
  const loose = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, {
175
199
  styleOverride,
176
200
  looseness,
177
201
  trackingEm: tracking > 0 ? tracking / 1000 : undefined,
202
+ ...gridCap,
178
203
  });
179
- if (loose
180
- && loose.measured.lines.length === target
181
- && !loose.measured.lastLineRunt
182
- && withinLimit(loose.measured.lines)) {
204
+ if (gains(loose)) {
183
205
  looseOutcome.set(blockIdx, tracking);
184
206
  return loose;
185
207
  }
186
208
  }
209
+ // Refused for the grid's sake alone: the paragraph would gain its line
210
+ // off the grid. Recorded, so the column says why it ends short.
211
+ if (onGrid && offGrid && gains(measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, { styleOverride, looseness }))) {
212
+ offGrid.add(blockIdx);
213
+ }
187
214
  looseOutcome.set(blockIdx, null);
188
215
  return base;
189
216
  }
@@ -220,7 +247,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
220
247
  }
221
248
  }
222
249
  function placeDocumentPass(content, config, cache, options, hints) {
223
- const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps, captionUnder, floatPages } = hints;
250
+ const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps, captionUnder, floatPages, citingPageFloats } = hints;
224
251
  const captionUnderProposals = new Set();
225
252
  /** Side columns whose foot a figure's side caption has cut, by the figure. */
226
253
  const asideCutBy = new WeakMap();
@@ -247,6 +274,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
247
274
  trackingLadder.push(balancingCfg.maxTracking);
248
275
  }
249
276
  const looseOutcome = new Map();
277
+ const looseOffGrid = new Set();
250
278
  // Lines gained so far per column budget group (see `LooseBudget`).
251
279
  const looseGained = new Map();
252
280
  // Level configs with heading-style overrides merged in (`{style="…"}`).
@@ -400,7 +428,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
400
428
  // An inline `::resource` placed `here` with `span: 'page'` spans the
401
429
  // page where it stands (#535): each goes in a frameless page-span box,
402
430
  // set by the span-block path like any other.
403
- const spanEmbeds = wrapPageSpanEmbeds(numberedBlocks, content.resources ?? [], effectiveResourceTypes(config, content.resources ?? []));
431
+ // A `:::columns` group in the running text (#634): each in a frameless
432
+ // box the callout machinery places, cuts and splits.
433
+ const flowGroups = resolved.layout.flowColumns ? wrapFlowColumns(numberedBlocks) : { blocks: numberedBlocks, wrapped: false };
434
+ const spanEmbeds = wrapPageSpanEmbeds(flowGroups.blocks, content.resources ?? [], effectiveResourceTypes(config, content.resources ?? []));
404
435
  // Code listings (#624): each in a box of the code style, which the
405
436
  // callout machinery places and splits.
406
437
  const codeBoxes = wrapCodeBlocks(spanEmbeds.blocks, resolved);
@@ -445,13 +476,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
445
476
  const syntheticBoxes = [
446
477
  ...(spanEmbeds.wrapped ? [spanEmbedStyle(bodyStyle.lineHeightPx, resolved.layout.inlineResourceGap !== 'above')] : []),
447
478
  ...(codeBoxes.wrapped ? [codeBoxStyle(resolved)] : []),
479
+ // The boxes of main-flow groups: sub-columns a body line apart in the
480
+ // text column, a gutter apart across the page.
481
+ ...(flowGroups.wrapped
482
+ ? flowColumnsStyles(bodyStyle.lineHeightPx, dimensionToPx(resolved.layout.gutterWidth, resolved.page.dpi, bodyStyle.fontSizePx), resolved.bodyText.paragraphSpacing === false ? { value: 0, unit: 'px' } : { value: bodyStyle.marginBottomPx, unit: 'px' }, Math.max(resolved.bodyText.avoidOrphans ? resolved.bodyText.orphanMinLines : 1, resolved.bodyText.avoidWidows ? resolved.bodyText.widowMinLines : 1))
483
+ : []),
448
484
  ];
449
485
  const calloutStyles = syntheticBoxes.length > 0
450
486
  ? [...resolved.calloutStyles, ...resolveCalloutStylesConfig(syntheticBoxes, resolved.bodyText, resolved.headings, resolved.unorderedLists, config?.locale)]
451
487
  : resolved.calloutStyles;
452
488
  // A box nested in another looks its style up in the configuration it is
453
489
  // laid out with: the listings' box among them.
454
- const calloutResolved = codeBoxes.wrapped ? { ...resolved, calloutStyles } : resolved;
490
+ const calloutResolved = codeBoxes.wrapped || flowGroups.wrapped ? { ...resolved, calloutStyles } : resolved;
455
491
  const blockquoteStyle = resolveBlockquoteStyle(resolved);
456
492
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
457
493
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
@@ -482,7 +518,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
482
518
  // it asks for is not applied there. Decided per block, by the writing
483
519
  // mode of the styled section the reference sits in (a horizontal
484
520
  // appendix of a vertical book turns its figures as asked).
485
- const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes, incorporated, (blockIdx) => sectionWritingMode(sectionPlan, resolved, blockIdx) === 'vertical-rl', resolved.layout.floatShrink, resolved.layout.wrap);
521
+ const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes, incorporated, (blockIdx) => sectionWritingMode(sectionPlan, resolved, blockIdx) === 'vertical-rl', resolved.layout.floatShrink, resolved.layout.wrap, resolved.layout.floatsAtCitingPage);
486
522
  const floatedIds = floatedResourceIds(floatPlan, incorporated, resources, resourceTypes);
487
523
  const floatsByFirstBlock = new Map();
488
524
  for (const f of floatPlan) {
@@ -499,6 +535,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
499
535
  * page: they take the side column of the next page the flow opens, in
500
536
  * order (see `placeCalloutSide`). */
501
537
  const pendingSideBoxes = [];
538
+ /** The flow segment has ended and its floats and side boxes are taking
539
+ * pages after the text (see `drainPendingFloats`, #639). */
540
+ let drainingAfterText = false;
502
541
  /** `span: 'side'` boxes of a style with `sideAtColumnEnd: 'after'`
503
542
  * (EF-161), and the side boxes fenced behind them, waiting for the text
504
543
  * after their fence to land (see `settleAwaitingSideBoxes`). Each keeps
@@ -884,8 +923,6 @@ function placeDocumentPass(content, config, cache, options, hints) {
884
923
  };
885
924
  const floatGapPx = bodyStyle.lineHeightPx;
886
925
  const minTextPx = bodyStyle.lineHeightPx * 3;
887
- /** Fewest body rows the closing slice of a split table carries. */
888
- const MIN_TAIL_ROWS = 3;
889
926
  /** Offset a resolved resource block's caption/table geometry from
890
927
  * block-relative to absolute page coordinates (mirrors inline placement). */
891
928
  const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
@@ -1244,28 +1281,6 @@ function placeDocumentPass(content, config, cache, options, hints) {
1244
1281
  // A top band rounds up to the grid: the tallest float it holds within
1245
1282
  // `avail` is the grid multiple below it, less the gap.
1246
1283
  const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
1247
- let end = planTableSlice(metrics, startRow, (rotated ? width : hMax) - overhead);
1248
- const floor = firstBody + 1;
1249
- if (end < floor) {
1250
- // Nothing fits. A fresh page carries the smallest slice anyway (it
1251
- // overflows, as a dominating figure would) rather than stall the
1252
- // queue; a slot of the current page is simply not this table's.
1253
- if (mode === 'strict')
1254
- return 'none';
1255
- end = floor;
1256
- }
1257
- // A last page holding a row or two under a repeated header reads as a
1258
- // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
1259
- // handing some back from this one (at a breakable edge, not under a
1260
- // group head), when this slice can spare them.
1261
- const tail = rowCount - end;
1262
- if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
1263
- let e = rowCount - MIN_TAIL_ROWS;
1264
- while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
1265
- e--;
1266
- if (e >= floor && e >= end - MIN_TAIL_ROWS)
1267
- end = e;
1268
- }
1269
1284
  const fits = (slice) => {
1270
1285
  const measure = measureFloat(f.resourceId, width, slice, rotated);
1271
1286
  if (!measure)
@@ -1275,23 +1290,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
1275
1290
  const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
1276
1291
  return need <= avail + 0.01;
1277
1292
  };
1278
- const sliceFor = (e) => ({
1279
- startRow,
1280
- endRow: e,
1281
- continues: e < rowCount && overflow === 'split',
1293
+ // Nothing fits: a fresh page carries the smallest slice anyway (it
1294
+ // overflows, as a dominating figure would) rather than stall the
1295
+ // queue; a slot of the current page is simply not this table's.
1296
+ const slice = chooseTableSlice(metrics, startRow, (rotated ? width : hMax) - overhead, {
1297
+ floor: firstBody + 1,
1298
+ force: mode === 'fresh',
1299
+ split: overflow === 'split',
1300
+ fits,
1282
1301
  });
1283
- let slice = sliceFor(end);
1284
- // The caption may wrap differently with its suffix, the closing slice
1285
- // carries the note instead of the marker: verify, backing off a row at
1286
- // a time (over breakable edges) when the band still overflows.
1287
- for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
1288
- do
1289
- end--;
1290
- while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
1291
- slice = sliceFor(end);
1292
- }
1293
- if (mode === 'strict' && !fits(slice))
1302
+ if (!slice)
1294
1303
  return 'none';
1304
+ const end = slice.endRow;
1295
1305
  const rest = slice.continues ? { ...f, startRow: end } : undefined;
1296
1306
  return rest ? { slice, rest } : { slice };
1297
1307
  };
@@ -1495,6 +1505,25 @@ function placeDocumentPass(content, config, cache, options, hints) {
1495
1505
  let cut = false;
1496
1506
  /** A rotated block wider than the band (its upright height). */
1497
1507
  const tooWide = (m) => m.rotatedWidth !== undefined && m.rotatedWidth > width + 0.01;
1508
+ /** The columns of the slot that hold a page-span opener and nothing
1509
+ * after it (#639), with the y of their head under the opener's band:
1510
+ * there the band is taken from the flow under the opener — the column
1511
+ * keeps its box, the opener stands in it — where an empty column's
1512
+ * head moves down under the band. */
1513
+ const openerHeads = new Map();
1514
+ if (position === 'top' && mode === 'strict' && !pageSpan && !side) {
1515
+ for (const c of targetCols) {
1516
+ const top = openerHeadOf(c);
1517
+ if (top !== undefined)
1518
+ openerHeads.set(c, top);
1519
+ }
1520
+ }
1521
+ /** What such a column loses above the band: from the flow's position
1522
+ * under the opener down to the grid line the band starts on. */
1523
+ const leadOf = (c) => {
1524
+ const top = openerHeads.get(c);
1525
+ return top === undefined ? 0 : Math.max(0, top - columnCursorY(c));
1526
+ };
1498
1527
  if (side) {
1499
1528
  // The side column's stack: the float goes under what the column holds
1500
1529
  // when the rest of the column takes it; otherwise it waits for the
@@ -1534,6 +1563,13 @@ function placeDocumentPass(content, config, cache, options, hints) {
1534
1563
  return 'skip';
1535
1564
  first.availableHeight = Math.max(0, first.availableHeight - need);
1536
1565
  commitFloatBlock(page, first, built, xLeft, y, width, f.firstBlockIdx);
1566
+ if (drainingAfterText && !calloutFloatOf(f.resourceId)) {
1567
+ const citing = contentBlocks[f.firstBlockIdx];
1568
+ setAfterText.set(built.block, {
1569
+ resourceId: f.resourceId,
1570
+ ...(citing ? { sourceStart: citing.sourceStart + bodyOffset, sourceEnd: citing.sourceEnd + bodyOffset } : {}),
1571
+ });
1572
+ }
1537
1573
  floatsPlaced++;
1538
1574
  return 'placed';
1539
1575
  }
@@ -1607,7 +1643,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1607
1643
  // to another band, as `fitsStrict` keeps it); the loop below checks
1608
1644
  // the band as for any float.
1609
1645
  if (f.shrink === 'slot' && measure.body && !rotated) {
1610
- const room = Math.min(...targetCols.map((c) => c.availableHeight - (columnHasFloatBand(page, c) ? minTextPx : 0)));
1646
+ const room = Math.min(...targetCols.map((c) => c.availableHeight - leadOf(c) - (columnHasFloatBand(page, c) ? minTextPx : 0)));
1611
1647
  if (need > room + 0.01) {
1612
1648
  const s = shrinkToRoom(f, width, aside, measure, room, position === 'top', bandOf);
1613
1649
  if (!s?.fits)
@@ -1623,7 +1659,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1623
1659
  continue;
1624
1660
  }
1625
1661
  const hasBand = columnHasFloatBand(page, c);
1626
- if (fitsStrict(need, c, hasBand, minTextPx))
1662
+ if (fitsStrict(need + leadOf(c), c, hasBand, minTextPx))
1627
1663
  continue;
1628
1664
  // The head of an empty column: a splittable table is cut to the
1629
1665
  // column and continues in the next slot — the compositor sets a
@@ -1675,6 +1711,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
1675
1711
  const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, flush, aside, mirroredOf(page), cap);
1676
1712
  if (!built)
1677
1713
  return 'skip';
1714
+ // Under an opener in its own column the band starts at that column's
1715
+ // head, the grid line the heads beside it stand on.
1716
+ y = openerHeads.get(first) ?? y;
1678
1717
  // The caption beside the figure takes its band of the side column: a
1679
1718
  // top float's caption is consumed from the stack's head (when the
1680
1719
  // stack is still above it), a bottom float's cuts the column's foot.
@@ -1694,7 +1733,34 @@ function placeDocumentPass(content, config, cache, options, hints) {
1694
1733
  asideCutBy.set(sideCol, f.resourceId);
1695
1734
  }
1696
1735
  }
1736
+ // Text wrap (#627): a one-column float at the head or foot of its
1737
+ // column, which running text may take back to run beside it — unless
1738
+ // the text left beside it would be too narrow, or the float too short
1739
+ // for lines beside it.
1740
+ const wrapGap = wrapGapPx(f.wrapGap, dpi, bodyStyle.fontSizePx, floatGapPx);
1741
+ const wraps = f.wrap !== undefined && !pageSpan && !side && targetCols.length === 1 && !rotated && !aside && !cut && !galleryFill
1742
+ && !(position === 'bottom' && uncappedBottoms.has(first));
1743
+ const wrapRefused = !wraps ? undefined
1744
+ : first.bbox.width - width - wrapGap + 0.01 < minTextWidthPx(resolved.layout.wrap.minTextWidth, first.bbox.width, dpi, bodyStyle.fontSizePx)
1745
+ ? 'tooNarrow'
1746
+ : Math.round(need / bodyStyle.lineHeightPx) < resolved.layout.wrap.minLinesBeside ? 'fewLines' : undefined;
1697
1747
  for (const col of targetCols) {
1748
+ if (openerHeads.has(col)) {
1749
+ // The band is taken from the flow under the opener: the column
1750
+ // keeps its box (a trailing cap measures the band from the opener's
1751
+ // top in every column) and records where the band ends, which is
1752
+ // where its rule starts (`columnRuleSegments`). A float text wraps
1753
+ // round records nothing: the text placed next takes its band back
1754
+ // (see below) and the rule runs beside both.
1755
+ col.availableHeight = Math.max(0, col.availableHeight - leadOf(col) - need);
1756
+ if (!(wraps && !wrapRefused))
1757
+ col.headFloatFoot = columnCursorY(col);
1758
+ openerHeadFloats.set(col, columnCursorY(col));
1759
+ // What the opener left under it went into the band's gap.
1760
+ pendingSpacing = 0;
1761
+ inlineGapOwed = null;
1762
+ continue;
1763
+ }
1698
1764
  const r = { ...reservedOf(col) };
1699
1765
  if (position === 'top') {
1700
1766
  col.bbox.y += need;
@@ -1727,38 +1793,34 @@ function placeDocumentPass(content, config, cache, options, hints) {
1727
1793
  if (galleryFill)
1728
1794
  for (const col of targetCols)
1729
1795
  col.availableHeight = 0;
1730
- // Text wrap (#627): a one-column float at the head or foot of its
1731
- // column, which running text may take back to run beside it — unless
1732
- // the text left beside it would be too narrow, or the float too short
1733
- // for lines beside it.
1734
- if (f.wrap && !pageSpan && !side && targetCols.length === 1 && !rotated && !aside && !cut && !galleryFill
1735
- && !(position === 'bottom' && uncappedBottoms.has(first))) {
1736
- const gap = wrapGapPx(f.wrapGap, dpi, bodyStyle.fontSizePx, floatGapPx);
1737
- const settings = resolved.layout.wrap;
1738
- const reason = first.bbox.width - width - gap + 0.01 < minTextWidthPx(settings.minTextWidth, first.bbox.width, dpi, bodyStyle.fontSizePx)
1739
- ? 'tooNarrow'
1740
- : Math.round(need / bodyStyle.lineHeightPx) < settings.minLinesBeside ? 'fewLines' : undefined;
1741
- if (reason) {
1742
- textWrapNotes.push({ kind: 'textWrap', reason, resourceId: f.resourceId, pageIndex: page.index });
1796
+ if (wraps && f.wrap) {
1797
+ if (wrapRefused) {
1798
+ textWrapNotes.push({ kind: 'textWrap', reason: wrapRefused, resourceId: f.resourceId, pageIndex: page.index });
1743
1799
  }
1744
1800
  else {
1745
- const exWidth = wrapExclusionWidth(first.bbox.width, width, gap);
1746
- const exY = position === 'top' ? first.bbox.y - need : first.bbox.y + first.bbox.height;
1747
- const list = floatWraps.get(first) ?? [];
1748
- list.push({
1749
- position,
1750
- need,
1751
- ex: {
1752
- x: f.wrap === 'left' ? first.bbox.x : first.bbox.x + first.bbox.width - exWidth,
1753
- y: exY,
1754
- width: exWidth,
1755
- height: need,
1756
- side: f.wrap,
1757
- gap: exWidth - width,
1758
- ownerId: built.block.id,
1759
- },
1760
- });
1761
- floatWraps.set(first, list);
1801
+ const exWidth = wrapExclusionWidth(first.bbox.width, width, wrapGap);
1802
+ const underOpener = openerHeads.has(first);
1803
+ const exY = underOpener ? y : position === 'top' ? first.bbox.y - need : first.bbox.y + first.bbox.height;
1804
+ const ex = {
1805
+ x: f.wrap === 'left' ? first.bbox.x : first.bbox.x + first.bbox.width - exWidth,
1806
+ y: exY,
1807
+ width: exWidth,
1808
+ height: need,
1809
+ side: f.wrap,
1810
+ gap: exWidth - width,
1811
+ ownerId: built.block.id,
1812
+ };
1813
+ if (underOpener) {
1814
+ // The band was taken from the flow: the text placed next takes
1815
+ // it back and runs beside the float, as it does beside an inline
1816
+ // figure that wraps (`pendingWrap`).
1817
+ pendingWrap = { col: first, blocks: first.blocks.length, cursorY: columnCursorY(first), ex };
1818
+ }
1819
+ else {
1820
+ const list = floatWraps.get(first) ?? [];
1821
+ list.push({ position, need, ex });
1822
+ floatWraps.set(first, list);
1823
+ }
1762
1824
  }
1763
1825
  }
1764
1826
  commitFloatBlock(page, first, built, xLeft, y, width, f.firstBlockIdx);
@@ -1808,7 +1870,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1808
1870
  * column float of its sequence still gets the page's foot once that one
1809
1871
  * is set. A float that does not fit holds up the ones behind it in its
1810
1872
  * numbering sequence (see `heldBack`), never the other sequence. */
1811
- const flushFloatsIntoPage = (page) => {
1873
+ const flushPendingFloatsIntoPage = (page) => {
1812
1874
  if (pendingFloats.length === 0) {
1813
1875
  flushSideBoxesIntoPage(page);
1814
1876
  return;
@@ -1896,6 +1958,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
1896
1958
  i++;
1897
1959
  continue;
1898
1960
  }
1961
+ // After the text a side float keeps behind the side boxes fenced
1962
+ // before its citation that still wait (#639).
1963
+ if (isSide && drainingAfterText && pendingSideBoxes.some((b) => b.startIdx < f.firstBlockIdx)) {
1964
+ i++;
1965
+ continue;
1966
+ }
1899
1967
  // A page-span rest never shares the page of its previous slice.
1900
1968
  if (isPageSpan && f.notBefore?.pageIndex === page.index) {
1901
1969
  i++;
@@ -2013,7 +2081,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
2013
2081
  // refuse (a head-of-page float offered the foot of the current page);
2014
2082
  // floated callouts keep their placement.
2015
2083
  const asked = anyPosition && !f.rotate && !f.callout && f.position !== 'auto' ? { ...f, position: 'auto' } : f;
2016
- let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, asked, capKindOf);
2084
+ let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, asked, capKindOf, openerHeadOf);
2017
2085
  // The rest of a table cut on this page only takes the slots after
2018
2086
  // its previous slice in reading order (never the foot of the column
2019
2087
  // before it; a page-span rest waits for the next page).
@@ -2050,6 +2118,242 @@ function placeDocumentPass(content, config, cache, options, hints) {
2050
2118
  i = settle(i, r);
2051
2119
  }
2052
2120
  };
2121
+ // --- Floats at the head of the page that cites them (#633) ---------------
2122
+ // A float that may head its citing page (`placement.citingPage`) is placed
2123
+ // as any other in the first pass; when its citing line lands on a page (a
2124
+ // column) whose head it would fit while the float went on to a later one,
2125
+ // the pass proposes that head (`settleCitingPageFloats`), and the next
2126
+ // pass opens the page with the float there, before any text, the text
2127
+ // above the reference moving down under it. The driver keeps the slot
2128
+ // while the citing line stays on that page (that column).
2129
+ /** The floats `PassHints.citingPageFloats` sets at the head of each page,
2130
+ * in first-reference order (the order of their numbers). */
2131
+ const citingHeadsByPage = new Map();
2132
+ if (citingPageFloats && citingPageFloats.size > 0) {
2133
+ for (const f of floatPlan) {
2134
+ const slot = citingPageFloats.get(f.resourceId);
2135
+ if (!slot || !f.citingPage)
2136
+ continue;
2137
+ const list = citingHeadsByPage.get(slot.pageIndex);
2138
+ if (list)
2139
+ list.push(f);
2140
+ else
2141
+ citingHeadsByPage.set(slot.pageIndex, [f]);
2142
+ }
2143
+ }
2144
+ /** The floats this pass set at the head of their citing page. */
2145
+ const citingHeadsSet = new Set();
2146
+ const floatPlanOrder = new Map(floatPlan.map((f, i) => [f.resourceId, i]));
2147
+ /** Whether every float of `f`'s numbering sequence first cited before it
2148
+ * is set already: figure 4 never heads a page while figure 3 waits. */
2149
+ const sequenceSetBefore = (f) => {
2150
+ const typeId = resourceById.get(f.resourceId)?.typeId;
2151
+ const at = floatPlanOrder.get(f.resourceId) ?? 0;
2152
+ for (let i = 0; i < at; i++) {
2153
+ const g = floatPlan[i];
2154
+ if (resourceById.get(g.resourceId)?.typeId !== typeId)
2155
+ continue;
2156
+ if (!enqueuedFloatIds.has(g.resourceId) || pendingFloats.some((p) => p.resourceId === g.resourceId))
2157
+ return false;
2158
+ }
2159
+ return true;
2160
+ };
2161
+ /** The text columns of `page`'s first band (no span block above them). */
2162
+ const firstBandTextColumns = (page) => page.columns.filter((c) => c.kind !== 'span' && c.kind !== 'side' && (c.band ?? 0) === 0);
2163
+ /** Whether a float takes the page's whole width on `page` (as
2164
+ * `flushFloatsIntoPage` reads it). */
2165
+ const spansPageOn = (page, f, textCols) => {
2166
+ const across = Math.min(f.columns ?? 1, textCols.length);
2167
+ return (f.span === 'page' || (across > 1 && across === textCols.length)) && (textCols.length > 1 || sideColumns(page).length > 0);
2168
+ };
2169
+ /** The columns a column float heading its citing column `columnIndex`
2170
+ * takes: that column, or the run of `columns` from it (ending at the
2171
+ * band's last column when fewer follow it). */
2172
+ const citingColumnsOf = (f, textCols, columnIndex) => {
2173
+ const across = Math.min(f.columns ?? 1, textCols.length);
2174
+ const at = textCols.findIndex((c) => c.index === columnIndex);
2175
+ if (at < 0)
2176
+ return undefined;
2177
+ const from = Math.max(0, Math.min(at, textCols.length - across));
2178
+ return textCols.slice(from, from + across);
2179
+ };
2180
+ /** Set the floats that head `page` (`PassHints.citingPageFloats`), after
2181
+ * the floats the page owes to earlier pages (cited before them, read
2182
+ * before them): each where its citing line lands, when its sequence is
2183
+ * set up to it and it fits within `layout.maxTopFraction` of the column,
2184
+ * the text room kept under it. One that does not is placed as usual. */
2185
+ const placeCitingHeads = (page) => {
2186
+ const heads = citingHeadsByPage.get(page.index);
2187
+ if (!heads)
2188
+ return;
2189
+ if (opensAfterBreak(doc.pages, page.index, forcedBreakPages) || page.openerBand || page.partInfo)
2190
+ return;
2191
+ const textCols = firstBandTextColumns(page);
2192
+ if (textCols.length === 0 || textCols.some((c) => c.blocks.length > 0))
2193
+ return;
2194
+ const maxTop = resolved.layout.maxTopFraction;
2195
+ for (const planned of heads) {
2196
+ const id = planned.resourceId;
2197
+ if (citingHeadsSet.has(id))
2198
+ continue;
2199
+ const at = pendingFloats.findIndex((p) => p.resourceId === id);
2200
+ // Placed already, or queued with its citing line placed (on an
2201
+ // earlier page): the usual slots are its.
2202
+ if (at >= 0 ? !awaitsCitation(pendingFloats[at]) : enqueuedFloatIds.has(id))
2203
+ continue;
2204
+ if (!sequenceSetBefore(planned))
2205
+ continue;
2206
+ const pageSpan = spansPageOn(page, planned, textCols);
2207
+ const cols = pageSpan ? [...textCols, ...sideColumns(page)] : citingColumnsOf(planned, textCols, citingPageFloats.get(id).columnIndex);
2208
+ if (!cols)
2209
+ continue;
2210
+ const f = { ...planned, refPageIndex: page.index };
2211
+ const probe = probeFloatBand(page, f, cols, 'top', pageSpan, false);
2212
+ if (!probe)
2213
+ continue;
2214
+ const fits = cols.filter((c) => c.kind !== 'side').every((c) => {
2215
+ const r = reservedOf(c);
2216
+ const full = c.bbox.height + r.top + r.bottom;
2217
+ return r.top + probe.need <= maxTop * full + 0.01 && probe.need <= c.availableHeight - minTextPx + 0.01;
2218
+ });
2219
+ if (!fits)
2220
+ continue;
2221
+ // A page opened for a block that cannot split keeps its room.
2222
+ if (freshPageHold && cols.includes(freshPageHold.col)
2223
+ && availableAfterBand(freshPageHold.col, 'top', probe, false) < freshPageHold.px - FIT_EPS)
2224
+ continue;
2225
+ const r = placeFloatInColumns(page, f, cols, 'top', pageSpan, 'strict');
2226
+ if (r === 'defer' || r === 'skip')
2227
+ continue;
2228
+ enqueuedFloatIds.add(id);
2229
+ citationGates.delete(id);
2230
+ citingHeadsSet.add(id);
2231
+ if (at >= 0)
2232
+ pendingFloats.splice(at, 1);
2233
+ // A table cut to the head after all goes on as any rest does.
2234
+ if (typeof r === 'object')
2235
+ pendingFloats.push(r.rest);
2236
+ }
2237
+ };
2238
+ /**
2239
+ * After the pass: the floats that may head their citing page and went on
2240
+ * to a later page (or column), with the head their citing line's page
2241
+ * (column) offers — proposals for the next pass — and the floats the
2242
+ * hints asked to head a page this pass did not keep there. A float is
2243
+ * proposed when its citing line lands in the first band of a page that
2244
+ * no explicit break opened (no opener, part or `:::pagebreak` above it),
2245
+ * as one slice (not a table split across pages), when the earlier floats
2246
+ * of its sequence went no later than that page, and when it fits within
2247
+ * `layout.maxTopFraction` of the column.
2248
+ */
2249
+ const settleCitingPageFloats = () => {
2250
+ const proposals = new Map();
2251
+ const refused = new Map();
2252
+ const candidates = floatPlan.filter((f) => f.citingPage);
2253
+ if (candidates.length === 0)
2254
+ return { proposals, refused };
2255
+ const byId = new Map(candidates.map((f) => [f.resourceId, f]));
2256
+ // Where each one's citation landed: the first line that cites it, or
2257
+ // for a `::resource` directive the first line placed after it.
2258
+ const cited = new Map();
2259
+ const directives = candidates.filter((f) => contentBlocks[f.firstBlockIdx]?.type === 'resourceBlock');
2260
+ for (const page of doc.pages) {
2261
+ for (const col of page.columns) {
2262
+ for (const b of col.blocks) {
2263
+ for (const f of directives) {
2264
+ if (!cited.has(f.resourceId) && b.contentIndex !== undefined && b.contentIndex > f.firstBlockIdx && b.lines.length > 0) {
2265
+ cited.set(f.resourceId, { pageIndex: page.index, col });
2266
+ }
2267
+ }
2268
+ for (const l of b.lines) {
2269
+ for (const seg of l.segments ?? []) {
2270
+ const id = seg.refResourceId;
2271
+ if (id !== undefined && byId.has(id) && !cited.has(id))
2272
+ cited.set(id, { pageIndex: page.index, col });
2273
+ }
2274
+ }
2275
+ }
2276
+ }
2277
+ }
2278
+ const landed = floatPagesOf(doc);
2279
+ const landedColumn = new Map();
2280
+ for (const page of doc.pages) {
2281
+ for (const fb of page.floats ?? []) {
2282
+ const id = fb.resourceBlock?.resource.id;
2283
+ if (id === undefined || !byId.has(id))
2284
+ continue;
2285
+ const l = landedColumn.get(id);
2286
+ if (l)
2287
+ l.slices++;
2288
+ else
2289
+ landedColumn.set(id, { columnIndex: fb.columnIndex ?? 0, slices: 1 });
2290
+ }
2291
+ }
2292
+ const positionIn = (cols, index) => cols.findIndex((c) => c.index === index);
2293
+ for (const f of candidates) {
2294
+ const id = f.resourceId;
2295
+ const c = cited.get(id);
2296
+ const slot = citingPageFloats?.get(id);
2297
+ if (slot) {
2298
+ // Hinted: kept when it was set at the head and its citing line is
2299
+ // still under it, on that page, in a column it heads.
2300
+ if (!citingHeadsSet.has(id)) {
2301
+ refused.set(id, 'notSet');
2302
+ continue;
2303
+ }
2304
+ const page = doc.pages[slot.pageIndex];
2305
+ let kept = !!page && c !== undefined && c.pageIndex === slot.pageIndex;
2306
+ if (kept && page) {
2307
+ const textCols = firstBandTextColumns(page);
2308
+ if (!spansPageOn(page, f, textCols)) {
2309
+ const cols = citingColumnsOf(f, textCols, slot.columnIndex);
2310
+ kept = cols !== undefined && cols.includes(c.col);
2311
+ }
2312
+ }
2313
+ if (!kept)
2314
+ refused.set(id, 'citationMoved');
2315
+ continue;
2316
+ }
2317
+ const l = landedColumn.get(id);
2318
+ const landedPage = landed.get(id);
2319
+ if (!c || !l || landedPage === undefined || l.slices > 1)
2320
+ continue;
2321
+ const page = doc.pages[c.pageIndex];
2322
+ if (c.col.kind === 'span' || c.col.kind === 'side' || (c.col.band ?? 0) !== 0)
2323
+ continue;
2324
+ if (opensAfterBreak(doc.pages, c.pageIndex, forcedBreakPages) || page.openerBand || page.partInfo)
2325
+ continue;
2326
+ const textCols = firstBandTextColumns(page);
2327
+ const pageSpan = spansPageOn(page, f, textCols);
2328
+ const later = landedPage > c.pageIndex
2329
+ || (!pageSpan && landedPage === c.pageIndex && positionIn(textCols, l.columnIndex) > positionIn(textCols, c.col.index));
2330
+ if (!later)
2331
+ continue;
2332
+ // The earlier floats of its sequence went no later than that page.
2333
+ const typeId = resourceById.get(id)?.typeId;
2334
+ const order = floatPlanOrder.get(id) ?? 0;
2335
+ if (floatPlan.slice(0, order).some((g) => resourceById.get(g.resourceId)?.typeId === typeId && (landed.get(g.resourceId) ?? Infinity) > c.pageIndex))
2336
+ continue;
2337
+ // It fits the head within `layout.maxTopFraction` of the column.
2338
+ const cols = pageSpan ? textCols : citingColumnsOf(f, textCols, c.col.index);
2339
+ if (!cols || cols.length === 0)
2340
+ continue;
2341
+ const slotWidth = pageSpan ? page.contentArea.width : cols[cols.length - 1].bbox.x + cols[cols.length - 1].bbox.width - cols[0].bbox.x;
2342
+ const width = f.widthFraction && f.widthFraction < 1 ? slotWidth * f.widthFraction : slotWidth;
2343
+ const m = measureFloat(id, width);
2344
+ if (!m || m.height + floatGapPx > resolved.layout.maxTopFraction * page.contentArea.height + 0.01)
2345
+ continue;
2346
+ proposals.set(id, { pageIndex: c.pageIndex, columnIndex: c.col.index });
2347
+ }
2348
+ return { proposals, refused };
2349
+ };
2350
+ /** Reserve the floats a freshly opened page owes: those waiting for a
2351
+ * page's bands, then those that head the page citing them (#633). */
2352
+ const flushFloatsIntoPage = (page) => {
2353
+ flushPendingFloatsIntoPage(page);
2354
+ if (citingHeadsByPage.size > 0)
2355
+ placeCitingHeads(page);
2356
+ };
2053
2357
  /** Reserve floats on each freshly opened content page. Passed only to the
2054
2358
  * content-flow column advances — parity / force-blank pages never get it. */
2055
2359
  const onNewPage = (page) => flushFloatsIntoPage(page);
@@ -2098,15 +2402,28 @@ function placeDocumentPass(content, config, cache, options, hints) {
2098
2402
  // its closing columns — whatever position it asked for: a chapter's
2099
2403
  // last page with room under its text beats a page holding one table.
2100
2404
  tryPlacePendingFloatsOnCurrentPage(false, undefined, true);
2405
+ // So does a waiting side box, for room in a side column of the pages
2406
+ // the flow opened after its fence.
2407
+ settlePendingSideBoxes();
2101
2408
  let guard = 0;
2102
- while ((pendingFloats.length > 0 || pendingSideBoxes.length > 0) && guard++ < 1000) {
2103
- const before = floatsPlaced;
2104
- const startPageIndex = cursor.pageIndex;
2105
- do {
2106
- advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2107
- } while (cursor.pageIndex === startPageIndex);
2108
- if (floatsPlaced === before)
2109
- break; // safety: no progress
2409
+ drainingAfterText = true;
2410
+ try {
2411
+ // A page that sets a side box counts as progress like one that sets
2412
+ // a float (#639): up to postext 1.24 the loop stopped after the first
2413
+ // page of boxes and the rest of the queue was never set.
2414
+ while ((pendingFloats.length > 0 || pendingSideBoxes.length > 0) && guard++ < 1000) {
2415
+ const before = floatsPlaced;
2416
+ const boxesBefore = pendingSideBoxes.length;
2417
+ const startPageIndex = cursor.pageIndex;
2418
+ do {
2419
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2420
+ } while (cursor.pageIndex === startPageIndex);
2421
+ if (floatsPlaced === before && pendingSideBoxes.length === boxesBefore)
2422
+ break; // safety: no progress
2423
+ }
2424
+ }
2425
+ finally {
2426
+ drainingAfterText = false;
2110
2427
  }
2111
2428
  };
2112
2429
  /** Boxes that left the flow this pass (side column, float band, fixed),
@@ -2700,6 +3017,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
2700
3017
  * level snaps, or would but for the heading after it). */
2701
3018
  const reserveOpenerBand = (headingCol, h, opener, pending = 0, gridText = true) => {
2702
3019
  const page = doc.pages[cursor.pageIndex];
3020
+ if (opener)
3021
+ openerOwnColumn.set(headingCol, opener);
2703
3022
  for (const otherCol of page.columns) {
2704
3023
  if (otherCol === headingCol)
2705
3024
  continue;
@@ -2729,6 +3048,47 @@ function placeDocumentPass(content, config, cache, options, hints) {
2729
3048
  * the opener, its column, the band's foot, where text right under the
2730
3049
  * opener starts and the float band the column held at its head then. */
2731
3050
  const openerBandHeads = new Map();
3051
+ /** The column each page-span opener was set in (#639). */
3052
+ const openerOwnColumn = new Map();
3053
+ /** The opener's own columns a float took the head of, under the opener:
3054
+ * where the float's band ends, the flow's position right after it. */
3055
+ const openerHeadFloats = new Map();
3056
+ /**
3057
+ * The head of the opener's own column under its band (#639): the first
3058
+ * grid line at or under the opener, level with the heads the band's other
3059
+ * columns were given (`reserveOpenerBand`), while the column holds nothing
3060
+ * after the opener and the band has another text column beside it — a
3061
+ * single text column under its heading has no other head to be level
3062
+ * with, and keeps the rule every column with content has. `undefined`
3063
+ * for any other column, and under `layout.floatsUnderOpener: false`
3064
+ * (postext 1.24 and earlier: the opener's column offered no head slot,
3065
+ * so a float fenced right after the opener landed from column 2).
3066
+ */
3067
+ const openerHeadOf = (col) => {
3068
+ if (!resolved.layout.floatsUnderOpener)
3069
+ return undefined;
3070
+ const opener = openerOwnColumn.get(col);
3071
+ if (!opener || col.blocks[col.blocks.length - 1] !== opener)
3072
+ return undefined;
3073
+ const page = doc.pages[opener.pageIndex];
3074
+ if (!page)
3075
+ return undefined;
3076
+ const beside = bandColumns(page, col.band ?? 0).filter((c) => c !== col && c.kind !== 'side' && c.kind !== 'span');
3077
+ if (beside.length === 0)
3078
+ return undefined;
3079
+ return gridUpOnPage(page, columnCursorY(col));
3080
+ };
3081
+ /** Whether the flow in `col` stands right under a float set at the head
3082
+ * of the opener's own column: the block placed next starts there as at
3083
+ * a column's head, with no space above it. */
3084
+ const underOpenerHeadFloat = (col) => {
3085
+ const foot = openerHeadFloats.get(col);
3086
+ return foot !== undefined && col.blocks[col.blocks.length - 1] === openerOwnColumn.get(col)
3087
+ && Math.abs(columnCursorY(col) - foot) < 0.01;
3088
+ };
3089
+ /** Whether what is placed next in `col` opens it: the column is empty,
3090
+ * or holds an opener and the float set under it at its head. */
3091
+ const atColumnHead = (col) => col.blocks.length === 0 || underOpenerHeadFloat(col);
2732
3092
  /**
2733
3093
  * The room above the first block of a column headed by an opener band,
2734
3094
  * relative to the column's head, or undefined for any other column (or
@@ -2756,7 +3116,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
2756
3116
  if (!head || col.blocks.length > 0 || reservedOf(col).top !== head.floatTop)
2757
3117
  return undefined;
2758
3118
  let target = head.textTop;
2759
- if (level) {
3119
+ // (With a float under the opener in its own column, the first block
3120
+ // there stands under that float: nothing to be level with, #639.)
3121
+ if (level && !openerHeadFloats.has(head.openerCol)) {
2760
3122
  const at = head.openerCol.blocks.indexOf(head.opener);
2761
3123
  const next = at >= 0 ? head.openerCol.blocks.slice(at + 1).find((b) => !b.hidden) : undefined;
2762
3124
  if (next && (next.type === 'heading' || next.type === 'mathDisplay')) {
@@ -2880,7 +3242,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
2880
3242
  /** `rollbackTrailingBlocks` that also drops a rolled-back heading's
2881
3243
  * side-column obstacles: the heading is set again further on. */
2882
3244
  const rollbackHeadings = (col) => {
2883
- const popped = rollbackTrailingBlocks(col, doc.blocks, isFreeHeading);
3245
+ const popped = rollbackTrailingBlocks(col, doc.blocks, (b) => isFreeHeading(b) && !holdsHeadFloat(col, b));
2884
3246
  for (const p of popped) {
2885
3247
  const side = sideObstacleColumn.get(p);
2886
3248
  if (!side)
@@ -3013,6 +3375,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
3013
3375
  const floatHeads = cols
3014
3376
  .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
3015
3377
  .map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
3378
+ // So does one set under a page-span opener at the head of the opener's
3379
+ // own column (#639).
3380
+ for (const c of cols) {
3381
+ if (c.headFloatFoot !== undefined)
3382
+ floatHeads.push(Math.ceil((c.headFloatFoot - top - 0.01) / baselineGrid));
3383
+ }
3016
3384
  bandCapProposals.set(boundaryIndex, {
3017
3385
  kind: 'trailing',
3018
3386
  startContentIndex: bandStart.contentIndex,
@@ -3199,13 +3567,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
3199
3567
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
3200
3568
  * unit; its children never leave it). */
3201
3569
  const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
3570
+ /** Whether `b` is the page-span opener of `col` with a float set under it
3571
+ * at the column's head (#639): the float follows it there, so it closes
3572
+ * no run of headings a block moving on could strand, and is never taken
3573
+ * on to the next column with one. */
3574
+ const holdsHeadFloat = (col, b) => openerHeadFloats.has(col) && openerOwnColumn.get(col) === b;
3202
3575
  /** Free headings closing `col` (its trailing run), 0 unless keep-with-next
3203
3576
  * is on: the headings a block moving on would strand. */
3204
3577
  const trailingHeadingRun = (col) => {
3205
3578
  if (!resolved.headings.keepWithNext || headingMayCloseColumn(col))
3206
3579
  return 0;
3207
3580
  let n = 0;
3208
- for (let j = col.blocks.length - 1; j >= 0 && isFreeHeading(col.blocks[j]); j--)
3581
+ for (let j = col.blocks.length - 1; j >= 0 && isFreeHeading(col.blocks[j]) && !holdsHeadFloat(col, col.blocks[j]); j--)
3209
3582
  n++;
3210
3583
  return n;
3211
3584
  };
@@ -3226,8 +3599,31 @@ function placeDocumentPass(content, config, cache, options, hints) {
3226
3599
  * convert the laid-out box to absolute coordinates at the frame's placed
3227
3600
  * origin, and push frame + children — in that order — to `doc.blocks`
3228
3601
  * and to the column the frame landed in. */
3602
+ /** `:::columns` groups whose sub-columns are too narrow (#634), once a
3603
+ * group. */
3604
+ const columnsNotes = [];
3605
+ const narrowNoted = new Set();
3606
+ const noteNarrowGroups = (result, pageIndex) => {
3607
+ for (const g of result.groups ?? []) {
3608
+ if (!g.narrow || narrowNoted.has(g.contentIndex))
3609
+ continue;
3610
+ narrowNoted.add(g.contentIndex);
3611
+ const raw = contentBlocks[g.contentIndex];
3612
+ columnsNotes.push({
3613
+ kind: 'columnsTooNarrow',
3614
+ columns: g.columns,
3615
+ widthPx: g.columnWidth,
3616
+ ...(raw ? { sourceStart: raw.sourceStart + bodyOffset, sourceEnd: raw.sourceEnd + bodyOffset } : {}),
3617
+ pageIndex,
3618
+ });
3619
+ }
3620
+ for (const u of result.units)
3621
+ if (u.kind === 'box')
3622
+ noteNarrowGroups(u.box.result, pageIndex);
3623
+ };
3229
3624
  const commitCallout = (result, startIdx, plan, col, range) => {
3230
3625
  const frame = result.frame;
3626
+ noteNarrowGroups(result, cursor.pageIndex);
3231
3627
  stampCalloutSource(frame, startIdx, plan, range);
3232
3628
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
3233
3629
  doc.blocks.push(frame);
@@ -3278,13 +3674,19 @@ function placeDocumentPass(content, config, cache, options, hints) {
3278
3674
  });
3279
3675
  const layoutRange = (from, to, width, frameId, continuation, mirrored = false) => {
3280
3676
  let n = 0;
3677
+ // A fragment that opens inside a `:::columns` group starts at its
3678
+ // opening marker and one that ends inside it at its closing marker:
3679
+ // the group sets the part between the cut's positions (#634).
3680
+ const fromChild = from.group ? from.group.open : from.child;
3681
+ const closeOfGroup = (open) => groups.find(([gs]) => gs === open)?.[1] ?? children.length - 1;
3281
3682
  // A cut inside child `to.child` includes that child (its first
3282
3683
  // `to.line` lines); a cut at a child's head excludes it.
3283
- const toChild = to.line > 0 ? to.child + 1 : to.child;
3684
+ const toChild = to.group ? closeOfGroup(to.group.open) + 1 : to.line > 0 ? to.child + 1 : to.child;
3685
+ const inSlice = (pos) => pos.map((p) => ({ ...p, child: p.child - fromChild }));
3284
3686
  // Nested boxes still open at `from` (their closing markers leading
3285
3687
  // the range close them): the fragment opens inside them.
3286
3688
  const open = [];
3287
- for (let k = 0; k < from.child; k++) {
3689
+ for (let k = 0; k < fromChild; k++) {
3288
3690
  const c = children[k];
3289
3691
  if (c.containerName !== 'callout')
3290
3692
  continue;
@@ -3293,7 +3695,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
3293
3695
  else if (c.type === 'containerEnd')
3294
3696
  open.pop();
3295
3697
  }
3296
- for (let k = from.child; k < children.length && open.length > 0; k++) {
3698
+ for (let k = fromChild; k < children.length && open.length > 0; k++) {
3297
3699
  const c = children[k];
3298
3700
  if (c.type !== 'containerEnd' || c.containerId !== open[open.length - 1].block.containerId)
3299
3701
  break;
@@ -3305,19 +3707,25 @@ function placeDocumentPass(content, config, cache, options, hints) {
3305
3707
  ...(contentBlocks[startIdx]?.direction ? { direction: contentBlocks[startIdx].direction } : {}),
3306
3708
  continuation,
3307
3709
  // Any range short of the box's end is a fragment that goes on.
3308
- ...(to.child < children.length || to.line > 0 ? { continues: true } : {}),
3309
- children: children.slice(from.child, toChild),
3310
- childStartIdx: startIdx + 1 + from.child,
3710
+ ...(to.child < children.length || to.line > 0 || to.group ? { continues: true } : {}),
3711
+ children: children.slice(fromChild, toChild),
3712
+ childStartIdx: startIdx + 1 + fromChild,
3311
3713
  width,
3312
- ctx: measureCtx,
3714
+ // A main-flow group's box sets its blocks as the text around it is
3715
+ // set: a styled section's or a part's body (#634).
3716
+ ctx: isFlowColumnsStyle(style.id)
3717
+ ? (partPlan.byBlock[startIdx] ? partMeasureCtx : sectionMeasureCtx(sectionPlan.byBlock[startIdx]))
3718
+ : measureCtx,
3313
3719
  resolved: calloutResolved,
3314
3720
  containerId: plan.containerId,
3315
3721
  frameId,
3316
3722
  nextChildId: () => `${frameId}-c${n++}`,
3317
3723
  paragraphContainers,
3318
- ...(from.line > 0 ? { lineFrom: from.line } : {}),
3319
- ...(from.line > 0 && from.widths ? { lineWidths: from.widths } : {}),
3320
- ...(to.line > 0 ? { lineTo: to.line } : {}),
3724
+ ...(from.line > 0 && !from.group ? { lineFrom: from.line } : {}),
3725
+ ...(from.line > 0 && !from.group && from.widths ? { lineWidths: from.widths } : {}),
3726
+ ...(to.line > 0 && !to.group ? { lineTo: to.line } : {}),
3727
+ ...(from.group ? { groupFrom: inSlice(from.group.pos) } : {}),
3728
+ ...(to.group ? { groupTo: inSlice(to.group.pos) } : {}),
3321
3729
  ...(open.length > 0 ? { openNested: open } : {}),
3322
3730
  mirrored,
3323
3731
  stripBlock: calloutStripBlock,
@@ -3451,7 +3859,68 @@ function placeDocumentPass(content, config, cache, options, hints) {
3451
3859
  });
3452
3860
  };
3453
3861
  collect(full.units, minLines, 0);
3454
- const insideGroup = (cut) => L.groups.some(([gs, ge]) => (cut.line === 0 ? gs < cut.child && cut.child <= ge : gs < cut.child && cut.child < ge));
3862
+ // Inside a `:::columns` group of the box (#634): the cut the group
3863
+ // offers for the room, where each of its sub-columns (each stream, in
3864
+ // a parallel group) ends when the galley fills them in turn; at least
3865
+ // one stream goes on and one moves on. A group of a nested box is
3866
+ // never cut, nor any group under 1.24's rules (`layout.flowColumns`).
3867
+ if (resolved.layout.flowColumns) {
3868
+ const base = from.group ? from.group.open : from.child;
3869
+ const childMin = Math.min(Math.max(1, minLines), resolved.layout.boxChildSplitMinLines);
3870
+ for (const g of full.groups ?? []) {
3871
+ const h = roomPx - tail - g.top;
3872
+ if (h <= 0.5)
3873
+ continue;
3874
+ const reopened = from.group?.open === g.open + base ? from.group : undefined;
3875
+ const pos = [];
3876
+ let moved = false;
3877
+ let left = false;
3878
+ let used = 0;
3879
+ g.streams.forEach((items, s) => {
3880
+ const end = { child: (g.ends[s] ?? g.close) + base, line: 0 };
3881
+ if (items.length === 0) {
3882
+ pos.push(end);
3883
+ return;
3884
+ }
3885
+ const breaks = galleyBreaks(items, childMin);
3886
+ const fill = fillGalley(items, breaks, g.flow === 'snake' ? g.columns : 1, h);
3887
+ if (fill.end) {
3888
+ moved = true;
3889
+ used = Math.max(used, fill.used);
3890
+ pos.push(end);
3891
+ return;
3892
+ }
3893
+ left = true;
3894
+ if (fill.cuts.length === 0) {
3895
+ // This stream takes nothing in the fragment: it stays where it is.
3896
+ pos.push(reopened?.pos[s] ?? { child: items[0].child + base, line: 0 });
3897
+ return;
3898
+ }
3899
+ moved = true;
3900
+ used = Math.max(used, fill.used);
3901
+ const b = breaks[fill.cuts[fill.cuts.length - 1]];
3902
+ const it = items[b.item];
3903
+ if (b.line === 0) {
3904
+ pos.push({ child: it.child + base, line: 0 });
3905
+ return;
3906
+ }
3907
+ // The sub-column widths the paragraph's lines are set at.
3908
+ const before = it.lineBase > 0 ? reopened?.pos[s]?.widths : undefined;
3909
+ const lastW = before?.[before.length - 1];
3910
+ const widths = !before || before.length === 0
3911
+ ? [{ fromLine: 0, width: g.columnWidth }]
3912
+ : Math.abs(lastW.width - g.columnWidth) <= 0.5 ? before : [...before, { fromLine: it.lineBase, width: g.columnWidth }];
3913
+ pos.push({ child: it.child + base, line: it.lineBase + b.line, widths });
3914
+ });
3915
+ if (!moved || !left)
3916
+ continue;
3917
+ candidates.push({
3918
+ cut: { child: Math.min(...pos.map((p) => p.child)), line: 0, group: { open: g.open + base, pos } },
3919
+ bottom: g.top + used,
3920
+ });
3921
+ }
3922
+ }
3923
+ const insideGroup = (cut) => !cut.group && L.groups.some(([gs, ge]) => (cut.line === 0 ? gs < cut.child && cut.child <= ge : gs < cut.child && cut.child < ge));
3455
3924
  const viable = candidates
3456
3925
  .filter((c) => !insideGroup(c.cut))
3457
3926
  .sort((a, b) => b.bottom - a.bottom);
@@ -4152,9 +4621,27 @@ function placeDocumentPass(content, config, cache, options, hints) {
4152
4621
  const cutBy = asideCutBy.get(side);
4153
4622
  if (cutBy !== undefined)
4154
4623
  captionUnderProposals.add(cutBy);
4624
+ // Taller than an empty side column: set anyway, and reported (#639).
4625
+ const overflowPx = y - used + result.totalHeight - side.availableHeight;
4626
+ if (overflowPx > 0.5) {
4627
+ (doc.warnings ??= []).push({
4628
+ kind: 'calloutOverflow',
4629
+ pageIndex: page.index,
4630
+ columnIndex: side.index,
4631
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
4632
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
4633
+ overflowPx,
4634
+ });
4635
+ }
4155
4636
  }
4156
4637
  const frame = result.frame;
4157
4638
  stampCalloutSource(frame, startIdx, plan);
4639
+ if (drainingAfterText) {
4640
+ setAfterText.set(frame, {
4641
+ ...(frame.sourceStart !== undefined ? { sourceStart: frame.sourceStart } : {}),
4642
+ ...(frame.sourceEnd !== undefined ? { sourceEnd: frame.sourceEnd } : {}),
4643
+ });
4644
+ }
4158
4645
  frame.pageIndex = page.index;
4159
4646
  frame.columnIndex = side.index;
4160
4647
  offsetCalloutToAbsolute(result, side.bbox.x, y);
@@ -4194,7 +4681,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4194
4681
  return true;
4195
4682
  }
4196
4683
  if (!trySideBox(page, side, { startIdx, plan, style }, refY, 'strict')) {
4197
- pendingSideBoxes.push({ startIdx, plan, style });
4684
+ pendingSideBoxes.push({ startIdx, plan, style, pageIndex: page.index });
4198
4685
  }
4199
4686
  return true;
4200
4687
  };
@@ -4263,7 +4750,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4263
4750
  placed = laterSide !== undefined && trySideBox(later, laterSide, w.box, undefined, 'fresh');
4264
4751
  }
4265
4752
  if (!placed)
4266
- pendingSideBoxes.push(w.box);
4753
+ pendingSideBoxes.push({ ...w.box, pageIndex: cursor.pageIndex });
4267
4754
  }
4268
4755
  };
4269
4756
  /** Set the waiting side boxes in the side column of a freshly opened
@@ -4280,6 +4767,31 @@ function placeDocumentPass(content, config, cache, options, hints) {
4280
4767
  pendingSideBoxes.shift();
4281
4768
  }
4282
4769
  };
4770
+ /** Where the text ends (#639): before a waiting side box takes a page
4771
+ * after it, the side columns of the pages the flow opened after its
4772
+ * fence that still hold it whole, in order (the page of the fence had no
4773
+ * room at or under it). A try that sets nothing gives its block id back,
4774
+ * so the ids after it stay what they were. */
4775
+ const settlePendingSideBoxes = () => {
4776
+ while (pendingSideBoxes.length > 0) {
4777
+ const box = pendingSideBoxes[0];
4778
+ let placed = false;
4779
+ for (let p = box.pageIndex + 1; p <= cursor.pageIndex && !placed; p++) {
4780
+ const page = doc.pages[p];
4781
+ for (const side of sideColumns(page)) {
4782
+ const idBefore = blockIdCounter;
4783
+ if (trySideBox(page, side, box, undefined, 'strict')) {
4784
+ placed = true;
4785
+ break;
4786
+ }
4787
+ blockIdCounter = idBefore;
4788
+ }
4789
+ }
4790
+ if (!placed)
4791
+ return;
4792
+ pendingSideBoxes.shift();
4793
+ }
4794
+ };
4283
4795
  /**
4284
4796
  * Place a `:::callout` inline at the current column width as one atomic
4285
4797
  * unit: the frame block followed by its children in the same column. The
@@ -4332,7 +4844,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4332
4844
  note('fewLines');
4333
4845
  return false;
4334
4846
  }
4335
- const spacing = col.blocks.length === 0 ? 0 : pendingSpacing;
4847
+ const spacing = atColumnHead(col) ? 0 : pendingSpacing;
4336
4848
  if (result.totalHeight > col.availableHeight - spacing + 0.01 && col.blocks.length > 0 && attempt < 4) {
4337
4849
  if (attempt === 0)
4338
4850
  note('moved');
@@ -4378,6 +4890,162 @@ function placeDocumentPass(content, config, cache, options, hints) {
4378
4890
  return true;
4379
4891
  }
4380
4892
  };
4893
+ /**
4894
+ * An inline table (`::resource` placed `here`) that does not fit the room
4895
+ * left in its column (#634), by its style's `overflow` when
4896
+ * `splitInline` is on: `'split'` cuts it between rows with the rules of a
4897
+ * floated table (`chooseTableSlice`: breakable edges, group heads, the
4898
+ * closing slice's three rows, header repeat, caption suffix, marker,
4899
+ * note on the last slice), the first slice keeping the float gap above
4900
+ * it and at least the header and {@link MIN_HEAD_ROWS} rows (fewer, and
4901
+ * the whole table starts in the next column, as before); every
4902
+ * continuation opens at the head of the next column, laid out at that
4903
+ * column's width, before the text after the table. `'clip'` keeps the
4904
+ * leading rows of a table taller than a column that fit the head of
4905
+ * one, `'hide'` leaves such a table out. Returns `'whole'` when the
4906
+ * table is placed as one block by the caller (it fits, or the rule does
4907
+ * not apply), `'dropped'`, or `'placed'`: every slice is placed and the
4908
+ * cursor sits in the column of the last one, which the caller snaps
4909
+ * under. `blk` is the first slice's block, filled in here.
4910
+ */
4911
+ const MIN_HEAD_ROWS = 2;
4912
+ const placeInlineTableSlices = (raw, blockIdx, blk, ctx, styleOverride, resource, wholeHeight, spacingBefore) => {
4913
+ const style = pickTableStyle(resolved, resource.table?.styleId);
4914
+ if (!style.splitInline || pageIsVertical(doc.pages[cursor.pageIndex]))
4915
+ return 'whole';
4916
+ const rowCount = resource.table?.model.rows.length ?? 0;
4917
+ const first = currentColumn(doc, cursor);
4918
+ const room = first.availableHeight - (first.blocks.length === 0 ? 0 : spacingBefore);
4919
+ if (wholeHeight <= room + FIT_EPS)
4920
+ return 'whole';
4921
+ const overflow = style.overflow;
4922
+ // `'clip'` and `'hide'` concern a table no column holds; a shorter one
4923
+ // moves whole to the next column, as before.
4924
+ const taller = wholeHeight > contentArea.height + FIT_EPS;
4925
+ if (overflow !== 'split' && !taller)
4926
+ return 'whole';
4927
+ if (overflow === 'hide')
4928
+ return 'dropped';
4929
+ const measureAt = (width, slice) => measureContentBlock(raw, blockIdx, width, ctx, { styleOverride, ...(slice ? { tableSlice: slice } : {}) });
4930
+ const metricsMemo = new Map();
4931
+ const metricsAt = (width) => {
4932
+ const key = width.toFixed(2);
4933
+ let m = metricsMemo.get(key);
4934
+ if (m === undefined)
4935
+ metricsMemo.set(key, (m = measureAt(width)?.tableRows ?? null));
4936
+ return m;
4937
+ };
4938
+ const firstMetrics = metricsAt(first.bbox.width);
4939
+ if (!firstMetrics || rowCount === 0)
4940
+ return 'whole';
4941
+ const headerRows = firstMetrics.headerRowCount;
4942
+ // Too few rows to leave a head and a tail worth the cut.
4943
+ if (overflow === 'split' && rowCount - headerRows < MIN_HEAD_ROWS + MIN_TAIL_ROWS)
4944
+ return 'whole';
4945
+ /** The rows a slice from `startRow` must carry at least, and its height
4946
+ * at `width` (caption and marker included): what a page opened for it
4947
+ * keeps clear of the floats it flushes (EF-160). */
4948
+ const floorOf = (startRow) => (startRow === 0
4949
+ ? Math.min(rowCount, headerRows + (overflow === 'split' ? MIN_HEAD_ROWS : 1))
4950
+ : Math.max(startRow, headerRows) + 1);
4951
+ const minHeightAt = (width, startRow) => {
4952
+ const floor = floorOf(startRow);
4953
+ const probe = measureAt(width, { startRow, endRow: floor, continues: floor < rowCount && overflow === 'split' });
4954
+ return probe?.measured.totalHeight ?? 0;
4955
+ };
4956
+ let startRow = 0;
4957
+ let part = 0;
4958
+ let moves = 0;
4959
+ let block = blk;
4960
+ for (;;) {
4961
+ enterBand(blockIdx, part);
4962
+ let col = currentColumn(doc, cursor);
4963
+ if (col.kind === 'span') {
4964
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(minHeightAt(col.bbox.width, startRow)));
4965
+ continue;
4966
+ }
4967
+ const width = col.bbox.width;
4968
+ const metrics = metricsAt(width) ?? firstMetrics;
4969
+ const spacing = part === 0 && col.blocks.length > 0 ? spacingBefore : 0;
4970
+ const avail = col.availableHeight - spacing;
4971
+ const restSlice = startRow > 0 ? { startRow, endRow: rowCount, continues: false } : undefined;
4972
+ const rest = measureAt(width, restSlice);
4973
+ if (!rest?.resourceBlock)
4974
+ return part === 0 ? 'whole' : 'placed';
4975
+ let placed = rest;
4976
+ let slice = null;
4977
+ if (rest.measured.totalHeight > avail + FIT_EPS) {
4978
+ const atHead = col.blocks.length === 0;
4979
+ // `'clip'` keeps the rows the head of a column holds.
4980
+ if (overflow === 'clip' && !atHead && moves < 4) {
4981
+ moves++;
4982
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(minHeightAt(width, startRow)));
4983
+ continue;
4984
+ }
4985
+ const firstBody = startRow > 0 ? Math.max(startRow, headerRows) : headerRows;
4986
+ const probe = measureAt(width, { startRow, endRow: firstBody + 1, continues: true });
4987
+ const overhead = probe?.resourceBlock ? probe.measured.totalHeight - probe.resourceBlock.bodyRect.height : 0;
4988
+ // An empty column of full height (or a run of short ones behind)
4989
+ // takes the smallest slice even when it overflows: a fresh page
4990
+ // always progresses.
4991
+ const force = atHead && (col.bbox.height >= contentArea.height - baselineGrid || moves >= 4);
4992
+ slice = chooseTableSlice(metrics, startRow, avail - overhead, {
4993
+ floor: floorOf(startRow),
4994
+ force,
4995
+ split: overflow === 'split',
4996
+ fits: (s) => (measureAt(width, s)?.measured.totalHeight ?? Infinity) <= avail + FIT_EPS,
4997
+ });
4998
+ if (!slice) {
4999
+ // Not even the head rows fit: the (rest of the) table starts in
5000
+ // the next column.
5001
+ moves++;
5002
+ pendingSpacing = 0;
5003
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(minHeightAt(width, startRow)));
5004
+ continue;
5005
+ }
5006
+ const m = measureAt(width, slice);
5007
+ if (!m?.resourceBlock)
5008
+ return part === 0 ? 'whole' : 'placed';
5009
+ placed = m;
5010
+ }
5011
+ // The slice's block: the first one is the caller's.
5012
+ if (part > 0) {
5013
+ block = createVDTBlock(`block-${blockIdCounter++}`, 'resource', blk.fontString, blk.color, blk.textAlign);
5014
+ block.contentIndex = blockIdx;
5015
+ stampBlockExtras(block, raw);
5016
+ block.dirty = false;
5017
+ block.snappedToGrid = false;
5018
+ block.sourceStart = blk.sourceStart;
5019
+ block.sourceEnd = blk.sourceEnd;
5020
+ }
5021
+ const rb = placed.resourceBlock;
5022
+ const height = placed.measured.totalHeight;
5023
+ block.resourceBlock = rb;
5024
+ block.lines = [{
5025
+ text: '',
5026
+ bbox: { x: 0, y: 0, width: rb.bodyRect.width, height },
5027
+ baseline: 0,
5028
+ hyphenated: false,
5029
+ segments: [],
5030
+ isLastLine: true,
5031
+ }];
5032
+ col = currentColumn(doc, cursor);
5033
+ if (spacing > 0)
5034
+ col.availableHeight -= spacing;
5035
+ placeBlockInColumn(block, height, col, cursor);
5036
+ offsetResourceBlockToAbsolute(rb, block.bbox.x, block.bbox.y);
5037
+ doc.blocks.push(block);
5038
+ enterBand(blockIdx, part);
5039
+ if (!slice?.continues)
5040
+ return 'placed';
5041
+ // The rest opens the next column, with no gap above it.
5042
+ startRow = slice.endRow;
5043
+ part++;
5044
+ moves = 0;
5045
+ pendingSpacing = 0;
5046
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(minHeightAt(width, startRow)));
5047
+ }
5048
+ };
4381
5049
  const placeCalloutInline = (startIdx, plan) => {
4382
5050
  const style = pickCalloutStyle(calloutStyles, plan.attrs.type);
4383
5051
  const firstFrameId = `block-${blockIdCounter++}`;
@@ -4460,7 +5128,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4460
5128
  const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
4461
5129
  ? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
4462
5130
  : 0;
4463
- const spacing = (curCol.blocks.length === 0 ? 0 : Math.max(pendingBeforeSelfSnapping(startIdx), result.marginTopPx)) + balanceBefore;
5131
+ const spacing = (atColumnHead(curCol) ? 0 : Math.max(pendingBeforeSelfSnapping(startIdx), result.marginTopPx)) + balanceBefore;
4464
5132
  const roomPx = curCol.availableHeight - spacing;
4465
5133
  let fragment = null;
4466
5134
  if (result.totalHeight > roomPx + 0.01) {
@@ -4542,7 +5210,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4542
5210
  const to = fragment ? fragment.to : L.end;
4543
5211
  if (part > 0 || fragment)
4544
5212
  markFragment(placed, part, fragment !== null);
4545
- const spacingBefore = (curCol.blocks.length === 0 ? 0 : Math.max(pendingBeforeSelfSnapping(startIdx), placed.marginTopPx)) + balanceBefore;
5213
+ const spacingBefore = (atColumnHead(curCol) ? 0 : Math.max(pendingBeforeSelfSnapping(startIdx), placed.marginTopPx)) + balanceBefore;
4546
5214
  if (balanceBefore > 0) {
4547
5215
  addBalanceExtra(curCol, balanceBefore);
4548
5216
  placed.frame.balancing = { levers: ['trailingCallout'], spaceAbove: balanceBefore };
@@ -5171,14 +5839,14 @@ function placeDocumentPass(content, config, cache, options, hints) {
5171
5839
  const figureGrowPx = rawBlock.type === 'resourceBlock' ? balanceExtraPx?.get(flexFigureKey(blockIdx)) ?? 0 : 0;
5172
5840
  const figureDelta = figureGrowPx > 0 ? { figureHeightDelta: figureGrowPx } : {};
5173
5841
  let measuredBlock = tryLoose
5174
- ? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome)
5842
+ ? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome, looseOffGrid)
5175
5843
  : measureContentBlock(rawBlock, blockIdx, measureWidth, blockMeasureCtx, { styleOverride, ...upright, ...figureDelta });
5176
5844
  // An inline picture with a safe area a little too tall for the room
5177
5845
  // left in its column is cropped within its safe area to stay there
5178
5846
  // rather than move on and leave the column short (#442).
5179
5847
  if (measuredBlock?.kind.vdtType === 'resource' && measuredBlock.resourceBlock?.bodyFlex) {
5180
5848
  const flex = measuredBlock.resourceBlock.bodyFlex;
5181
- const room = col.availableHeight - (col.blocks.length === 0 ? 0 : Math.max(pendingSpacing, floatGapPx));
5849
+ const room = col.availableHeight - (atColumnHead(col) ? 0 : Math.max(pendingSpacing, floatGapPx));
5182
5850
  const over = measuredBlock.measured.totalHeight - room;
5183
5851
  if (over > 0.01 && room > 0 && over <= flex.shrink + 0.01) {
5184
5852
  const cropped = measureContentBlock(rawBlock, blockIdx, measureWidth, blockMeasureCtx, {
@@ -5194,8 +5862,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
5194
5862
  // the page empty. Never below MIN_INLINE_FIGURE_SCALE of its size.
5195
5863
  if (measuredBlock?.kind.vdtType === 'resource' && resolved.layout.fitFiguresToPage) {
5196
5864
  const rb = measuredBlock.resourceBlock;
5197
- const room = col.availableHeight - (col.blocks.length === 0 ? 0 : Math.max(pendingSpacing, floatGapPx));
5198
- if (rb && !rb.table && !rb.rotation && rb.bodyRect.height > 0 && measuredBlock.measured.totalHeight > room && room > 0) {
5865
+ const room = col.availableHeight - (atColumnHead(col) ? 0 : Math.max(pendingSpacing, floatGapPx));
5866
+ if (rb && !rb.table && !rb.rotation && rb.bodyRect.height > 0 && measuredBlock.measured.totalHeight > room + FIT_EPS && room > 0) {
5199
5867
  let width = rb.bodyRect.width;
5200
5868
  for (let attempt = 0; attempt < 3; attempt++) {
5201
5869
  const current = measuredBlock.resourceBlock;
@@ -5210,12 +5878,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
5210
5878
  if (!smaller)
5211
5879
  break;
5212
5880
  measuredBlock = smaller;
5213
- if (smaller.measured.totalHeight <= room)
5881
+ if (smaller.measured.totalHeight <= room + FIT_EPS)
5214
5882
  break;
5215
5883
  }
5216
5884
  // Still too tall at the smallest acceptable size: back to full size
5217
5885
  // and on to the next column, as without the option.
5218
- if (measuredBlock.measured.totalHeight > room) {
5886
+ if (measuredBlock.measured.totalHeight > room + FIT_EPS) {
5219
5887
  measuredBlock = measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, { styleOverride });
5220
5888
  }
5221
5889
  }
@@ -5308,56 +5976,67 @@ function placeDocumentPass(content, config, cache, options, hints) {
5308
5976
  // float would, unless the block before asked for more. A wrapped one
5309
5977
  // stands level with the next line's top instead.
5310
5978
  const spacingBefore = wraps ? pendingSpacing : Math.max(pendingSpacing, floatGapPx);
5311
- const columnBefore = col;
5312
- enterBand(blockIdx, 0);
5313
- placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(groupHeight));
5314
- enterBand(blockIdx, 0);
5315
- // Too tall for the room left in its column, a wrapped picture moved
5316
- // on with its anchor: say so.
5317
- if (wraps && currentColumn(doc, cursor) !== columnBefore && columnBefore.blocks.length > 0) {
5318
- textWrapNotes.push({
5319
- kind: 'textWrap', reason: 'moved', resourceId: kind.resource.id,
5320
- sourceStart: rawBlock.sourceStart + bodyOffset, sourceEnd: rawBlock.sourceEnd + bodyOffset, pageIndex: cursor.pageIndex,
5321
- });
5322
- }
5323
- // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
5324
- // resource's own caption/table lines live on `resourceBlock` and must be
5325
- // offset to absolute page coordinates here using the placed bbox origin.
5326
- offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
5327
- const grown = figureGrowPx > 0 ? resourceBlock.bodyFlex?.delta ?? 0 : 0;
5328
- if (grown > 0.01) {
5329
- blk.balancing = { levers: ['flexFigure'], spaceAbove: 0, bodyGrowth: grown };
5330
- addBalanceExtra(currentColumn(doc, cursor), grown);
5331
- }
5332
- doc.blocks.push(blk);
5333
- // A wrapped picture (#627): the flow goes on under it and its gap, on
5334
- // a grid line, unless the next block takes the band back to run
5335
- // beside it (see `pendingWrap`).
5336
- if (wraps && wrapSpec) {
5337
- const rCol = currentColumn(doc, cursor);
5338
- const top = blk.bbox.y;
5339
- const bottomRel = Math.ceil((top - rCol.bbox.y + groupHeight + wrapGap - 0.01) / baselineGrid) * baselineGrid;
5340
- rCol.availableHeight = Math.max(0, Math.min(rCol.availableHeight, rCol.bbox.height - bottomRel));
5341
- pendingSpacing = 0;
5342
- inlineGapOwed = null;
5343
- const exWidth = wrapExclusionWidth(rCol.bbox.width, rCol.bbox.width * wrapSpec.width, wrapGap);
5344
- pendingWrap = {
5345
- col: rCol,
5346
- blocks: rCol.blocks.length,
5347
- cursorY: columnCursorY(rCol),
5348
- ex: {
5349
- x: wrapSpec.side === 'left' ? rCol.bbox.x : rCol.bbox.x + rCol.bbox.width - exWidth,
5350
- y: top,
5351
- width: exWidth,
5352
- height: rCol.bbox.y + bottomRel - top,
5353
- side: wrapSpec.side,
5354
- gap: exWidth - rCol.bbox.width * wrapSpec.width,
5355
- ownerId: blk.id,
5356
- },
5357
- };
5979
+ // An inline table that does not fit the room left in its column is
5980
+ // cut between rows (#634), by the rules of a floated one.
5981
+ const sliced = !wraps && !resourceBlock.rotation && resourceBlock.table && kind.resource
5982
+ ? placeInlineTableSlices(rawBlock, blockIdx, blk, blockMeasureCtx, styleOverride, kind.resource, groupHeight, spacingBefore)
5983
+ : 'whole';
5984
+ if (sliced === 'dropped') {
5358
5985
  flushPendingNumberingAtBoundary();
5359
5986
  continue;
5360
5987
  }
5988
+ const columnBefore = col;
5989
+ if (sliced === 'whole') {
5990
+ enterBand(blockIdx, 0);
5991
+ placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(groupHeight));
5992
+ enterBand(blockIdx, 0);
5993
+ // Too tall for the room left in its column, a wrapped picture moved
5994
+ // on with its anchor: say so.
5995
+ if (wraps && currentColumn(doc, cursor) !== columnBefore && columnBefore.blocks.length > 0) {
5996
+ textWrapNotes.push({
5997
+ kind: 'textWrap', reason: 'moved', resourceId: kind.resource.id,
5998
+ sourceStart: rawBlock.sourceStart + bodyOffset, sourceEnd: rawBlock.sourceEnd + bodyOffset, pageIndex: cursor.pageIndex,
5999
+ });
6000
+ }
6001
+ // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
6002
+ // resource's own caption/table lines live on `resourceBlock` and must be
6003
+ // offset to absolute page coordinates here using the placed bbox origin.
6004
+ offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
6005
+ const grown = figureGrowPx > 0 ? resourceBlock.bodyFlex?.delta ?? 0 : 0;
6006
+ if (grown > 0.01) {
6007
+ blk.balancing = { levers: ['flexFigure'], spaceAbove: 0, bodyGrowth: grown };
6008
+ addBalanceExtra(currentColumn(doc, cursor), grown);
6009
+ }
6010
+ doc.blocks.push(blk);
6011
+ // A wrapped picture (#627): the flow goes on under it and its gap, on
6012
+ // a grid line, unless the next block takes the band back to run
6013
+ // beside it (see `pendingWrap`).
6014
+ if (wraps && wrapSpec) {
6015
+ const rCol = currentColumn(doc, cursor);
6016
+ const top = blk.bbox.y;
6017
+ const bottomRel = Math.ceil((top - rCol.bbox.y + groupHeight + wrapGap - 0.01) / baselineGrid) * baselineGrid;
6018
+ rCol.availableHeight = Math.max(0, Math.min(rCol.availableHeight, rCol.bbox.height - bottomRel));
6019
+ pendingSpacing = 0;
6020
+ inlineGapOwed = null;
6021
+ const exWidth = wrapExclusionWidth(rCol.bbox.width, rCol.bbox.width * wrapSpec.width, wrapGap);
6022
+ pendingWrap = {
6023
+ col: rCol,
6024
+ blocks: rCol.blocks.length,
6025
+ cursorY: columnCursorY(rCol),
6026
+ ex: {
6027
+ x: wrapSpec.side === 'left' ? rCol.bbox.x : rCol.bbox.x + rCol.bbox.width - exWidth,
6028
+ y: top,
6029
+ width: exWidth,
6030
+ height: rCol.bbox.y + bottomRel - top,
6031
+ side: wrapSpec.side,
6032
+ gap: exWidth - rCol.bbox.width * wrapSpec.width,
6033
+ ownerId: blk.id,
6034
+ },
6035
+ };
6036
+ flushPendingNumberingAtBoundary();
6037
+ continue;
6038
+ }
6039
+ }
5361
6040
  // Snap the flow position after the resource to the baseline grid (the
5362
6041
  // group height is arbitrary), baking in at least marginBottom — same
5363
6042
  // convention as snapped headings — so the following text lands back on
@@ -5625,7 +6304,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
5625
6304
  /** Column balancing: the levers applied to this placement, stamped
5626
6305
  * on the block it places (`VDTBlock.balancing`). */
5627
6306
  let balancing;
5628
- if (!isFirstInColumn) {
6307
+ // (Right under a float at the head of the opener's own column, #639,
6308
+ // the block starts as at a column's head: no space above it.)
6309
+ if (!isFirstInColumn && !underOpenerHeadFloat(curCol)) {
5629
6310
  spacingBefore = pendingSpacing;
5630
6311
  if (vdtType === 'heading' || vdtType === 'mathDisplay') {
5631
6312
  spacingBefore = Math.max(partIndex === 0 ? pendingBeforeSelfSnapping(blockIdx) : spacingBefore, style.marginTopPx);
@@ -5860,7 +6541,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
5860
6541
  // the tail follows the list naturally in the next column.
5861
6542
  if (endsWithColon
5862
6543
  && partIndex === 0
5863
- && totalRemainHeight <= effectiveAvailable
6544
+ // The tolerance of the fit check below (#635): a paragraph that
6545
+ // fits by a hair of drift is one this rule reads too.
6546
+ && totalRemainHeight <= effectiveAvailable + FIT_EPS
5864
6547
  && curCol.blocks.length > 0) {
5865
6548
  const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
5866
6549
  const paragraphBottom = usedHeight + totalRemainHeight;
@@ -5884,7 +6567,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5884
6567
  // there, by the rules that split it — a two-line item the orphan
5885
6568
  // and widow rules keep whole needs both. `'line'` keeps the 1.4
5886
6569
  // check, one line.
5887
- if (availableAfter < minSpaceForList
6570
+ if (availableAfter < minSpaceForList - FIT_EPS
5888
6571
  || (resolved.bodyText.colonListRoom === 'item'
5889
6572
  && !firstListItemStarts(blockIdx, availableAfter - effectiveGap, curCol.bbox.width, blockMeasureCtx))) {
5890
6573
  const effectiveWidowMin = resolved.bodyText.avoidWidows
@@ -6086,7 +6769,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
6086
6769
  // A `:::space` between the heading and its text needs room too.
6087
6770
  const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx
6088
6771
  + spaceLinesAfter(contentBlocks, blockIdx) * baselineGrid;
6089
- if (remainAfterHeading < minSpaceAfter) {
6772
+ // The room is a difference of grid sums and the need a product:
6773
+ // a head whose text ends on the last line of the column is short
6774
+ // by floating-point drift alone (#635), and stays.
6775
+ if (remainAfterHeading < minSpaceAfter - FIT_EPS) {
6090
6776
  if (curCol.blocks.length === 0)
6091
6777
  shortColumnMoves++;
6092
6778
  // Roll back any immediately-preceding heading blocks in this
@@ -6189,7 +6875,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
6189
6875
  // its margin and grid snap would carry the block past the foot: it
6190
6876
  // takes the rest of the column instead, as a design reaching past
6191
6877
  // the foot does (the text after it opens the next column or page).
6192
- if (vdtType === 'heading' && effectiveRemainHeight > totalRemainHeight && h > curCol.availableHeight) {
6878
+ if (vdtType === 'heading' && effectiveRemainHeight > totalRemainHeight && h > curCol.availableHeight + FIT_EPS) {
6193
6879
  h = Math.max(effectiveRemainHeight, curCol.availableHeight);
6194
6880
  }
6195
6881
  placeBlockInColumn(blk, h, curCol, cursor);
@@ -6581,6 +7267,25 @@ function placeDocumentPass(content, config, cache, options, hints) {
6581
7267
  // End of the document: level the closing band and place any floats still
6582
7268
  // pending (referenced on the last page) on pages appended after it.
6583
7269
  closeFlowSegment(contentBlocks.length);
7270
+ // Nothing leaves the layout unseen (#639): a box or a float still in a
7271
+ // queue here is on no page, and the build says so.
7272
+ const unplaced = [];
7273
+ const sourceOf = (from, to) => {
7274
+ const first = contentBlocks[from];
7275
+ const last = contentBlocks[to];
7276
+ return first && last ? { sourceStart: first.sourceStart + bodyOffset, sourceEnd: last.sourceEnd + bodyOffset } : {};
7277
+ };
7278
+ for (const box of [...awaitingSideBoxes.map((w) => w.box), ...pendingSideBoxes]) {
7279
+ unplaced.push({ kind: 'unplaced', ...sourceOf(box.startIdx, box.plan.endIdx) });
7280
+ }
7281
+ for (const f of pendingFloats) {
7282
+ const box = f.callout ? calloutFloats.get(f.callout.startIdx) : undefined;
7283
+ unplaced.push(box
7284
+ ? { kind: 'unplaced', ...sourceOf(f.callout.startIdx, box.plan.endIdx) }
7285
+ : { kind: 'unplaced', resourceId: f.resourceId, ...sourceOf(f.firstBlockIdx, f.firstBlockIdx) });
7286
+ }
7287
+ if (unplaced.length > 0)
7288
+ unplacedByDoc.set(doc, unplaced);
6584
7289
  // The notes, in the room the flow reserved for them.
6585
7290
  reserveLeftoverNotes();
6586
7291
  setColumnNotes();
@@ -6704,13 +7409,17 @@ function placeDocumentPass(content, config, cache, options, hints) {
6704
7409
  });
6705
7410
  if (shrunk.length > 0)
6706
7411
  floatShrinkWarningsByDoc.set(doc, shrunk);
6707
- if (textWrapNotes.length > 0)
6708
- textWrapWarningsByDoc.set(doc, textWrapNotes);
6709
- return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome, captionUnderProposals };
7412
+ if (textWrapNotes.length > 0 || columnsNotes.length > 0)
7413
+ textWrapWarningsByDoc.set(doc, [...textWrapNotes, ...columnsNotes]);
7414
+ const { proposals: citingPageProposals, refused: citingPageRefused } = settleCitingPageFloats();
7415
+ return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome, looseOffGrid, captionUnderProposals, citingPageProposals, citingPageRefused };
6710
7416
  }
6711
7417
  /** Passes a document printing its own contents gets at most, beyond the
6712
7418
  * first, for the page labels it prints to settle. */
6713
7419
  const MAX_TOC_ROUNDS = 3;
7420
+ /** Rounds of proposals for floats heading their citing page (#633), each a
7421
+ * placement pass, before the band caps run. */
7422
+ const MAX_CITING_PAGE_ROUNDS = 3;
6714
7423
  export function buildDocument(content, config, cache, options) {
6715
7424
  return drainPasses(buildDocumentGen(content, config, cache, options));
6716
7425
  }
@@ -6754,6 +7463,12 @@ export async function buildDocumentAsync(content, config, cache, options) {
6754
7463
  }
6755
7464
  /** The build as a generator: one `yield` after every placement pass. */
6756
7465
  export function* buildDocumentGen(content, config, cache, options) {
7466
+ // Faces that arrived since the last build drop what was measured in
7467
+ // their families, and a config changed in place resolves again (#629).
7468
+ const fontSet = options?.fontSet === undefined ? defaultFontSet() : options.fontSet;
7469
+ if (fontSet)
7470
+ syncFontSet(fontSet);
7471
+ validateConfigCache(config);
6757
7472
  const doc = yield* buildDocumentRounds(content, config, cache, options);
6758
7473
  // References and markup the source names wrongly: read from the source
6759
7474
  // once, located on the pages of the finished layout. Kept apart from
@@ -6767,6 +7482,9 @@ export function* buildDocumentGen(content, config, cache, options) {
6767
7482
  // whose letter-spacing such words do not take.
6768
7483
  // Line numbers set over a float of the side column (#621).
6769
7484
  const loose = [...cjkLooseLineWarnings(doc), ...wordOverflowWarnings(doc), ...(joiningSpacingWarnings.get(doc) ?? []), ...(dropCapWarningsByDoc.get(doc) ?? []), ...lineNumberWarnings(doc), ...codeOverflowWarnings(doc), ...(floatShrinkWarningsByDoc.get(doc) ?? []), ...(textWrapWarningsByDoc.get(doc) ?? [])];
7485
+ // Side boxes and side floats set on pages after the text, and what the
7486
+ // layout could place nowhere (#639).
7487
+ loose.push(...afterTextWarnings(doc), ...(unplacedByDoc.get(doc) ?? []));
6770
7488
  // Design texts cut to fit their width (#628).
6771
7489
  loose.push(...designTruncationWarnings(doc));
6772
7490
  // Comic panels whose cell cannot hold their picture's safe area (#556).
@@ -6794,8 +7512,12 @@ export function* buildDocumentGen(content, config, cache, options) {
6794
7512
  const harakat = annotateArabicMarks(doc);
6795
7513
  // The rules over `*…*` runs under `bodyText.emphasis: 'overline'`.
6796
7514
  overlineEmphasis(doc);
6797
- if (located.length > 0 || loose.length > 0 || annotations.length > 0 || harakat.length > 0) {
6798
- doc.contentWarnings = [...located, ...loose, ...annotations, ...harakat];
7515
+ // Faces the text was set in that the font set could not give (#629).
7516
+ const fallbacks = fontSet && resolveDebugConfig(config?.debug).warnings.missingFont
7517
+ ? fontFallbacks(documentFontFaces(doc), fontSet).map((f) => ({ kind: 'fontFallback', family: f.family, weight: f.weight, style: f.style, reason: f.reason }))
7518
+ : [];
7519
+ if (located.length > 0 || loose.length > 0 || annotations.length > 0 || harakat.length > 0 || fallbacks.length > 0) {
7520
+ doc.contentWarnings = [...located, ...loose, ...annotations, ...harakat, ...fallbacks];
6799
7521
  }
6800
7522
  // Config values the build replaced (an unknown number format, a font
6801
7523
  // stack, a side column no column width can take): walked once per build,
@@ -6815,8 +7537,33 @@ const joiningSpacingWarnings = new WeakMap();
6815
7537
  const dropCapWarningsByDoc = new WeakMap();
6816
7538
  /** The `floatShrunk` warnings of a build (#626), found by its last pass. */
6817
7539
  const floatShrinkWarningsByDoc = new WeakMap();
6818
- /** The `textWrap` warnings of a build (#627), found by its last pass. */
7540
+ /** The `textWrap` warnings of a build (#627) and its `columnsTooNarrow`
7541
+ * ones (#634), found by its last pass. */
6819
7542
  const textWrapWarningsByDoc = new WeakMap();
7543
+ /** What a pass set in the side column of a page opened after the text of
7544
+ * its chapter (#639): a side box's frame, a side float's block, with the
7545
+ * source range an `afterText` warning points at. Read from the finished
7546
+ * layout, so the blocks a splice keeps bring their note along. */
7547
+ const setAfterText = new WeakMap();
7548
+ /** The `unplaced` warnings of a build (#639): what its last pass left in
7549
+ * its queues. */
7550
+ const unplacedByDoc = new WeakMap();
7551
+ /** An `afterText` warning for each side box and side float set after the
7552
+ * text (`setAfterText`) on a page whose text columns hold nothing: one a
7553
+ * barrier box then shared with them holds text again. */
7554
+ function afterTextWarnings(doc) {
7555
+ const out = [];
7556
+ for (const page of doc.pages) {
7557
+ if (!page.floats || page.columns.some((c) => c.kind !== 'side' && c.blocks.length > 0))
7558
+ continue;
7559
+ for (const block of page.floats) {
7560
+ const note = setAfterText.get(block);
7561
+ if (note)
7562
+ out.push({ kind: 'afterText', ...note, pageIndex: page.index });
7563
+ }
7564
+ }
7565
+ return out;
7566
+ }
6820
7567
  /** `doc` with the page of each of its index marks (`doc.indexMarks`). */
6821
7568
  function withIndexMarks(doc, content) {
6822
7569
  const { content: body, contentOffset } = extractFrontmatter(content.markdown);
@@ -6835,6 +7582,10 @@ function withIndexMarks(doc, content) {
6835
7582
  /** {@link buildDocumentGen} before the document-wide warnings: the
6836
7583
  * contents rounds around the balanced build. */
6837
7584
  function* buildDocumentRounds(content, config, cache, options) {
7585
+ // The floats heading their citing page (#633) carry over from one round
7586
+ // of the contents or of the note numbers to the next: the layout barely
7587
+ // moves between them, and each round would otherwise settle them again.
7588
+ const citing = { hints: new Map(), refused: new Set() };
6838
7589
  // A document printing its own table of contents (`:::toc` with no
6839
7590
  // host-supplied outline) is laid out with the page labels of the previous
6840
7591
  // build until they no longer change: the contents' own length moves what
@@ -6851,18 +7602,18 @@ function* buildDocumentRounds(content, config, cache, options) {
6851
7602
  };
6852
7603
  if (hasTocDirective(parsed) || hasIndexDirective(parsed) || pageRefs()) {
6853
7604
  let outline = computeOutline(parsed, resolveAllConfig(config), content.continuation?.headings, content.continuation?.statementCounters);
6854
- let doc = withIndexMarks(yield* buildDocumentNumbered({ ...content, outline }, config, cache, options, 0), content);
7605
+ let doc = withIndexMarks(yield* buildDocumentNumbered({ ...content, outline }, config, cache, options, 0, citing), content);
6855
7606
  for (let round = 0; round < MAX_TOC_ROUNDS; round++) {
6856
7607
  const after = outlineFromDoc(doc, outline);
6857
7608
  if (sameOutline(after, outline))
6858
7609
  break;
6859
7610
  outline = after;
6860
- doc = withIndexMarks(yield* buildDocumentNumbered({ ...content, outline }, config, cache, options, round + 1), content);
7611
+ doc = withIndexMarks(yield* buildDocumentNumbered({ ...content, outline }, config, cache, options, round + 1, citing), content);
6861
7612
  }
6862
7613
  return doc;
6863
7614
  }
6864
7615
  }
6865
- return withIndexMarks(yield* buildDocumentNumbered(content, config, cache, options), content);
7616
+ return withIndexMarks(yield* buildDocumentNumbered(content, config, cache, options, 0, citing), content);
6866
7617
  }
6867
7618
  /** The number each note's marker prints in `doc` (its first marker). */
6868
7619
  function printedFootnoteNumbers(doc) {
@@ -6892,8 +7643,8 @@ const MAX_FOOTNOTE_ROUNDS = 3;
6892
7643
  * width whatever its number, so the second build reflows nothing and the
6893
7644
  * numbers hold; a decimal one may move a line (9 → 10), hence the bound.
6894
7645
  */
6895
- function* buildDocumentNumbered(content, config, cache, options, tocRound = 0) {
6896
- let doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound);
7646
+ function* buildDocumentNumbered(content, config, cache, options, tocRound = 0, citing) {
7647
+ let doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound, undefined, citing);
6897
7648
  const f = doc.config.footnotes;
6898
7649
  if ((f.placement !== 'column' && f.placement !== 'spread') || (f.numbering !== 'page' && f.numbering !== 'column' && f.numbering !== 'spread'))
6899
7650
  return doc;
@@ -6911,12 +7662,15 @@ function* buildDocumentNumbered(content, config, cache, options, tocRound = 0) {
6911
7662
  next.set(id, number);
6912
7663
  if (sameFootnoteNumbers(next, used))
6913
7664
  break;
6914
- doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound, next);
7665
+ doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound, next, citing);
6915
7666
  }
6916
7667
  return doc;
6917
7668
  }
6918
7669
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
6919
- function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, footnoteNumbers) {
7670
+ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, footnoteNumbers,
7671
+ /** Floats heading their citing page settled by an earlier round of the
7672
+ * same build (#633): seeds the hints and the refusals. */
7673
+ citing = { hints: new Map(), refused: new Set() }) {
6920
7674
  // Each pass reports its own progress, numbered in build order.
6921
7675
  let passIndex = 0;
6922
7676
  const onProgress = options?.onProgress;
@@ -6927,12 +7681,28 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6927
7681
  // Side captions moved under their figure (see `PassHints.captionUnder`):
6928
7682
  // once a pass asks for one, every later pass keeps it.
6929
7683
  const captionUnder = new Set();
7684
+ // Loose paragraphs any pass found would gain their line only off the
7685
+ // character grid (#632): the columns they leave short say so.
7686
+ const looseOffGrid = new Set();
7687
+ // Floats heading the page or column that cites them (#633,
7688
+ // `PassHints.citingPageFloats`): once a pass keeps one there, every later
7689
+ // pass opens that page with it; a float refused once (its citing line
7690
+ // left the page, or it could not be set there) is never proposed again.
7691
+ const citingPage = citing.hints;
7692
+ const citingRefused = citing.refused;
6930
7693
  const runPass = (hints) => {
6931
7694
  passIndex++;
6932
7695
  const started = onPass ? now() : 0;
6933
- const result = buildDocumentPass(content, config, cache, passOptions, { ...hints, captionUnder, ...(footnoteNumbers ? { footnoteNumbers } : {}) });
7696
+ const result = buildDocumentPass(content, config, cache, passOptions, {
7697
+ ...hints,
7698
+ captionUnder,
7699
+ ...(footnoteNumbers ? { footnoteNumbers } : {}),
7700
+ ...(citingPage.size > 0 ? { citingPageFloats: new Map(citingPage) } : {}),
7701
+ });
6934
7702
  for (const id of result.captionUnderProposals)
6935
7703
  captionUnder.add(id);
7704
+ for (const k of result.looseOffGrid)
7705
+ looseOffGrid.add(k);
6936
7706
  onPass?.({ pass: passIndex, tocRound, ms: now() - started, pages: result.doc.pages.length });
6937
7707
  return result;
6938
7708
  };
@@ -6949,12 +7719,55 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6949
7719
  if (captionUnder.size === before)
6950
7720
  break;
6951
7721
  }
7722
+ // --- Floats at the head of their citing page (#633) --------------------
7723
+ // A float that may head the page (column) its citing line lands on and
7724
+ // went on to a later one is proposed there; the next pass opens that
7725
+ // page with it. A pass that could not keep one there (the text it pushed
7726
+ // down took the citing line to the next page) drops it for good. A few
7727
+ // rounds at most, as the side captions get; then any hint still refused
7728
+ // is dropped, each pass with fewer, until every one left holds.
7729
+ /** Drop the hints `r` could not keep; whether there were any. */
7730
+ const dropRefusedCiting = (r) => {
7731
+ let dropped = false;
7732
+ for (const id of r.citingPageRefused.keys()) {
7733
+ citingRefused.add(id);
7734
+ if (citingPage.delete(id))
7735
+ dropped = true;
7736
+ }
7737
+ return dropped;
7738
+ };
7739
+ for (let round = 0; round < MAX_CITING_PAGE_ROUNDS; round++) {
7740
+ let changed = dropRefusedCiting(initial);
7741
+ for (const [id, slot] of initial.citingPageProposals) {
7742
+ if (citingRefused.has(id) || citingPage.has(id))
7743
+ continue;
7744
+ citingPage.set(id, slot);
7745
+ changed = true;
7746
+ }
7747
+ if (!changed)
7748
+ break;
7749
+ initial = runPass();
7750
+ yield;
7751
+ }
7752
+ while (dropRefusedCiting(initial)) {
7753
+ initial = runPass();
7754
+ yield;
7755
+ }
6952
7756
  // --- Band caps (page-span blocks mid-page) -----------------------------
6953
7757
  // A span block that arrived in an uneven band proposes a cap; the driver
6954
7758
  // re-places the document with it (and grows / drops caps whose band
6955
7759
  // overflowed) before balancing runs. Documents without such blocks get
6956
7760
  // their first pass back untouched — no extra pass.
6957
- const bands = yield* resolveBandCapsGen(initial, (bandCaps) => runPass({ bandCaps }));
7761
+ let bands = yield* resolveBandCapsGen(initial, (bandCaps) => runPass({ bandCaps }));
7762
+ // A cap that took a citing line off the page its float heads: drop that
7763
+ // float's hint and settle the caps again without it.
7764
+ while (dropRefusedCiting(bands.result)) {
7765
+ initial = runPass();
7766
+ yield;
7767
+ const passes = bands.passCount;
7768
+ bands = yield* resolveBandCapsGen(initial, (bandCaps) => runPass({ bandCaps }));
7769
+ bands = { ...bands, passCount: bands.passCount + passes + 1 };
7770
+ }
6958
7771
  let best = bands.result;
6959
7772
  const bandCaps = bands.bandCaps;
6960
7773
  let passCount = bands.passCount;
@@ -6980,8 +7793,20 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6980
7793
  // balances exactly as its chapters would one by one, instead of one
6981
7794
  // cascade anywhere costing every page of the book a pass.
6982
7795
  const balancing = best.doc.config.headings.balancing;
6983
- if (!balancing.enabled)
7796
+ const onGrid = best.doc.config.cjk.grid.enabled;
7797
+ // Why the columns of a page on a character grid may end short (#632):
7798
+ // balancing off by the grid's default (vertical text has it off by its
7799
+ // own), or its whole-line levers kept out.
7800
+ const gridBalancing = onGrid
7801
+ ? (!balancing.enabled
7802
+ ? (config?.headings?.balancing?.enabled === undefined && best.doc.config.layout.writingMode !== 'vertical-rl' ? { off: true } : undefined)
7803
+ : (balancing.gridLines === 'off' ? { gridLines: 'off' } : undefined))
7804
+ : undefined;
7805
+ if (!balancing.enabled) {
7806
+ if (gridBalancing)
7807
+ best.doc.gridBalancing = gridBalancing;
6984
7808
  return best.doc;
7809
+ }
6985
7810
  /** Levers accepted so far, over every segment (keyed by content index /
6986
7811
  * `balanceKey`; each key belongs to exactly one segment). */
6987
7812
  const applied = { lines: new Map(), loose: new Map() };
@@ -7101,6 +7926,11 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7101
7926
  if (!sameContent(blocks, next.doc.blocks))
7102
7927
  return best;
7103
7928
  const doc = { ...best.doc, pages, blocks, ...(warnings.length > 0 ? { warnings } : { warnings: undefined }) };
7929
+ // It holds what `next` holds: what `next` placed nowhere is still
7930
+ // missing from it (#639).
7931
+ const unplaced = unplacedByDoc.get(next.doc);
7932
+ if (unplaced)
7933
+ unplacedByDoc.set(doc, unplaced);
7104
7934
  const pageBest = pageOfContent(best.doc);
7105
7935
  const pageNext = pageOfContent(next.doc);
7106
7936
  const mergeMap = (a, b) => {
@@ -7130,7 +7960,10 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7130
7960
  spanPlacedInBand: mergeSet(best.spanPlacedInBand, next.spanPlacedInBand),
7131
7961
  bandCapsApplied: mergeSet(best.bandCapsApplied, next.bandCapsApplied),
7132
7962
  looseOutcome: mergeMap(best.looseOutcome, next.looseOutcome),
7963
+ looseOffGrid: new Set([...best.looseOffGrid, ...next.looseOffGrid]),
7133
7964
  captionUnderProposals: new Set([...best.captionUnderProposals, ...next.captionUnderProposals]),
7965
+ citingPageProposals: best.citingPageProposals,
7966
+ citingPageRefused: best.citingPageRefused,
7134
7967
  };
7135
7968
  };
7136
7969
  /**
@@ -7200,6 +8033,7 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7200
8033
  failedLoose,
7201
8034
  failedLines,
7202
8035
  closingBox: balancing.closingBox,
8036
+ ...(onGrid && balancing.gridLines === 'off' ? { gridLines: false } : {}),
7203
8037
  });
7204
8038
  // The levers newly proposed, by segment; those of a segment that is
7205
8039
  // done (plateaued, out of attempts) are withdrawn from the pass.
@@ -7265,7 +8099,10 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7265
8099
  // a levelled closing band spills past its cut) is a regression —
7266
8100
  // capped columns without their box are not a layout we may keep.
7267
8101
  const capsDelivered = [...bandCaps.keys()].every((i) => !inRange(s.range, capPage.get(i)) || next.spanPlacedInBand.has(i));
7268
- const score = capsDelivered && wholeIntact(next, s, si === segments.length - 1)
8102
+ // So is one that loses a float heading its citing page (#633): the
8103
+ // levers pushed its citing line off it.
8104
+ const citingKept = ![...next.citingPageRefused.keys()].some((id) => inRange(s.range, citingPage.get(id)?.pageIndex));
8105
+ const score = capsDelivered && citingKept && wholeIntact(next, s, si === segments.length - 1)
7269
8106
  ? gapLinesIn(nextGaps, s.range)
7270
8107
  : Infinity;
7271
8108
  // Loose paragraphs that gained no line at any tracking rung are
@@ -7348,7 +8185,9 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7348
8185
  const frozen = hintsFrom(applied.lines, applied.loose);
7349
8186
  const trailing = yield* resolveTrailingCapsGen(best, bandCaps, (caps) => runPass({ ...frozen, bandCaps: caps, floatPages: floatPagesOf(best.doc) }));
7350
8187
  passCount += trailing.passCount;
7351
- if (trailing.result !== best) {
8188
+ // A cap that takes a citing line off the page its float heads (#633)
8189
+ // is not kept.
8190
+ if (trailing.result !== best && trailing.result.citingPageRefused.size === 0) {
7352
8191
  best = trailing.result;
7353
8192
  for (const [i, cap] of trailing.caps)
7354
8193
  bandCaps.set(i, cap);
@@ -7364,8 +8203,41 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
7364
8203
  }
7365
8204
  best.doc.iterationCount = passCount;
7366
8205
  best.doc.converged = segments.every((s) => s.stable || s.bestScore === 0);
8206
+ if (onGrid)
8207
+ markGridRefusals(best.doc, best.forcedBreakPages, looseOffGrid, gridBalancing);
7367
8208
  return best.doc;
7368
8209
  }
8210
+ /**
8211
+ * Column balancing on a character grid (#632): stamp the document with why
8212
+ * its balancing is limited (`VDTDocument.gridBalancing`) and every column
8213
+ * still short with the levers the grid kept from it
8214
+ * (`VDTColumn.gridRefused`): the loose paragraphs that would have gained
8215
+ * their line off the grid (`offGrid`), and the whole-line levers under
8216
+ * `headings.balancing.gridLines: 'off'`.
8217
+ */
8218
+ function markGridRefusals(doc, forcedBreakPages, offGrid, gridBalancing) {
8219
+ if (gridBalancing)
8220
+ doc.gridBalancing = gridBalancing;
8221
+ const noLines = gridBalancing?.gridLines === 'off';
8222
+ for (const gap of collectColumnGaps(doc, forcedBreakPages)) {
8223
+ if (gap.gapLines < 1)
8224
+ continue;
8225
+ const refused = new Set();
8226
+ for (const c of gap.candidates) {
8227
+ if (noLines && GRID_LINE_LEVERS.includes(c.kind))
8228
+ refused.add(c.kind);
8229
+ else if (c.kind === 'looseParagraph' && offGrid.has(c.contentIndex))
8230
+ refused.add(c.kind);
8231
+ }
8232
+ if (refused.size === 0)
8233
+ continue;
8234
+ const col = doc.pages[gap.pageIndex]?.columns[gap.columnIndex];
8235
+ if (col)
8236
+ col.gridRefused = BALANCE_LEVER_ORDER.filter((l) => refused.has(l));
8237
+ }
8238
+ }
8239
+ /** The balancing levers in the order `BalanceLever` lists them. */
8240
+ const BALANCE_LEVER_ORDER = ['trailingCallout', 'flexFigure', 'heading', 'listEnd', 'afterDisplay', 'afterFloat', 'looseParagraph'];
7369
8241
  /** Whether two lines set one bayt of a poem (`VDTLine.verse`), which a
7370
8242
  * column never cuts. */
7371
8243
  function sameBayt(a, b) {