postext 1.23.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 (471) hide show
  1. package/dist/__tests__/bundle/bitmapInfo.test.d.ts +2 -0
  2. package/dist/__tests__/bundle/bitmapInfo.test.d.ts.map +1 -0
  3. package/dist/__tests__/bundle/bitmapInfo.test.js +78 -0
  4. package/dist/__tests__/bundle/bitmapInfo.test.js.map +1 -0
  5. package/dist/__tests__/bundle/cjkRulesPin.test.d.ts +2 -0
  6. package/dist/__tests__/bundle/cjkRulesPin.test.d.ts.map +1 -0
  7. package/dist/__tests__/bundle/cjkRulesPin.test.js +78 -0
  8. package/dist/__tests__/bundle/cjkRulesPin.test.js.map +1 -0
  9. package/dist/__tests__/bundle/designOverflowPin.test.d.ts +2 -0
  10. package/dist/__tests__/bundle/designOverflowPin.test.d.ts.map +1 -0
  11. package/dist/__tests__/bundle/designOverflowPin.test.js +89 -0
  12. package/dist/__tests__/bundle/designOverflowPin.test.js.map +1 -0
  13. package/dist/__tests__/bundle/flowColumnsPin.test.d.ts +2 -0
  14. package/dist/__tests__/bundle/flowColumnsPin.test.d.ts.map +1 -0
  15. package/dist/__tests__/bundle/flowColumnsPin.test.js +40 -0
  16. package/dist/__tests__/bundle/flowColumnsPin.test.js.map +1 -0
  17. package/dist/__tests__/bundle/gridBalancingPin.test.d.ts +2 -0
  18. package/dist/__tests__/bundle/gridBalancingPin.test.d.ts.map +1 -0
  19. package/dist/__tests__/bundle/gridBalancingPin.test.js +53 -0
  20. package/dist/__tests__/bundle/gridBalancingPin.test.js.map +1 -0
  21. package/dist/__tests__/bundle/inlineTableSplitPin.test.d.ts +2 -0
  22. package/dist/__tests__/bundle/inlineTableSplitPin.test.d.ts.map +1 -0
  23. package/dist/__tests__/bundle/inlineTableSplitPin.test.js +43 -0
  24. package/dist/__tests__/bundle/inlineTableSplitPin.test.js.map +1 -0
  25. package/dist/__tests__/bundle/openerHeadFloatsPin.test.d.ts +2 -0
  26. package/dist/__tests__/bundle/openerHeadFloatsPin.test.d.ts.map +1 -0
  27. package/dist/__tests__/bundle/openerHeadFloatsPin.test.js +50 -0
  28. package/dist/__tests__/bundle/openerHeadFloatsPin.test.js.map +1 -0
  29. package/dist/__tests__/bundle.test.js +11 -1
  30. package/dist/__tests__/bundle.test.js.map +1 -1
  31. package/dist/__tests__/cjk/annotationsRender.test.js +5 -2
  32. package/dist/__tests__/cjk/annotationsRender.test.js.map +1 -1
  33. package/dist/__tests__/cjk/circledNumbers.test.d.ts +2 -0
  34. package/dist/__tests__/cjk/circledNumbers.test.d.ts.map +1 -0
  35. package/dist/__tests__/cjk/circledNumbers.test.js +83 -0
  36. package/dist/__tests__/cjk/circledNumbers.test.js.map +1 -0
  37. package/dist/__tests__/cjk/gridBalancing.test.d.ts +2 -0
  38. package/dist/__tests__/cjk/gridBalancing.test.d.ts.map +1 -0
  39. package/dist/__tests__/cjk/gridBalancing.test.js +227 -0
  40. package/dist/__tests__/cjk/gridBalancing.test.js.map +1 -0
  41. package/dist/__tests__/cjk/justifyRender.test.js +2 -1
  42. package/dist/__tests__/cjk/justifyRender.test.js.map +1 -1
  43. package/dist/__tests__/cjk/titleBreaks.test.d.ts +2 -0
  44. package/dist/__tests__/cjk/titleBreaks.test.d.ts.map +1 -0
  45. package/dist/__tests__/cjk/titleBreaks.test.js +76 -0
  46. package/dist/__tests__/cjk/titleBreaks.test.js.map +1 -0
  47. package/dist/__tests__/cjk/wordJoiner.test.js +13 -4
  48. package/dist/__tests__/cjk/wordJoiner.test.js.map +1 -1
  49. package/dist/__tests__/codeBlocks.test.js +1 -1
  50. package/dist/__tests__/codeBlocks.test.js.map +1 -1
  51. package/dist/__tests__/design/cjkDesignText.test.d.ts +2 -0
  52. package/dist/__tests__/design/cjkDesignText.test.d.ts.map +1 -0
  53. package/dist/__tests__/design/cjkDesignText.test.js +130 -0
  54. package/dist/__tests__/design/cjkDesignText.test.js.map +1 -0
  55. package/dist/__tests__/design/cjkDesignTextRender.test.d.ts +2 -0
  56. package/dist/__tests__/design/cjkDesignTextRender.test.d.ts.map +1 -0
  57. package/dist/__tests__/design/cjkDesignTextRender.test.js +100 -0
  58. package/dist/__tests__/design/cjkDesignTextRender.test.js.map +1 -0
  59. package/dist/__tests__/design/designTextOverflow.test.d.ts +2 -0
  60. package/dist/__tests__/design/designTextOverflow.test.d.ts.map +1 -0
  61. package/dist/__tests__/design/designTextOverflow.test.js +102 -0
  62. package/dist/__tests__/design/designTextOverflow.test.js.map +1 -0
  63. package/dist/__tests__/exports.test.js +60 -0
  64. package/dist/__tests__/exports.test.js.map +1 -1
  65. package/dist/__tests__/frontmatterInvalid.test.d.ts +2 -0
  66. package/dist/__tests__/frontmatterInvalid.test.d.ts.map +1 -0
  67. package/dist/__tests__/frontmatterInvalid.test.js +101 -0
  68. package/dist/__tests__/frontmatterInvalid.test.js.map +1 -0
  69. package/dist/__tests__/hardBreaks.test.js +1 -1
  70. package/dist/__tests__/hardBreaks.test.js.map +1 -1
  71. package/dist/__tests__/headingFirstLineIndent.test.d.ts +2 -0
  72. package/dist/__tests__/headingFirstLineIndent.test.d.ts.map +1 -0
  73. package/dist/__tests__/headingFirstLineIndent.test.js +185 -0
  74. package/dist/__tests__/headingFirstLineIndent.test.js.map +1 -0
  75. package/dist/__tests__/headingInlineMarks.test.js +1 -1
  76. package/dist/__tests__/headingInlineMarks.test.js.map +1 -1
  77. package/dist/__tests__/headingLetterSpacing.test.js +1 -1
  78. package/dist/__tests__/headingLetterSpacing.test.js.map +1 -1
  79. package/dist/__tests__/headingNumberSpans.test.d.ts +2 -0
  80. package/dist/__tests__/headingNumberSpans.test.d.ts.map +1 -0
  81. package/dist/__tests__/headingNumberSpans.test.js +96 -0
  82. package/dist/__tests__/headingNumberSpans.test.js.map +1 -0
  83. package/dist/__tests__/mathSizeMigration.test.js +2 -2
  84. package/dist/__tests__/mathSizeMigration.test.js.map +1 -1
  85. package/dist/__tests__/multipleColumns.test.js +207 -0
  86. package/dist/__tests__/multipleColumns.test.js.map +1 -1
  87. package/dist/__tests__/parse/citations.test.js +29 -0
  88. package/dist/__tests__/parse/citations.test.js.map +1 -1
  89. package/dist/__tests__/pipeline/bitmapResolution.test.d.ts +2 -0
  90. package/dist/__tests__/pipeline/bitmapResolution.test.d.ts.map +1 -0
  91. package/dist/__tests__/pipeline/bitmapResolution.test.js +139 -0
  92. package/dist/__tests__/pipeline/bitmapResolution.test.js.map +1 -0
  93. package/dist/__tests__/pipeline/calloutInlineResourceGap.test.js +10 -6
  94. package/dist/__tests__/pipeline/calloutInlineResourceGap.test.js.map +1 -1
  95. package/dist/__tests__/pipeline/calloutOverflow.test.js +4 -2
  96. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -1
  97. package/dist/__tests__/pipeline/calloutSplitMinLinesChild.test.js +1 -1
  98. package/dist/__tests__/pipeline/calloutSplitMinLinesChild.test.js.map +1 -1
  99. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js +6 -5
  100. package/dist/__tests__/pipeline/calloutTallKeepTogether.test.js.map +1 -1
  101. package/dist/__tests__/pipeline/colonListRoom.test.js +1 -1
  102. package/dist/__tests__/pipeline/colonListRoom.test.js.map +1 -1
  103. package/dist/__tests__/pipeline/compoundBreaks.test.js +1 -1
  104. package/dist/__tests__/pipeline/compoundBreaks.test.js.map +1 -1
  105. package/dist/__tests__/pipeline/dashBreaks.test.js +1 -1
  106. package/dist/__tests__/pipeline/dashBreaks.test.js.map +1 -1
  107. package/dist/__tests__/pipeline/designTextTruncated.test.d.ts +2 -0
  108. package/dist/__tests__/pipeline/designTextTruncated.test.d.ts.map +1 -0
  109. package/dist/__tests__/pipeline/designTextTruncated.test.js +116 -0
  110. package/dist/__tests__/pipeline/designTextTruncated.test.js.map +1 -0
  111. package/dist/__tests__/pipeline/floatCitingPage.test.d.ts +2 -0
  112. package/dist/__tests__/pipeline/floatCitingPage.test.d.ts.map +1 -0
  113. package/dist/__tests__/pipeline/floatCitingPage.test.js +241 -0
  114. package/dist/__tests__/pipeline/floatCitingPage.test.js.map +1 -0
  115. package/dist/__tests__/pipeline/floatOverInlineResource.test.js +18 -6
  116. package/dist/__tests__/pipeline/floatOverInlineResource.test.js.map +1 -1
  117. package/dist/__tests__/pipeline/floatPlacement.test.js +3 -3
  118. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
  119. package/dist/__tests__/pipeline/floatShrink.test.d.ts +2 -0
  120. package/dist/__tests__/pipeline/floatShrink.test.d.ts.map +1 -0
  121. package/dist/__tests__/pipeline/floatShrink.test.js +298 -0
  122. package/dist/__tests__/pipeline/floatShrink.test.js.map +1 -0
  123. package/dist/__tests__/pipeline/flowColumns.test.d.ts +2 -0
  124. package/dist/__tests__/pipeline/flowColumns.test.d.ts.map +1 -0
  125. package/dist/__tests__/pipeline/flowColumns.test.js +261 -0
  126. package/dist/__tests__/pipeline/flowColumns.test.js.map +1 -0
  127. package/dist/__tests__/pipeline/headingKeepFitEps.test.d.ts +2 -0
  128. package/dist/__tests__/pipeline/headingKeepFitEps.test.d.ts.map +1 -0
  129. package/dist/__tests__/pipeline/headingKeepFitEps.test.js +83 -0
  130. package/dist/__tests__/pipeline/headingKeepFitEps.test.js.map +1 -0
  131. package/dist/__tests__/pipeline/headingSplitOrphans.test.js +1 -1
  132. package/dist/__tests__/pipeline/headingSplitOrphans.test.js.map +1 -1
  133. package/dist/__tests__/pipeline/inlineFigureSpace.test.js +1 -1
  134. package/dist/__tests__/pipeline/inlineFigureSpace.test.js.map +1 -1
  135. package/dist/__tests__/pipeline/inlineTableSplit.test.d.ts +2 -0
  136. package/dist/__tests__/pipeline/inlineTableSplit.test.d.ts.map +1 -0
  137. package/dist/__tests__/pipeline/inlineTableSplit.test.js +238 -0
  138. package/dist/__tests__/pipeline/inlineTableSplit.test.js.map +1 -0
  139. package/dist/__tests__/pipeline/paragraphContainerTail.test.js +1 -1
  140. package/dist/__tests__/pipeline/paragraphContainerTail.test.js.map +1 -1
  141. package/dist/__tests__/pipeline/raggedOptimal.test.js +1 -1
  142. package/dist/__tests__/pipeline/raggedOptimal.test.js.map +1 -1
  143. package/dist/__tests__/pipeline/resolvedPaletteCopy.test.js +3 -2
  144. package/dist/__tests__/pipeline/resolvedPaletteCopy.test.js.map +1 -1
  145. package/dist/__tests__/pipeline/sideBoxAfterText.test.d.ts +2 -0
  146. package/dist/__tests__/pipeline/sideBoxAfterText.test.d.ts.map +1 -0
  147. package/dist/__tests__/pipeline/sideBoxAfterText.test.js +163 -0
  148. package/dist/__tests__/pipeline/sideBoxAfterText.test.js.map +1 -0
  149. package/dist/__tests__/pipeline/textWrap.test.d.ts +2 -0
  150. package/dist/__tests__/pipeline/textWrap.test.d.ts.map +1 -0
  151. package/dist/__tests__/pipeline/textWrap.test.js +453 -0
  152. package/dist/__tests__/pipeline/textWrap.test.js.map +1 -0
  153. package/dist/__tests__/preflight.test.js +30 -1
  154. package/dist/__tests__/preflight.test.js.map +1 -1
  155. package/dist/__tests__/prepareFonts.test.d.ts +2 -0
  156. package/dist/__tests__/prepareFonts.test.d.ts.map +1 -0
  157. package/dist/__tests__/prepareFonts.test.js +358 -0
  158. package/dist/__tests__/prepareFonts.test.js.map +1 -0
  159. package/dist/__tests__/presets/showcaseDesignOverflow.test.d.ts +2 -0
  160. package/dist/__tests__/presets/showcaseDesignOverflow.test.d.ts.map +1 -0
  161. package/dist/__tests__/presets/showcaseDesignOverflow.test.js +58 -0
  162. package/dist/__tests__/presets/showcaseDesignOverflow.test.js.map +1 -0
  163. package/dist/__tests__/svgFonts.test.d.ts +2 -0
  164. package/dist/__tests__/svgFonts.test.d.ts.map +1 -0
  165. package/dist/__tests__/svgFonts.test.js +286 -0
  166. package/dist/__tests__/svgFonts.test.js.map +1 -0
  167. package/dist/__tests__/table/booktabs.test.d.ts +2 -0
  168. package/dist/__tests__/table/booktabs.test.d.ts.map +1 -0
  169. package/dist/__tests__/table/booktabs.test.js +290 -0
  170. package/dist/__tests__/table/booktabs.test.js.map +1 -0
  171. package/dist/__tests__/textWrapMeasure.test.d.ts +2 -0
  172. package/dist/__tests__/textWrapMeasure.test.d.ts.map +1 -0
  173. package/dist/__tests__/textWrapMeasure.test.js +162 -0
  174. package/dist/__tests__/textWrapMeasure.test.js.map +1 -0
  175. package/dist/__tests__/verseLines.test.js +134 -4
  176. package/dist/__tests__/verseLines.test.js.map +1 -1
  177. package/dist/__tests__/vertical/measurePaint.test.js +16 -0
  178. package/dist/__tests__/vertical/measurePaint.test.js.map +1 -1
  179. package/dist/bitmapResolution.d.ts +42 -0
  180. package/dist/bitmapResolution.d.ts.map +1 -0
  181. package/dist/bitmapResolution.js +82 -0
  182. package/dist/bitmapResolution.js.map +1 -0
  183. package/dist/bundle/adapters.d.ts +31 -7
  184. package/dist/bundle/adapters.d.ts.map +1 -1
  185. package/dist/bundle/adapters.js +113 -34
  186. package/dist/bundle/adapters.js.map +1 -1
  187. package/dist/bundle/codec.d.ts +9 -2
  188. package/dist/bundle/codec.d.ts.map +1 -1
  189. package/dist/bundle/codec.js +25 -2
  190. package/dist/bundle/codec.js.map +1 -1
  191. package/dist/bundle/configVersion.d.ts +168 -10
  192. package/dist/bundle/configVersion.d.ts.map +1 -1
  193. package/dist/bundle/configVersion.js +414 -8
  194. package/dist/bundle/configVersion.js.map +1 -1
  195. package/dist/bundle/index.d.ts +5 -5
  196. package/dist/bundle/index.d.ts.map +1 -1
  197. package/dist/bundle/index.js +3 -3
  198. package/dist/bundle/index.js.map +1 -1
  199. package/dist/bundle/manifest.d.ts +12 -2
  200. package/dist/bundle/manifest.d.ts.map +1 -1
  201. package/dist/bundle/manifest.js +134 -7
  202. package/dist/bundle/manifest.js.map +1 -1
  203. package/dist/bundle/types.d.ts +32 -2
  204. package/dist/bundle/types.d.ts.map +1 -1
  205. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  206. package/dist/canvas-backend/blockRender.js +6 -4
  207. package/dist/canvas-backend/blockRender.js.map +1 -1
  208. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  209. package/dist/canvas-backend/headerFooter.js +3 -1
  210. package/dist/canvas-backend/headerFooter.js.map +1 -1
  211. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  212. package/dist/canvas-backend/renderResourceBlock.js +15 -1
  213. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  214. package/dist/columnRule.d.ts.map +1 -1
  215. package/dist/columnRule.js +5 -2
  216. package/dist/columnRule.js.map +1 -1
  217. package/dist/configWarnings.js +30 -3
  218. package/dist/configWarnings.js.map +1 -1
  219. package/dist/defaults/cjk.d.ts +2 -1
  220. package/dist/defaults/cjk.d.ts.map +1 -1
  221. package/dist/defaults/cjk.js +17 -1
  222. package/dist/defaults/cjk.js.map +1 -1
  223. package/dist/defaults/diagramStyle.d.ts.map +1 -1
  224. package/dist/defaults/diagramStyle.js +6 -0
  225. package/dist/defaults/diagramStyle.js.map +1 -1
  226. package/dist/defaults/headerFooter.d.ts +21 -6
  227. package/dist/defaults/headerFooter.d.ts.map +1 -1
  228. package/dist/defaults/headerFooter.js +37 -14
  229. package/dist/defaults/headerFooter.js.map +1 -1
  230. package/dist/defaults/headings.d.ts +5 -4
  231. package/dist/defaults/headings.d.ts.map +1 -1
  232. package/dist/defaults/headings.js +18 -4
  233. package/dist/defaults/headings.js.map +1 -1
  234. package/dist/defaults/index.d.ts +1 -1
  235. package/dist/defaults/index.d.ts.map +1 -1
  236. package/dist/defaults/index.js +1 -1
  237. package/dist/defaults/index.js.map +1 -1
  238. package/dist/defaults/layout.d.ts +35 -1
  239. package/dist/defaults/layout.d.ts.map +1 -1
  240. package/dist/defaults/layout.js +137 -1
  241. package/dist/defaults/layout.js.map +1 -1
  242. package/dist/defaults/parts.js +4 -4
  243. package/dist/defaults/parts.js.map +1 -1
  244. package/dist/defaults/tableStyle.d.ts.map +1 -1
  245. package/dist/defaults/tableStyle.js +50 -0
  246. package/dist/defaults/tableStyle.js.map +1 -1
  247. package/dist/defaults/toc.js +2 -2
  248. package/dist/defaults/verse.d.ts +2 -1
  249. package/dist/defaults/verse.d.ts.map +1 -1
  250. package/dist/defaults/verse.js +6 -1
  251. package/dist/defaults/verse.js.map +1 -1
  252. package/dist/design/cjkText.d.ts +34 -0
  253. package/dist/design/cjkText.d.ts.map +1 -0
  254. package/dist/design/cjkText.js +82 -0
  255. package/dist/design/cjkText.js.map +1 -0
  256. package/dist/design/layout.d.ts +15 -1
  257. package/dist/design/layout.d.ts.map +1 -1
  258. package/dist/design/layout.js +130 -8
  259. package/dist/design/layout.js.map +1 -1
  260. package/dist/design/richText.d.ts +24 -0
  261. package/dist/design/richText.d.ts.map +1 -1
  262. package/dist/design/richText.js +37 -1
  263. package/dist/design/richText.js.map +1 -1
  264. package/dist/fonts/buildWithFonts.d.ts +19 -0
  265. package/dist/fonts/buildWithFonts.d.ts.map +1 -0
  266. package/dist/fonts/buildWithFonts.js +16 -0
  267. package/dist/fonts/buildWithFonts.js.map +1 -0
  268. package/dist/fonts/faces.d.ts +88 -0
  269. package/dist/fonts/faces.d.ts.map +1 -0
  270. package/dist/fonts/faces.js +264 -0
  271. package/dist/fonts/faces.js.map +1 -0
  272. package/dist/fonts/fontSet.d.ts +34 -0
  273. package/dist/fonts/fontSet.d.ts.map +1 -0
  274. package/dist/fonts/fontSet.js +173 -0
  275. package/dist/fonts/fontSet.js.map +1 -0
  276. package/dist/fonts/prepare.d.ts +120 -0
  277. package/dist/fonts/prepare.d.ts.map +1 -0
  278. package/dist/fonts/prepare.js +375 -0
  279. package/dist/fonts/prepare.js.map +1 -0
  280. package/dist/frontmatter.d.ts +20 -0
  281. package/dist/frontmatter.d.ts.map +1 -1
  282. package/dist/frontmatter.js +69 -3
  283. package/dist/frontmatter.js.map +1 -1
  284. package/dist/html-backend.d.ts +17 -0
  285. package/dist/html-backend.d.ts.map +1 -1
  286. package/dist/html-backend.js +109 -7
  287. package/dist/html-backend.js.map +1 -1
  288. package/dist/index.d.ts +35 -10
  289. package/dist/index.d.ts.map +1 -1
  290. package/dist/index.js +23 -4
  291. package/dist/index.js.map +1 -1
  292. package/dist/measure/bidiLines.d.ts +4 -3
  293. package/dist/measure/bidiLines.d.ts.map +1 -1
  294. package/dist/measure/bidiLines.js +12 -7
  295. package/dist/measure/bidiLines.js.map +1 -1
  296. package/dist/measure/cache.d.ts.map +1 -1
  297. package/dist/measure/cache.js +32 -2
  298. package/dist/measure/cache.js.map +1 -1
  299. package/dist/measure/canvas.d.ts +15 -0
  300. package/dist/measure/canvas.d.ts.map +1 -1
  301. package/dist/measure/canvas.js +68 -4
  302. package/dist/measure/canvas.js.map +1 -1
  303. package/dist/measure/cjk.d.ts.map +1 -1
  304. package/dist/measure/cjk.js +4 -1
  305. package/dist/measure/cjk.js.map +1 -1
  306. package/dist/measure/cjkClasses.d.ts +17 -0
  307. package/dist/measure/cjkClasses.d.ts.map +1 -1
  308. package/dist/measure/cjkClasses.js +41 -1
  309. package/dist/measure/cjkClasses.js.map +1 -1
  310. package/dist/measure/cjkCompose.d.ts.map +1 -1
  311. package/dist/measure/cjkCompose.js +126 -10
  312. package/dist/measure/cjkCompose.js.map +1 -1
  313. package/dist/measure/cjkPunctuation.d.ts +18 -1
  314. package/dist/measure/cjkPunctuation.d.ts.map +1 -1
  315. package/dist/measure/cjkPunctuation.js +13 -1
  316. package/dist/measure/cjkPunctuation.js.map +1 -1
  317. package/dist/measure/font.d.ts +8 -0
  318. package/dist/measure/font.d.ts.map +1 -1
  319. package/dist/measure/font.js +13 -2
  320. package/dist/measure/font.js.map +1 -1
  321. package/dist/measure/fontString.d.ts +14 -0
  322. package/dist/measure/fontString.d.ts.map +1 -0
  323. package/dist/measure/fontString.js +55 -0
  324. package/dist/measure/fontString.js.map +1 -0
  325. package/dist/measure/hardBreaks.d.ts.map +1 -1
  326. package/dist/measure/hardBreaks.js +13 -1
  327. package/dist/measure/hardBreaks.js.map +1 -1
  328. package/dist/measure/index.d.ts +7 -4
  329. package/dist/measure/index.d.ts.map +1 -1
  330. package/dist/measure/index.js +7 -5
  331. package/dist/measure/index.js.map +1 -1
  332. package/dist/measure/plain.d.ts.map +1 -1
  333. package/dist/measure/plain.js +11 -7
  334. package/dist/measure/plain.js.map +1 -1
  335. package/dist/measure/rich.d.ts.map +1 -1
  336. package/dist/measure/rich.js +9 -6
  337. package/dist/measure/rich.js.map +1 -1
  338. package/dist/measure/types.d.ts +53 -3
  339. package/dist/measure/types.d.ts.map +1 -1
  340. package/dist/measure/types.js +67 -3
  341. package/dist/measure/types.js.map +1 -1
  342. package/dist/parse/citations.d.ts.map +1 -1
  343. package/dist/parse/citations.js +7 -3
  344. package/dist/parse/citations.js.map +1 -1
  345. package/dist/pipeline/build.d.ts +36 -0
  346. package/dist/pipeline/build.d.ts.map +1 -1
  347. package/dist/pipeline/build.js +1482 -153
  348. package/dist/pipeline/build.js.map +1 -1
  349. package/dist/pipeline/buildBlockKind.d.ts +5 -0
  350. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  351. package/dist/pipeline/buildBlockKind.js +34 -7
  352. package/dist/pipeline/buildBlockKind.js.map +1 -1
  353. package/dist/pipeline/buildMeasurement.d.ts +6 -0
  354. package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
  355. package/dist/pipeline/buildMeasurement.js +4 -3
  356. package/dist/pipeline/buildMeasurement.js.map +1 -1
  357. package/dist/pipeline/calloutLayout.d.ts +59 -0
  358. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  359. package/dist/pipeline/calloutLayout.js +287 -65
  360. package/dist/pipeline/calloutLayout.js.map +1 -1
  361. package/dist/pipeline/columnBalancing.d.ts +20 -0
  362. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  363. package/dist/pipeline/columnBalancing.js +27 -4
  364. package/dist/pipeline/columnBalancing.js.map +1 -1
  365. package/dist/pipeline/columnsGroup.d.ts +61 -0
  366. package/dist/pipeline/columnsGroup.d.ts.map +1 -0
  367. package/dist/pipeline/columnsGroup.js +88 -0
  368. package/dist/pipeline/columnsGroup.js.map +1 -0
  369. package/dist/pipeline/config.d.ts +28 -0
  370. package/dist/pipeline/config.d.ts.map +1 -1
  371. package/dist/pipeline/config.js +67 -8
  372. package/dist/pipeline/config.js.map +1 -1
  373. package/dist/pipeline/contentWarnings.d.ts +16 -0
  374. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  375. package/dist/pipeline/contentWarnings.js +141 -17
  376. package/dist/pipeline/contentWarnings.js.map +1 -1
  377. package/dist/pipeline/floatPlacement.d.ts +48 -4
  378. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  379. package/dist/pipeline/floatPlacement.js +43 -5
  380. package/dist/pipeline/floatPlacement.js.map +1 -1
  381. package/dist/pipeline/floatSlots.d.ts +16 -3
  382. package/dist/pipeline/floatSlots.d.ts.map +1 -1
  383. package/dist/pipeline/floatSlots.js +15 -6
  384. package/dist/pipeline/floatSlots.js.map +1 -1
  385. package/dist/pipeline/flowColumns.d.ts +41 -0
  386. package/dist/pipeline/flowColumns.d.ts.map +1 -0
  387. package/dist/pipeline/flowColumns.js +116 -0
  388. package/dist/pipeline/flowColumns.js.map +1 -0
  389. package/dist/pipeline/headerFooter.d.ts +4 -1
  390. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  391. package/dist/pipeline/headerFooter.js +19 -5
  392. package/dist/pipeline/headerFooter.js.map +1 -1
  393. package/dist/pipeline/measureContentBlock.d.ts +20 -1
  394. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  395. package/dist/pipeline/measureContentBlock.js +35 -11
  396. package/dist/pipeline/measureContentBlock.js.map +1 -1
  397. package/dist/pipeline/partPalette.d.ts +3 -0
  398. package/dist/pipeline/partPalette.d.ts.map +1 -1
  399. package/dist/pipeline/partPalette.js +5 -0
  400. package/dist/pipeline/partPalette.js.map +1 -1
  401. package/dist/pipeline/placement.d.ts +19 -0
  402. package/dist/pipeline/placement.d.ts.map +1 -1
  403. package/dist/pipeline/placement.js +12 -2
  404. package/dist/pipeline/placement.js.map +1 -1
  405. package/dist/pipeline/resourceLayout.d.ts +41 -0
  406. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  407. package/dist/pipeline/resourceLayout.js +148 -18
  408. package/dist/pipeline/resourceLayout.js.map +1 -1
  409. package/dist/pipeline/styles.d.ts.map +1 -1
  410. package/dist/pipeline/styles.js +8 -5
  411. package/dist/pipeline/styles.js.map +1 -1
  412. package/dist/pipeline/textWrap.d.ts +94 -0
  413. package/dist/pipeline/textWrap.d.ts.map +1 -0
  414. package/dist/pipeline/textWrap.js +188 -0
  415. package/dist/pipeline/textWrap.js.map +1 -0
  416. package/dist/pipeline/toc.d.ts.map +1 -1
  417. package/dist/pipeline/toc.js +6 -1
  418. package/dist/pipeline/toc.js.map +1 -1
  419. package/dist/pipeline/verseLines.d.ts +22 -2
  420. package/dist/pipeline/verseLines.d.ts.map +1 -1
  421. package/dist/pipeline/verseLines.js +52 -4
  422. package/dist/pipeline/verseLines.js.map +1 -1
  423. package/dist/preflight.d.ts +46 -1
  424. package/dist/preflight.d.ts.map +1 -1
  425. package/dist/preflight.js +42 -7
  426. package/dist/preflight.js.map +1 -1
  427. package/dist/svg/fontRegistry.d.ts +66 -0
  428. package/dist/svg/fontRegistry.d.ts.map +1 -0
  429. package/dist/svg/fontRegistry.js +294 -0
  430. package/dist/svg/fontRegistry.js.map +1 -0
  431. package/dist/svg/fonts.d.ts +140 -0
  432. package/dist/svg/fonts.d.ts.map +1 -0
  433. package/dist/svg/fonts.js +642 -0
  434. package/dist/svg/fonts.js.map +1 -0
  435. package/dist/svg/image.d.ts +44 -0
  436. package/dist/svg/image.d.ts.map +1 -0
  437. package/dist/svg/image.js +67 -0
  438. package/dist/svg/image.js.map +1 -0
  439. package/dist/table/booktabs.d.ts +37 -0
  440. package/dist/table/booktabs.d.ts.map +1 -0
  441. package/dist/table/booktabs.js +82 -0
  442. package/dist/table/booktabs.js.map +1 -0
  443. package/dist/types.d.ts +365 -10
  444. package/dist/types.d.ts.map +1 -1
  445. package/dist/util/stableHash.d.ts +20 -0
  446. package/dist/util/stableHash.d.ts.map +1 -0
  447. package/dist/util/stableHash.js +59 -0
  448. package/dist/util/stableHash.js.map +1 -0
  449. package/dist/vdt.d.ts +249 -9
  450. package/dist/vdt.d.ts.map +1 -1
  451. package/dist/vdt.js +3 -2
  452. package/dist/vdt.js.map +1 -1
  453. package/dist/word/index.d.ts +1 -1
  454. package/dist/word/index.d.ts.map +1 -1
  455. package/dist/word/index.js +1 -1
  456. package/dist/word/index.js.map +1 -1
  457. package/dist/word/toMarkdown.d.ts +10 -0
  458. package/dist/word/toMarkdown.d.ts.map +1 -1
  459. package/dist/word/toMarkdown.js +16 -1
  460. package/dist/word/toMarkdown.js.map +1 -1
  461. package/dist/worker/client.d.ts +8 -0
  462. package/dist/worker/client.d.ts.map +1 -1
  463. package/dist/worker/client.js +22 -0
  464. package/dist/worker/client.js.map +1 -1
  465. package/dist/worker/fonts.d.ts +1 -13
  466. package/dist/worker/fonts.d.ts.map +1 -1
  467. package/dist/worker/fonts.js +3 -61
  468. package/dist/worker/fonts.js.map +1 -1
  469. package/dist/worker/layout.worker.js +23 -16
  470. package/dist/worker/layout.worker.js.map +1 -1
  471. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { withBitmapResolutions } from '../bitmapResolution';
1
2
  import { plainTitleBreaks } from '../parse/inlineFormatting';
2
3
  import { dimensionToPx } from '../units';
3
4
  import { createVDTDocument, createVDTBlock, createBoundingBox, createVDTColumn, pageIsMirrored, pageIsVertical, flowRectToPage, } from '../vdt';
@@ -14,7 +15,10 @@ import { stampCentralBaselines } from './verticalMetrics';
14
15
  import { orientLinesToFrames } from './mirrorFrame';
15
16
  import { verseMarginTopPx } from './buildBlockKind';
16
17
  import { verseLinesSettings, versesLineByLine } from './verseLines';
17
- 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';
18
22
  import { asciiDigits } from '../arabicNumerals';
19
23
  import { getMeasureDirection, setMeasureDirection, shiftLineX } from '../measure/bidiLines';
20
24
  import { createHeadingLevelResolver, deriveSectionGeometryConfig, sectionWritingMode, deriveSectionMeasureContext, headingIsHidden, headingIsNumbered, headingMarksFor, headingStyleOf, numberTitlesFor, planHeadingSections, } from './headingStyles';
@@ -24,12 +28,13 @@ import { bookCitationContexts, needsCitationContext } from '../citations/context
24
28
  import { anchorTargetsOf, hasAnchorRefs, printsAnchorPages } from './crossRefs';
25
29
  import { applyStatementNumbering, numberStatements, outlineLookup } from './statementNumbering';
26
30
  import { SPAN_EMBED_STYLE_ID, isPageSpanStrip, spanEmbedStyle, wrapPageSpanEmbeds } from './spanEmbeds';
31
+ import { flowColumnsStyles, isFlowColumnsStyle, wrapFlowColumns } from './flowColumns';
27
32
  import { anchorOutline, computeOutline, hasIndexDirective, hasTocDirective, headingNumberingOptions, headingTemplatesOf, outlineFromDoc, sameOutline } from './outline';
28
33
  import { expandTocDirectives } from './toc';
29
34
  import { expandIndexDirectives, locateIndexMarks } from './indexDirective';
30
35
  import { resolveBodyStyle, resolveBlockquoteStyle, resolveParagraphStyle } from './styles';
31
36
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, listBulletPosition, listItemGapPx, listItemSpacingPx, mirrorListMarker, } from './lists';
32
- 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';
33
38
  import { chooseParagraphSplit } from './orphanWidow';
34
39
  import { layoutComicPage, comicPageLayoutWarnings } from '../comics/layoutPage';
35
40
  import { comicStripExtent, comicStripOffset, comicStripPlacement, comicStripWidth, layoutComicStrip } from '../comics/strip';
@@ -40,25 +45,28 @@ import { containerStyle, paragraphStyleIdOf, planParagraphContainers } from './p
40
45
  import { appendChapterEndNotes, footnoteIdsOfLines, footnoteParagraphStyle, noteContentBlock, noteNumberPositionOf, numberFootnotes, footnoteFormatOf, numberFootnotesByPlacement, sameFootnoteNumbers, splitFootnoteDefinitions, withFootnoteStyle, } from './footnotes';
41
46
  import { planParts, derivePartMeasureContext } from './parts';
42
47
  import { layoutCallout, offsetBlock, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
43
- import { layoutCaptionText, layoutResourceBlock, planTableSlice } from './resourceLayout';
48
+ import { fillGalley, galleyBreaks } from './columnsGroup';
49
+ import { chooseTableSlice, layoutCaptionText, layoutResourceBlock, MIN_TAIL_ROWS } from './resourceLayout';
44
50
  import { computeFloatPlan, floatedResourceIds, floatColumnCount, } from './floatPlacement';
45
- import { enumerateCurrentPageSlots, measureFloatBand, floatGapAbove, measureSideStack, clearSideObstacles, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
51
+ import { enumerateCurrentPageSlots, measureFloatBand, floatGapAbove, floatGapBelow, measureSideStack, clearSideObstacles, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
46
52
  import { computeHeadingContext, computeResourceNumberingState, } from './resourceNumbering';
47
53
  import { documentLocale, effectiveResourceTypes } from '../defaults/resourceTypes';
48
54
  import { resolveCalloutStylesConfig } from '../defaults/calloutStyles';
49
55
  import { codeBoxStyle, codeParseOptions, wrapCodeBlocks } from './codeBlocks';
50
56
  import { buildCodeLineNumbers, codeOverflowWarnings } from './codeLines';
51
57
  import { pickTableStyle } from '../defaults/tableStyle';
58
+ import { DEFAULT_FLOAT_MIN_SCALE } from '../defaults/layout';
52
59
  import { buildLineNumbers, lineNumberWarnings } from './lineNumbers';
53
60
  import { buildHeadersAndFooters, defaultOpenerTitle, headingDesignBoxes, measureDefaultOpenerHeight, measureHeadingDesign } from './headerFooter';
54
61
  import { flowColorValues } from './partPalette';
55
62
  import { chapterNumberCounter, leadingBoldText } from './placeholders';
56
- 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';
57
64
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, bareBandTop, resolveBandCapsGen, drainPasses, resolveTrailingCapsGen, bandCapLinesAroundZone, } from './bandCaps';
58
65
  import { raggedLooseLines } from './raggedLines';
59
- import { cjkLooseLineWarnings, collectContentWarnings, dropCapWarnings, joiningLetterSpacingWarnings, locateContentWarnings, wordOverflowWarnings } from './contentWarnings';
66
+ import { cjkLooseLineWarnings, collectContentWarnings, designTruncationWarnings, dropCapWarnings, floatShrinkWarnings, joiningLetterSpacingWarnings, locateContentWarnings, wordOverflowWarnings } from './contentWarnings';
60
67
  import { dropCapSinkAfter } from './dropCap';
61
68
  import { mostlyJoiningScript } from '../measure/joining';
69
+ import { composesAsCjk } from '../measure/cjkCompose';
62
70
  import { annotateDocument } from '../cjkMarks';
63
71
  import { annotateArabicMarks } from '../arabicMarks';
64
72
  import { literalBreaksFor } from './hardBreaks';
@@ -67,9 +75,8 @@ import { overlineEmphasis } from '../emphasisOverline';
67
75
  import { withBookTitleBrackets } from './annotations';
68
76
  import { paperByBlock, stampPagePaper } from './paper';
69
77
  import { headingLineSpanBand } from './lineSpan';
70
- /** Tolerance for "does this block fit" checks against a column's free
71
- * height, absorbing floating-point drift between grid multiples. */
72
- const FIT_EPS = 0.01;
78
+ import { columnCursorY, insetsBefore, minTextWidthPx, openColumnWrap, resolveCalloutWrap, resolveResourceWrap, runsBesideWrap, sameInsetsFrom, settleColumnWraps, transparentToWrap, wrapGapPx, wrapInsetSteps } from './textWrap';
79
+ import { lineInsetsAt } from '../measure/types';
73
80
  /** Room (px) under closing boxes a balancing pass must close to be kept on
74
81
  * that ground alone (see `boxRoomIn`). */
75
82
  const BOX_ROOM_EPS_PX = 0.5;
@@ -97,6 +104,16 @@ function floatPagesOf(doc) {
97
104
  }
98
105
  return out;
99
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
+ }
100
117
  /** `{chapterNumber}` at each content block: the value of the last level-1
101
118
  * heading at or before it ('' before the first), by the counter the
102
119
  * running heads use over the placed blocks (`computeChapterNumbers`) —
@@ -144,7 +161,7 @@ function chapterNumbersOfContent(blocks, resolved, prefixes, values, ordinalOffs
144
161
  * line less (EF-129: asked for one more, it could only match the target by
145
162
  * falling back to its optimum, unchecked).
146
163
  */
147
- function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome) {
164
+ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome, offGrid) {
148
165
  const base = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, { styleOverride });
149
166
  if (!base)
150
167
  return null;
@@ -161,25 +178,39 @@ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverri
161
178
  // A paragraph mostly in a joining script (Arabic…) is not tracked: its
162
179
  // words take no letter-spacing (`measure/joining.ts`), so only the first
163
180
  // rung, word spacing alone, can gain it the line.
164
- 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 } : {};
165
189
  const maxWordSpacing = ctx.resolved.bodyText.maxWordSpacing + 1e-9;
166
190
  // A CJK line spread past its tracking cap (`cjkLoose`) is past the
167
191
  // limit too.
168
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);
169
197
  for (const tracking of ladder) {
170
198
  const loose = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, {
171
199
  styleOverride,
172
200
  looseness,
173
201
  trackingEm: tracking > 0 ? tracking / 1000 : undefined,
202
+ ...gridCap,
174
203
  });
175
- if (loose
176
- && loose.measured.lines.length === target
177
- && !loose.measured.lastLineRunt
178
- && withinLimit(loose.measured.lines)) {
204
+ if (gains(loose)) {
179
205
  looseOutcome.set(blockIdx, tracking);
180
206
  return loose;
181
207
  }
182
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
+ }
183
214
  looseOutcome.set(blockIdx, null);
184
215
  return base;
185
216
  }
@@ -216,7 +247,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
216
247
  }
217
248
  }
218
249
  function placeDocumentPass(content, config, cache, options, hints) {
219
- const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps, captionUnder, floatPages } = hints;
250
+ const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps, captionUnder, floatPages, citingPageFloats } = hints;
220
251
  const captionUnderProposals = new Set();
221
252
  /** Side columns whose foot a figure's side caption has cut, by the figure. */
222
253
  const asideCutBy = new WeakMap();
@@ -225,6 +256,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
225
256
  * shortened it, so what does not fit it may still fit a full column. */
226
257
  const shortSideColumn = (page, col) => col.bbox.height < page.contentArea.height - 0.5;
227
258
  const resolved = resolveAllConfig(config);
259
+ // Bitmaps sized by the document's `layout.bitmapResolution` carry it as
260
+ // their own `resolution` from here on (#631); under the default policy
261
+ // the content is untouched.
262
+ const stampedResources = content.resources && withBitmapResolutions(content.resources, resolved.layout.bitmapResolution);
263
+ if (stampedResources && stampedResources !== content.resources)
264
+ content = { ...content, resources: stampedResources };
228
265
  // Tracking rungs a loose paragraph may climb: none, then 5‰ steps up to
229
266
  // the cap (`headings.balancing.maxTracking`, thousandths of an em), the
230
267
  // cap itself always included. Only the smallest rung that gains the line
@@ -237,6 +274,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
237
274
  trackingLadder.push(balancingCfg.maxTracking);
238
275
  }
239
276
  const looseOutcome = new Map();
277
+ const looseOffGrid = new Set();
240
278
  // Lines gained so far per column budget group (see `LooseBudget`).
241
279
  const looseGained = new Map();
242
280
  // Level configs with heading-style overrides merged in (`{style="…"}`).
@@ -390,7 +428,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
390
428
  // An inline `::resource` placed `here` with `span: 'page'` spans the
391
429
  // page where it stands (#535): each goes in a frameless page-span box,
392
430
  // set by the span-block path like any other.
393
- 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 ?? []));
394
435
  // Code listings (#624): each in a box of the code style, which the
395
436
  // callout machinery places and splits.
396
437
  const codeBoxes = wrapCodeBlocks(spanEmbeds.blocks, resolved);
@@ -435,13 +476,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
435
476
  const syntheticBoxes = [
436
477
  ...(spanEmbeds.wrapped ? [spanEmbedStyle(bodyStyle.lineHeightPx, resolved.layout.inlineResourceGap !== 'above')] : []),
437
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
+ : []),
438
484
  ];
439
485
  const calloutStyles = syntheticBoxes.length > 0
440
486
  ? [...resolved.calloutStyles, ...resolveCalloutStylesConfig(syntheticBoxes, resolved.bodyText, resolved.headings, resolved.unorderedLists, config?.locale)]
441
487
  : resolved.calloutStyles;
442
488
  // A box nested in another looks its style up in the configuration it is
443
489
  // laid out with: the listings' box among them.
444
- const calloutResolved = codeBoxes.wrapped ? { ...resolved, calloutStyles } : resolved;
490
+ const calloutResolved = codeBoxes.wrapped || flowGroups.wrapped ? { ...resolved, calloutStyles } : resolved;
445
491
  const blockquoteStyle = resolveBlockquoteStyle(resolved);
446
492
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
447
493
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
@@ -472,7 +518,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
472
518
  // it asks for is not applied there. Decided per block, by the writing
473
519
  // mode of the styled section the reference sits in (a horizontal
474
520
  // appendix of a vertical book turns its figures as asked).
475
- const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes, incorporated, (blockIdx) => sectionWritingMode(sectionPlan, resolved, blockIdx) === 'vertical-rl');
521
+ const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes, incorporated, (blockIdx) => sectionWritingMode(sectionPlan, resolved, blockIdx) === 'vertical-rl', resolved.layout.floatShrink, resolved.layout.wrap, resolved.layout.floatsAtCitingPage);
476
522
  const floatedIds = floatedResourceIds(floatPlan, incorporated, resources, resourceTypes);
477
523
  const floatsByFirstBlock = new Map();
478
524
  for (const f of floatPlan) {
@@ -489,6 +535,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
489
535
  * page: they take the side column of the next page the flow opens, in
490
536
  * order (see `placeCalloutSide`). */
491
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;
492
541
  /** `span: 'side'` boxes of a style with `sideAtColumnEnd: 'after'`
493
542
  * (EF-161), and the side boxes fenced behind them, waiting for the text
494
543
  * after their fence to land (see `settleAwaitingSideBoxes`). Each keeps
@@ -835,7 +884,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
835
884
  /** Pages whose head or foot a floated box took (see the gallery rule in
836
885
  * `placeFloatInColumns`). */
837
886
  const calloutBandPages = new Set();
838
- const enqueueCalloutFloat = (startIdx, plan, style, L, placement, span) => {
887
+ const enqueueCalloutFloat = (startIdx, plan, style, L, placement, span,
888
+ /** Text wraps round the box (#627): a one-column float at its side. */
889
+ wrap) => {
839
890
  const key = `${CALLOUT_FLOAT_PREFIX}${startIdx}`;
840
891
  const columns = plan.attrs.columns !== undefined ? floatColumnCount(plan.attrs.columns) : style.columns;
841
892
  // A keep-with-next replay passes the fence again: enqueue once.
@@ -851,6 +902,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
851
902
  span: span === 'page' ? 'page' : 'column',
852
903
  ...(span === 'column' && columns > 1 ? { columns } : {}),
853
904
  callout: { startIdx },
905
+ ...(wrap && span === 'column' && columns === 1
906
+ ? { widthFraction: wrap.width, align: wrap.side, wrap: wrap.side, ...(wrap.gap ? { wrapGap: wrap.gap } : {}) }
907
+ : {}),
854
908
  ...(col ? { refPageIndex: page.index, refY: col.bbox.y + (col.bbox.height - col.availableHeight) } : {}),
855
909
  });
856
910
  }
@@ -869,8 +923,6 @@ function placeDocumentPass(content, config, cache, options, hints) {
869
923
  };
870
924
  const floatGapPx = bodyStyle.lineHeightPx;
871
925
  const minTextPx = bodyStyle.lineHeightPx * 3;
872
- /** Fewest body rows the closing slice of a split table carries. */
873
- const MIN_TAIL_ROWS = 3;
874
926
  /** Offset a resolved resource block's caption/table geometry from
875
927
  * block-relative to absolute page coordinates (mirrors inline placement). */
876
928
  const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
@@ -918,7 +970,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
918
970
  };
919
971
  const rotationKey = (rotated) => rotated ? `:${rotated.direction}${rotated.length.toFixed(2)}${rotated.upright ? 'u' : ''}` : '';
920
972
  const asideKey = (a) => (a ? `:aside${a.dx.toFixed(1)}x${a.width.toFixed(1)}${a.alignBottom ? 'b' : 't'}${(a.offsetY ?? 0).toFixed(1)}` : '');
921
- const layoutFloat = (resourceId, width, slice, rotated, aside) => {
973
+ const capKey = (cap) => (cap ? `:cap${cap.maxBodyHeight.toFixed(2)}@${cap.minScale}` : '');
974
+ const layoutFloat = (resourceId, width, slice, rotated, aside, cap) => {
922
975
  const resource = resourceById.get(resourceId);
923
976
  if (!resource)
924
977
  return null;
@@ -939,6 +992,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
939
992
  ...(rotated ? (rotated.upright ? { upright: { maxLength: rotated.length } } : { rotate: rotated.direction, rotatedLength: rotated.length }) : {}),
940
993
  ...(aside ? { captionAside: aside } : {}),
941
994
  ...(flexPx > 0 ? { bodyHeightDelta: flexPx } : {}),
995
+ ...(cap && !rotated ? { maxBodyHeight: cap.maxBodyHeight, minBodyScale: cap.minScale } : {}),
942
996
  });
943
997
  };
944
998
  /** The rotation of a pending float on a band whose columns keep `avail`
@@ -989,8 +1043,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
989
1043
  /** Height (and caption baseline) of a float at a given width, memoised —
990
1044
  * fit checks run for every pending float on every loop iteration. */
991
1045
  const floatMeasureMemo = new Map();
992
- const measureFloat = (resourceId, width, slice, rotated, aside) => {
993
- const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}${rotationKey(rotated)}${asideKey(aside)}`;
1046
+ const measureFloat = (resourceId, width, slice, rotated, aside, cap) => {
1047
+ const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}${rotationKey(rotated)}${asideKey(aside)}${capKey(cap)}`;
994
1048
  const memo = floatMeasureMemo.get(key);
995
1049
  if (memo !== undefined)
996
1050
  return memo;
@@ -1024,7 +1078,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1024
1078
  floatMeasureMemo.set(key, mc);
1025
1079
  return mc;
1026
1080
  }
1027
- const laid = layoutFloat(resourceId, width, slice, rotated, aside);
1081
+ const laid = layoutFloat(resourceId, width, slice, rotated, aside, cap);
1028
1082
  let m = null;
1029
1083
  if (laid && laid.block.rotation) {
1030
1084
  // A rotated block takes its band whole: no caption baseline to align,
@@ -1045,10 +1099,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
1045
1099
  if (lastBaseline === undefined || ln.baseline > lastBaseline)
1046
1100
  lastBaseline = ln.baseline;
1047
1101
  }
1102
+ const picture = rb.kind === 'bitmap' || rb.kind === 'svg' || rb.kind === 'video';
1048
1103
  m = {
1049
1104
  height: laid.totalHeight,
1050
1105
  ...(lastBaseline !== undefined ? { lastCaptionBaseline: lastBaseline } : {}),
1051
1106
  ...(laid.asideHeight !== undefined ? { asideHeight: laid.asideHeight } : {}),
1107
+ ...(picture ? { body: { height: rb.bodyRect.height, scale: rb.shrinkScale ?? 1 } } : {}),
1052
1108
  };
1053
1109
  }
1054
1110
  floatMeasureMemo.set(key, m);
@@ -1064,7 +1120,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
1064
1120
  flushEnd = false, aside,
1065
1121
  /** The page is a verso of mirrored margins (a floated box's `'outer'`
1066
1122
  * corner icon hangs on the left there). */
1067
- mirrored = false) => {
1123
+ mirrored = false,
1124
+ /** A picture scaled to its slot (`placement.shrink`). */
1125
+ cap) => {
1068
1126
  const strip = comicStripOf(resourceId);
1069
1127
  if (strip) {
1070
1128
  const block = buildComicStripBlock(strip.idx, strip.source, x, width, contentArea.height - 2 * floatGapPx, cursor.pageIndex);
@@ -1077,7 +1135,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1077
1135
  calloutFloatResults.set(result.frame, { result, startIdx, plan: cf.plan });
1078
1136
  return { block: result.frame, height: result.totalHeight };
1079
1137
  }
1080
- const laid = layoutFloat(resourceId, width, slice, rotated, aside);
1138
+ const laid = layoutFloat(resourceId, width, slice, rotated, aside, cap);
1081
1139
  if (!laid)
1082
1140
  return null;
1083
1141
  const { block: rb, totalHeight } = laid;
@@ -1116,6 +1174,52 @@ function placeDocumentPass(content, config, cache, options, hints) {
1116
1174
  /** Pages a `:::pagebreak{center}` opened (#424). */
1117
1175
  const centredPages = new Set();
1118
1176
  const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
1177
+ /** Text wrap (#627): floats set in a band at the head or foot of one
1178
+ * column that text may run beside, per column. The band stays a band
1179
+ * until running text comes to take it back (`openFloatWraps`). */
1180
+ /** The room a wrapped item takes from the lines beside it (#627): its
1181
+ * width and gap, in whole characters of the body under a CJK character
1182
+ * grid (`cjk.grid`), so the lines beside it keep to the grid. */
1183
+ const wrapExclusionWidth = (columnWidth, itemWidth, gap) => {
1184
+ const w = itemWidth + gap;
1185
+ const em = bodyStyle.fontSizePx;
1186
+ return Math.min(columnWidth, resolved.cjk.grid.enabled && em > 0 ? Math.ceil((w - 0.01) / em) * em : w);
1187
+ };
1188
+ /** The `textWrap` warnings of this pass (#627). */
1189
+ const textWrapNotes = [];
1190
+ const floatWraps = new Map();
1191
+ /** Give the bands of the wrapped floats of `col` back to the flow, as
1192
+ * exclusions the text runs beside: a float at the head of a column the
1193
+ * text has not entered yet, one at its foot the text has not reached. */
1194
+ const openFloatWraps = (col) => {
1195
+ const list = floatWraps.get(col);
1196
+ if (!list)
1197
+ return;
1198
+ floatWraps.delete(col);
1199
+ for (const w of list) {
1200
+ const r = { ...reservedOf(col) };
1201
+ if (w.position === 'top') {
1202
+ // A block already stands under it, or another band between: it
1203
+ // keeps its band.
1204
+ if (col.blocks.length > 0 || Math.abs(w.ex.y + w.need - col.bbox.y) > 0.5)
1205
+ continue;
1206
+ col.bbox.y -= w.need;
1207
+ col.bbox.height += w.need;
1208
+ col.availableHeight += w.need;
1209
+ r.top = Math.max(0, r.top - w.need);
1210
+ openColumnWrap(col, w.ex, 'top');
1211
+ }
1212
+ else {
1213
+ if (uncappedBottoms.has(col) || Math.abs(col.bbox.y + col.bbox.height - w.ex.y) > 0.5 || columnCursorY(col) > w.ex.y + 0.01)
1214
+ continue;
1215
+ col.bbox.height += w.need;
1216
+ col.availableHeight += w.need;
1217
+ r.bottom = Math.max(0, r.bottom - w.need);
1218
+ openColumnWrap(col, w.ex, 'bottom');
1219
+ }
1220
+ floatReserved.set(col, r);
1221
+ }
1222
+ };
1119
1223
  const floatHeadOf = (col) => reservedOf(col).top;
1120
1224
  /** `BandCap.headPx` for a cap proposed on `cols`: how far the float bands
1121
1225
  * every column holds at its head put the band's top below its bare one. */
@@ -1177,28 +1281,6 @@ function placeDocumentPass(content, config, cache, options, hints) {
1177
1281
  // A top band rounds up to the grid: the tallest float it holds within
1178
1282
  // `avail` is the grid multiple below it, less the gap.
1179
1283
  const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
1180
- let end = planTableSlice(metrics, startRow, (rotated ? width : hMax) - overhead);
1181
- const floor = firstBody + 1;
1182
- if (end < floor) {
1183
- // Nothing fits. A fresh page carries the smallest slice anyway (it
1184
- // overflows, as a dominating figure would) rather than stall the
1185
- // queue; a slot of the current page is simply not this table's.
1186
- if (mode === 'strict')
1187
- return 'none';
1188
- end = floor;
1189
- }
1190
- // A last page holding a row or two under a repeated header reads as a
1191
- // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
1192
- // handing some back from this one (at a breakable edge, not under a
1193
- // group head), when this slice can spare them.
1194
- const tail = rowCount - end;
1195
- if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
1196
- let e = rowCount - MIN_TAIL_ROWS;
1197
- while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
1198
- e--;
1199
- if (e >= floor && e >= end - MIN_TAIL_ROWS)
1200
- end = e;
1201
- }
1202
1284
  const fits = (slice) => {
1203
1285
  const measure = measureFloat(f.resourceId, width, slice, rotated);
1204
1286
  if (!measure)
@@ -1208,23 +1290,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
1208
1290
  const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
1209
1291
  return need <= avail + 0.01;
1210
1292
  };
1211
- const sliceFor = (e) => ({
1212
- startRow,
1213
- endRow: e,
1214
- 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,
1215
1301
  });
1216
- let slice = sliceFor(end);
1217
- // The caption may wrap differently with its suffix, the closing slice
1218
- // carries the note instead of the marker: verify, backing off a row at
1219
- // a time (over breakable edges) when the band still overflows.
1220
- for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
1221
- do
1222
- end--;
1223
- while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
1224
- slice = sliceFor(end);
1225
- }
1226
- if (mode === 'strict' && !fits(slice))
1302
+ if (!slice)
1227
1303
  return 'none';
1304
+ const end = slice.endRow;
1228
1305
  const rest = slice.continues ? { ...f, startRow: end } : undefined;
1229
1306
  return rest ? { slice, rest } : { slice };
1230
1307
  };
@@ -1369,6 +1446,50 @@ function placeDocumentPass(content, config, cache, options, hints) {
1369
1446
  built.block.contentIndex = anchorIdx;
1370
1447
  (page.floats ??= []).push(built.block);
1371
1448
  };
1449
+ /**
1450
+ * A picture float scaled to `room` px of a slot instead of moving on
1451
+ * (`placement.shrink`, #626): the tallest body that keeps its band
1452
+ * within the room. A top band rounds up to the grid, so the float may
1453
+ * stand as tall as the grid multiple within the room, less its gap; a
1454
+ * bottom band (its caption's last baseline on the grid) and a side
1455
+ * stack give the room back px for px. The caption's height may change
1456
+ * with the picture's width (`captionMeasure: 'body'`) and a safe area
1457
+ * crops before it scales, so the cap is refined over a few rounds. The
1458
+ * scale never goes below the float's `minScale`; `fits` is false when
1459
+ * even that does not hold the room. Null for a float that does not
1460
+ * scale (no `shrink`, not an upright picture, the rest of a table) or
1461
+ * already fits.
1462
+ */
1463
+ const shrinkToRoom = (f, width, aside, full, room, topBand, bandOf) => {
1464
+ if (!f.shrink || !full.body || f.rotate || f.startRow)
1465
+ return null;
1466
+ const minScale = f.minScale ?? DEFAULT_FLOAT_MIN_SCALE;
1467
+ const excessOf = (m, band) => topBand
1468
+ ? m.height + floatGapBelow(m, floatGapPx) - Math.floor((room + 0.01) / baselineGrid) * baselineGrid
1469
+ : band.need - room;
1470
+ let measure = full;
1471
+ let band = bandOf(full);
1472
+ let cap;
1473
+ for (let round = 0; round < 4; round++) {
1474
+ const excess = excessOf(measure, band);
1475
+ if (excess <= 0.01)
1476
+ break;
1477
+ const next = { maxBodyHeight: Math.max(0, measure.body.height - excess), minScale };
1478
+ if (cap && next.maxBodyHeight >= cap.maxBodyHeight - 0.01)
1479
+ break;
1480
+ cap = next;
1481
+ const m = measureFloat(f.resourceId, width, undefined, undefined, aside, cap);
1482
+ if (!m?.body)
1483
+ return null;
1484
+ measure = m;
1485
+ band = bandOf(m);
1486
+ if (m.body.scale <= minScale + 1e-6)
1487
+ break;
1488
+ }
1489
+ if (!cap)
1490
+ return null;
1491
+ return { measure, ...band, cap, fits: excessOf(measure, band) <= 0.01 };
1492
+ };
1372
1493
  const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false, side = false, refY) => {
1373
1494
  const first = targetCols[0];
1374
1495
  const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap, side, refY);
@@ -1377,37 +1498,78 @@ function placeDocumentPass(content, config, cache, options, hints) {
1377
1498
  const { width, xLeft, rotated, aside, sideCol } = probe;
1378
1499
  let { slice, measure, need, y } = probe;
1379
1500
  let rest;
1501
+ /** A picture scaled to this slot (`placement.shrink`, #626). */
1502
+ let cap;
1503
+ const bandOf = (m) => measureFloatBand(position, m, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => (anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms)));
1380
1504
  /** The float was cut to this slot (a table slice). */
1381
1505
  let cut = false;
1382
1506
  /** A rotated block wider than the band (its upright height). */
1383
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
+ };
1384
1527
  if (side) {
1385
1528
  // The side column's stack: the float goes under what the column holds
1386
1529
  // when the rest of the column takes it; otherwise it waits for the
1387
1530
  // side column of the next page — where it is set anyway, overflowing,
1388
1531
  // when even an empty column cannot hold it (a dominating figure).
1532
+ let cap;
1389
1533
  if (need > first.availableHeight + 0.01) {
1390
1534
  // A column that already holds something — a float, a box, or a
1391
1535
  // heading design standing in it — is no empty column to overflow.
1392
1536
  const holds = sideUsedBottom(first) > first.bbox.y + 0.5 || (sideObstacles.get(first)?.length ?? 0) > 0;
1393
- if (mode === 'strict' || holds)
1394
- return 'defer';
1395
1537
  // An empty column shortened by a band above it (a page-span box,
1396
1538
  // a top float) is no measure of the float: when a full column
1397
1539
  // would hold it, it waits for one instead of overflowing this one.
1398
- if (shortSideColumn(page, first) && measure.height <= page.contentArea.height + 0.01)
1399
- return 'defer';
1400
- // Overflowing a column a side caption has shortened: the next pass
1401
- // sets that caption under its figure and gives the column back.
1402
- const cutBy = asideCutBy.get(first);
1403
- if (cutBy !== undefined)
1404
- captionUnderProposals.add(cutBy);
1540
+ const waitsForFull = shortSideColumn(page, first) && measure.height <= page.contentArea.height + 0.01;
1541
+ const lastResort = mode === 'fresh' && !holds && !waitsForFull;
1542
+ // A picture scaled to the room the column keeps (#626): any slot
1543
+ // with `'slot'`, when it holds it at its smallest scale or more;
1544
+ // the empty column it would overflow with `'page'` too, at its
1545
+ // smallest scale if need be.
1546
+ if (f.shrink === 'slot' || (f.shrink && lastResort)) {
1547
+ const s = shrinkToRoom(f, width, undefined, measure, first.availableHeight, false, (m) => measureSideStack(m, first, refY, page.contentArea, baselineGrid, floatGapPx, sideObstacles.get(first)));
1548
+ if (s && (s.fits || lastResort))
1549
+ ({ measure, need, y, cap } = s);
1550
+ }
1551
+ if (need > first.availableHeight + 0.01) {
1552
+ if (!lastResort)
1553
+ return 'defer';
1554
+ // Overflowing a column a side caption has shortened: the next pass
1555
+ // sets that caption under its figure and gives the column back.
1556
+ const cutBy = asideCutBy.get(first);
1557
+ if (cutBy !== undefined)
1558
+ captionUnderProposals.add(cutBy);
1559
+ }
1405
1560
  }
1406
- const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated);
1561
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, false, undefined, false, cap);
1407
1562
  if (!built)
1408
1563
  return 'skip';
1409
1564
  first.availableHeight = Math.max(0, first.availableHeight - need);
1410
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
+ }
1411
1573
  floatsPlaced++;
1412
1574
  return 'placed';
1413
1575
  }
@@ -1431,9 +1593,26 @@ function placeDocumentPass(content, config, cache, options, hints) {
1431
1593
  const gallery = need <= minAvail + 0.01
1432
1594
  && f.refPageIndex !== undefined && f.refPageIndex < page.index
1433
1595
  && calloutBandPages.has(page) && !calloutFloatOf(f.resourceId);
1434
- if (!gallery)
1435
- return 'defer';
1436
- galleryFill = true;
1596
+ if (gallery) {
1597
+ galleryFill = true;
1598
+ }
1599
+ else {
1600
+ // A picture that scales to any slot (#626) takes the room left
1601
+ // beside the other band, the text room kept, when it holds it
1602
+ // at its smallest scale or more.
1603
+ const s = f.shrink === 'slot' ? shrinkToRoom(f, width, aside, measure, minAvail - minTextPx, position === 'top', bandOf) : null;
1604
+ if (!s?.fits)
1605
+ return 'defer';
1606
+ ({ measure, need, y, cap } = s);
1607
+ }
1608
+ }
1609
+ // Dominating the band: a picture that scales (`'page'` or `'slot'`,
1610
+ // #626) takes the band's room, at its smallest scale if need be —
1611
+ // then it runs past the band, as any dominating float does.
1612
+ if (need > minAvail + 0.01 && f.shrink && !rotated) {
1613
+ const s = shrinkToRoom(f, width, aside, measure, minAvail, position === 'top', bandOf);
1614
+ if (s)
1615
+ ({ measure, need, y, cap } = s);
1437
1616
  }
1438
1617
  if (need > minAvail + 0.01 || tooWide(measure)) {
1439
1618
  // Dominating the band: a table is cut to it (a rotated table, to
@@ -1459,6 +1638,19 @@ function placeDocumentPass(content, config, cache, options, hints) {
1459
1638
  // too wide.
1460
1639
  if (f.rotate || tooWide(measure))
1461
1640
  return 'defer';
1641
+ // A picture that scales to any slot (#626) takes this one when it
1642
+ // holds it at its smallest scale or more (the text room kept next
1643
+ // to another band, as `fitsStrict` keeps it); the loop below checks
1644
+ // the band as for any float.
1645
+ if (f.shrink === 'slot' && measure.body && !rotated) {
1646
+ const room = Math.min(...targetCols.map((c) => c.availableHeight - leadOf(c) - (columnHasFloatBand(page, c) ? minTextPx : 0)));
1647
+ if (need > room + 0.01) {
1648
+ const s = shrinkToRoom(f, width, aside, measure, room, position === 'top', bandOf);
1649
+ if (!s?.fits)
1650
+ return 'defer';
1651
+ ({ measure, need, y, cap } = s);
1652
+ }
1653
+ }
1462
1654
  for (const c of targetCols) {
1463
1655
  if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
1464
1656
  // Trailing cap: the band must lie entirely below the level cut.
@@ -1467,7 +1659,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1467
1659
  continue;
1468
1660
  }
1469
1661
  const hasBand = columnHasFloatBand(page, c);
1470
- if (fitsStrict(need, c, hasBand, minTextPx))
1662
+ if (fitsStrict(need + leadOf(c), c, hasBand, minTextPx))
1471
1663
  continue;
1472
1664
  // The head of an empty column: a splittable table is cut to the
1473
1665
  // column and continues in the next slot — the compositor sets a
@@ -1516,9 +1708,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
1516
1708
  // An upright figure of a vertical page stands at the head of its tier
1517
1709
  // (`placement.align`: `left` the top, `center`, `right` the foot).
1518
1710
  const flush = rotated?.upright ? (f.align === 'center' ? 0.5 : f.align === 'right' ? 1 : 0) : rotated ? rotatedFlushEnd(page) : false;
1519
- const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, flush, aside, mirroredOf(page));
1711
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, flush, aside, mirroredOf(page), cap);
1520
1712
  if (!built)
1521
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;
1522
1717
  // The caption beside the figure takes its band of the side column: a
1523
1718
  // top float's caption is consumed from the stack's head (when the
1524
1719
  // stack is still above it), a bottom float's cuts the column's foot.
@@ -1538,7 +1733,34 @@ function placeDocumentPass(content, config, cache, options, hints) {
1538
1733
  asideCutBy.set(sideCol, f.resourceId);
1539
1734
  }
1540
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;
1541
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
+ }
1542
1764
  const r = { ...reservedOf(col) };
1543
1765
  if (position === 'top') {
1544
1766
  col.bbox.y += need;
@@ -1571,6 +1793,36 @@ function placeDocumentPass(content, config, cache, options, hints) {
1571
1793
  if (galleryFill)
1572
1794
  for (const col of targetCols)
1573
1795
  col.availableHeight = 0;
1796
+ if (wraps && f.wrap) {
1797
+ if (wrapRefused) {
1798
+ textWrapNotes.push({ kind: 'textWrap', reason: wrapRefused, resourceId: f.resourceId, pageIndex: page.index });
1799
+ }
1800
+ else {
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
+ }
1824
+ }
1825
+ }
1574
1826
  commitFloatBlock(page, first, built, xLeft, y, width, f.firstBlockIdx);
1575
1827
  floatsPlaced++;
1576
1828
  return rest ? { rest } : 'placed';
@@ -1618,7 +1870,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1618
1870
  * column float of its sequence still gets the page's foot once that one
1619
1871
  * is set. A float that does not fit holds up the ones behind it in its
1620
1872
  * numbering sequence (see `heldBack`), never the other sequence. */
1621
- const flushFloatsIntoPage = (page) => {
1873
+ const flushPendingFloatsIntoPage = (page) => {
1622
1874
  if (pendingFloats.length === 0) {
1623
1875
  flushSideBoxesIntoPage(page);
1624
1876
  return;
@@ -1706,6 +1958,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
1706
1958
  i++;
1707
1959
  continue;
1708
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
+ }
1709
1967
  // A page-span rest never shares the page of its previous slice.
1710
1968
  if (isPageSpan && f.notBefore?.pageIndex === page.index) {
1711
1969
  i++;
@@ -1823,7 +2081,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
1823
2081
  // refuse (a head-of-page float offered the foot of the current page);
1824
2082
  // floated callouts keep their placement.
1825
2083
  const asked = anyPosition && !f.rotate && !f.callout && f.position !== 'auto' ? { ...f, position: 'auto' } : f;
1826
- let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, asked, capKindOf);
2084
+ let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, asked, capKindOf, openerHeadOf);
1827
2085
  // The rest of a table cut on this page only takes the slots after
1828
2086
  // its previous slice in reading order (never the foot of the column
1829
2087
  // before it; a page-span rest waits for the next page).
@@ -1860,6 +2118,242 @@ function placeDocumentPass(content, config, cache, options, hints) {
1860
2118
  i = settle(i, r);
1861
2119
  }
1862
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
+ };
1863
2357
  /** Reserve floats on each freshly opened content page. Passed only to the
1864
2358
  * content-flow column advances — parity / force-blank pages never get it. */
1865
2359
  const onNewPage = (page) => flushFloatsIntoPage(page);
@@ -1908,15 +2402,28 @@ function placeDocumentPass(content, config, cache, options, hints) {
1908
2402
  // its closing columns — whatever position it asked for: a chapter's
1909
2403
  // last page with room under its text beats a page holding one table.
1910
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();
1911
2408
  let guard = 0;
1912
- while ((pendingFloats.length > 0 || pendingSideBoxes.length > 0) && guard++ < 1000) {
1913
- const before = floatsPlaced;
1914
- const startPageIndex = cursor.pageIndex;
1915
- do {
1916
- advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1917
- } while (cursor.pageIndex === startPageIndex);
1918
- if (floatsPlaced === before)
1919
- 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;
1920
2427
  }
1921
2428
  };
1922
2429
  /** Boxes that left the flow this pass (side column, float band, fixed),
@@ -2313,7 +2820,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
2313
2820
  blk.lines = resetLinePositions(raggedLooseLines(m.measured.lines, noteStyle.textAlign), noteStyle.lineHeightPx).map((line) => ({
2314
2821
  ...line,
2315
2822
  bbox: createBoundingBox(line.bbox.x + x, line.bbox.y + y, line.bbox.width, line.bbox.height),
2316
- ...(line.measure ? { measure: { x: line.measure.x + x, width: line.measure.width } } : {}),
2823
+ ...(line.measure ? { measure: { ...line.measure, x: line.measure.x + x } } : {}),
2317
2824
  baseline: line.baseline + y,
2318
2825
  }));
2319
2826
  const h = blk.lines.length * noteStyle.lineHeightPx;
@@ -2510,6 +3017,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
2510
3017
  * level snaps, or would but for the heading after it). */
2511
3018
  const reserveOpenerBand = (headingCol, h, opener, pending = 0, gridText = true) => {
2512
3019
  const page = doc.pages[cursor.pageIndex];
3020
+ if (opener)
3021
+ openerOwnColumn.set(headingCol, opener);
2513
3022
  for (const otherCol of page.columns) {
2514
3023
  if (otherCol === headingCol)
2515
3024
  continue;
@@ -2539,6 +3048,47 @@ function placeDocumentPass(content, config, cache, options, hints) {
2539
3048
  * the opener, its column, the band's foot, where text right under the
2540
3049
  * opener starts and the float band the column held at its head then. */
2541
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);
2542
3092
  /**
2543
3093
  * The room above the first block of a column headed by an opener band,
2544
3094
  * relative to the column's head, or undefined for any other column (or
@@ -2566,7 +3116,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
2566
3116
  if (!head || col.blocks.length > 0 || reservedOf(col).top !== head.floatTop)
2567
3117
  return undefined;
2568
3118
  let target = head.textTop;
2569
- 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)) {
2570
3122
  const at = head.openerCol.blocks.indexOf(head.opener);
2571
3123
  const next = at >= 0 ? head.openerCol.blocks.slice(at + 1).find((b) => !b.hidden) : undefined;
2572
3124
  if (next && (next.type === 'heading' || next.type === 'mathDisplay')) {
@@ -2690,7 +3242,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
2690
3242
  /** `rollbackTrailingBlocks` that also drops a rolled-back heading's
2691
3243
  * side-column obstacles: the heading is set again further on. */
2692
3244
  const rollbackHeadings = (col) => {
2693
- const popped = rollbackTrailingBlocks(col, doc.blocks, isFreeHeading);
3245
+ const popped = rollbackTrailingBlocks(col, doc.blocks, (b) => isFreeHeading(b) && !holdsHeadFloat(col, b));
2694
3246
  for (const p of popped) {
2695
3247
  const side = sideObstacleColumn.get(p);
2696
3248
  if (!side)
@@ -2775,7 +3327,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
2775
3327
  return;
2776
3328
  const band = currentBand(page, cursor);
2777
3329
  const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
2778
- if (cols.length < 2 || cols.some((c) => c.forcedBreak))
3330
+ // A band where text wraps round something (#627) is not levelled.
3331
+ if (cols.length < 2 || cols.some((c) => c.forcedBreak || (c.exclusions?.length ?? 0) > 0))
2779
3332
  return;
2780
3333
  if (activeCap && activeCap.spanIndex === boundaryIndex
2781
3334
  && activeCap.pageIndex === page.index && activeCap.band === band) {
@@ -2822,6 +3375,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
2822
3375
  const floatHeads = cols
2823
3376
  .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
2824
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
+ }
2825
3384
  bandCapProposals.set(boundaryIndex, {
2826
3385
  kind: 'trailing',
2827
3386
  startContentIndex: bandStart.contentIndex,
@@ -2845,7 +3404,15 @@ function placeDocumentPass(content, config, cache, options, hints) {
2845
3404
  * page the boundary opens: they sit the drain out and stay pending (with
2846
3405
  * no reference stamp, which the citing line sets), so they land after
2847
3406
  * the line that cites them, in the new segment (EF-69). */
3407
+ /** Text wrap (#627): an inline figure that wraps, placed as a band, whose
3408
+ * band the next block takes back to run beside it when it can (see
3409
+ * `pipeline/textWrap.ts`). */
3410
+ let pendingWrap = null;
2848
3411
  const closeFlowSegment = (boundaryIndex) => {
3412
+ // What text wrapped round in the column the segment ends in keeps its
3413
+ // room (#627).
3414
+ pendingWrap = null;
3415
+ settleColumnWraps(currentColumn(doc, cursor));
2849
3416
  settleAwaitingSideBoxes(true);
2850
3417
  releaseStaleCitationGates();
2851
3418
  const ownFloats = [];
@@ -3000,13 +3567,18 @@ function placeDocumentPass(content, config, cache, options, hints) {
3000
3567
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
3001
3568
  * unit; its children never leave it). */
3002
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;
3003
3575
  /** Free headings closing `col` (its trailing run), 0 unless keep-with-next
3004
3576
  * is on: the headings a block moving on would strand. */
3005
3577
  const trailingHeadingRun = (col) => {
3006
3578
  if (!resolved.headings.keepWithNext || headingMayCloseColumn(col))
3007
3579
  return 0;
3008
3580
  let n = 0;
3009
- 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--)
3010
3582
  n++;
3011
3583
  return n;
3012
3584
  };
@@ -3027,8 +3599,31 @@ function placeDocumentPass(content, config, cache, options, hints) {
3027
3599
  * convert the laid-out box to absolute coordinates at the frame's placed
3028
3600
  * origin, and push frame + children — in that order — to `doc.blocks`
3029
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
+ };
3030
3624
  const commitCallout = (result, startIdx, plan, col, range) => {
3031
3625
  const frame = result.frame;
3626
+ noteNarrowGroups(result, cursor.pageIndex);
3032
3627
  stampCalloutSource(frame, startIdx, plan, range);
3033
3628
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
3034
3629
  doc.blocks.push(frame);
@@ -3079,13 +3674,19 @@ function placeDocumentPass(content, config, cache, options, hints) {
3079
3674
  });
3080
3675
  const layoutRange = (from, to, width, frameId, continuation, mirrored = false) => {
3081
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;
3082
3682
  // A cut inside child `to.child` includes that child (its first
3083
3683
  // `to.line` lines); a cut at a child's head excludes it.
3084
- 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 }));
3085
3686
  // Nested boxes still open at `from` (their closing markers leading
3086
3687
  // the range close them): the fragment opens inside them.
3087
3688
  const open = [];
3088
- for (let k = 0; k < from.child; k++) {
3689
+ for (let k = 0; k < fromChild; k++) {
3089
3690
  const c = children[k];
3090
3691
  if (c.containerName !== 'callout')
3091
3692
  continue;
@@ -3094,7 +3695,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
3094
3695
  else if (c.type === 'containerEnd')
3095
3696
  open.pop();
3096
3697
  }
3097
- for (let k = from.child; k < children.length && open.length > 0; k++) {
3698
+ for (let k = fromChild; k < children.length && open.length > 0; k++) {
3098
3699
  const c = children[k];
3099
3700
  if (c.type !== 'containerEnd' || c.containerId !== open[open.length - 1].block.containerId)
3100
3701
  break;
@@ -3106,19 +3707,25 @@ function placeDocumentPass(content, config, cache, options, hints) {
3106
3707
  ...(contentBlocks[startIdx]?.direction ? { direction: contentBlocks[startIdx].direction } : {}),
3107
3708
  continuation,
3108
3709
  // Any range short of the box's end is a fragment that goes on.
3109
- ...(to.child < children.length || to.line > 0 ? { continues: true } : {}),
3110
- children: children.slice(from.child, toChild),
3111
- 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,
3112
3713
  width,
3113
- 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,
3114
3719
  resolved: calloutResolved,
3115
3720
  containerId: plan.containerId,
3116
3721
  frameId,
3117
3722
  nextChildId: () => `${frameId}-c${n++}`,
3118
3723
  paragraphContainers,
3119
- ...(from.line > 0 ? { lineFrom: from.line } : {}),
3120
- ...(from.line > 0 && from.widths ? { lineWidths: from.widths } : {}),
3121
- ...(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) } : {}),
3122
3729
  ...(open.length > 0 ? { openNested: open } : {}),
3123
3730
  mirrored,
3124
3731
  stripBlock: calloutStripBlock,
@@ -3252,7 +3859,68 @@ function placeDocumentPass(content, config, cache, options, hints) {
3252
3859
  });
3253
3860
  };
3254
3861
  collect(full.units, minLines, 0);
3255
- 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));
3256
3924
  const viable = candidates
3257
3925
  .filter((c) => !insideGroup(c.cut))
3258
3926
  .sort((a, b) => b.bottom - a.bottom);
@@ -3953,9 +4621,27 @@ function placeDocumentPass(content, config, cache, options, hints) {
3953
4621
  const cutBy = asideCutBy.get(side);
3954
4622
  if (cutBy !== undefined)
3955
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
+ }
3956
4636
  }
3957
4637
  const frame = result.frame;
3958
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
+ }
3959
4645
  frame.pageIndex = page.index;
3960
4646
  frame.columnIndex = side.index;
3961
4647
  offsetCalloutToAbsolute(result, side.bbox.x, y);
@@ -3995,7 +4681,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
3995
4681
  return true;
3996
4682
  }
3997
4683
  if (!trySideBox(page, side, { startIdx, plan, style }, refY, 'strict')) {
3998
- pendingSideBoxes.push({ startIdx, plan, style });
4684
+ pendingSideBoxes.push({ startIdx, plan, style, pageIndex: page.index });
3999
4685
  }
4000
4686
  return true;
4001
4687
  };
@@ -4064,7 +4750,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4064
4750
  placed = laterSide !== undefined && trySideBox(later, laterSide, w.box, undefined, 'fresh');
4065
4751
  }
4066
4752
  if (!placed)
4067
- pendingSideBoxes.push(w.box);
4753
+ pendingSideBoxes.push({ ...w.box, pageIndex: cursor.pageIndex });
4068
4754
  }
4069
4755
  };
4070
4756
  /** Set the waiting side boxes in the side column of a freshly opened
@@ -4081,6 +4767,31 @@ function placeDocumentPass(content, config, cache, options, hints) {
4081
4767
  pendingSideBoxes.shift();
4082
4768
  }
4083
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
+ };
4084
4795
  /**
4085
4796
  * Place a `:::callout` inline at the current column width as one atomic
4086
4797
  * unit: the frame block followed by its children in the same column. The
@@ -4102,6 +4813,239 @@ function placeDocumentPass(content, config, cache, options, hints) {
4102
4813
  * boxes) still fall back to this inline placement for v1 — the frame's
4103
4814
  * `callout.placement` records the request.
4104
4815
  */
4816
+ /**
4817
+ * A box set `here` that text wraps round (#627): laid out whole at its
4818
+ * share of the column, at its side, level with the next line's top, then
4819
+ * set as a band the next running text takes back to run beside it (see
4820
+ * `pendingWrap`). It never splits: too tall for the room left, it moves
4821
+ * on to the next column (a `textWrap` warning says so). Frame and
4822
+ * children go to the page's floats, where a floated box goes. Returns
4823
+ * false, the box set in the flow as any, when the text beside it would
4824
+ * be too narrow or the box too short for lines beside it.
4825
+ */
4826
+ const placeCalloutWrapped = (startIdx, plan, style, L, frameId, wrap) => {
4827
+ const settings = resolved.layout.wrap;
4828
+ const gap = wrapGapPx(wrap.gap, dpi, bodyStyle.fontSizePx, floatGapPx);
4829
+ const note = (reason) => {
4830
+ const start = contentBlocks[startIdx];
4831
+ textWrapNotes.push({ kind: 'textWrap', reason, box: style.id, sourceStart: start.sourceStart + bodyOffset, sourceEnd: start.sourceEnd + bodyOffset, pageIndex: cursor.pageIndex });
4832
+ };
4833
+ let col = currentColumn(doc, cursor);
4834
+ if (col.bbox.width * (1 - wrap.width) - gap + 0.01 < minTextWidthPx(settings.minTextWidth, col.bbox.width, dpi, bodyStyle.fontSizePx)) {
4835
+ note('tooNarrow');
4836
+ return false;
4837
+ }
4838
+ for (let attempt = 0;; attempt++) {
4839
+ col = currentColumn(doc, cursor);
4840
+ const page = doc.pages[cursor.pageIndex];
4841
+ const width = col.bbox.width * wrap.width;
4842
+ const result = L.layoutRange(CUT_START, L.end, width, frameId, false, mirroredOf(page));
4843
+ if (attempt === 0 && Math.ceil((result.totalHeight + gap - 0.01) / bodyStyle.lineHeightPx) < settings.minLinesBeside) {
4844
+ note('fewLines');
4845
+ return false;
4846
+ }
4847
+ const spacing = atColumnHead(col) ? 0 : pendingSpacing;
4848
+ if (result.totalHeight > col.availableHeight - spacing + 0.01 && col.blocks.length > 0 && attempt < 4) {
4849
+ if (attempt === 0)
4850
+ note('moved');
4851
+ pendingSpacing = 0;
4852
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
4853
+ continue;
4854
+ }
4855
+ const top = columnCursorY(col) + spacing;
4856
+ const x = wrap.side === 'left' ? col.bbox.x : col.bbox.x + col.bbox.width - width;
4857
+ const frame = result.frame;
4858
+ stampCalloutSource(frame, startIdx, plan);
4859
+ frame.pageIndex = page.index;
4860
+ frame.columnIndex = col.index;
4861
+ offsetCalloutToAbsolute(result, x, top);
4862
+ const floats = (page.floats ??= []);
4863
+ doc.blocks.push(frame);
4864
+ floats.push(frame);
4865
+ for (const child of result.children) {
4866
+ child.pageIndex = page.index;
4867
+ child.columnIndex = col.index;
4868
+ doc.blocks.push(child);
4869
+ floats.push(child);
4870
+ }
4871
+ const bottomRel = Math.ceil((top - col.bbox.y + result.totalHeight + gap - 0.01) / baselineGrid) * baselineGrid;
4872
+ col.availableHeight = Math.max(0, Math.min(col.availableHeight, col.bbox.height - bottomRel));
4873
+ pendingSpacing = 0;
4874
+ inlineGapOwed = null;
4875
+ const exWidth = wrapExclusionWidth(col.bbox.width, width, gap);
4876
+ pendingWrap = {
4877
+ col,
4878
+ blocks: col.blocks.length,
4879
+ cursorY: columnCursorY(col),
4880
+ ex: {
4881
+ x: wrap.side === 'left' ? col.bbox.x : col.bbox.x + col.bbox.width - exWidth,
4882
+ y: top,
4883
+ width: exWidth,
4884
+ height: col.bbox.y + bottomRel - top,
4885
+ side: wrap.side,
4886
+ gap: exWidth - width,
4887
+ ownerId: frame.id,
4888
+ },
4889
+ };
4890
+ return true;
4891
+ }
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
+ };
4105
5049
  const placeCalloutInline = (startIdx, plan) => {
4106
5050
  const style = pickCalloutStyle(calloutStyles, plan.attrs.type);
4107
5051
  const firstFrameId = `block-${blockIdCounter++}`;
@@ -4130,7 +5074,17 @@ function placeDocumentPass(content, config, cache, options, hints) {
4130
5074
  const inFlow = tallerThanColumn(L.layoutRange(CUT_START, L.end, width, 'float-probe', false))
4131
5075
  && splitCalloutFragment(L, CUT_START, width, contentArea.height, 'float-probe', false, style.splitMinLines) !== null;
4132
5076
  if (!inFlow) {
4133
- enqueueCalloutFloat(startIdx, plan, style, L, placement, span);
5077
+ const wrap = pageIsVertical(page) ? undefined : resolveCalloutWrap(plan.attrs, resolved.layout.wrap);
5078
+ enqueueCalloutFloat(startIdx, plan, style, L, placement, span, wrap);
5079
+ calloutsOutOfFlow.add(startIdx);
5080
+ return undefined;
5081
+ }
5082
+ }
5083
+ // A box text wraps round (#627): at its side of the column, the text
5084
+ // after it beside it.
5085
+ if (placement === 'here' && span === 'column' && !pageIsVertical(doc.pages[cursor.pageIndex])) {
5086
+ const wrap = resolveCalloutWrap(plan.attrs, resolved.layout.wrap);
5087
+ if (wrap && placeCalloutWrapped(startIdx, plan, style, L, firstFrameId, wrap)) {
4134
5088
  calloutsOutOfFlow.add(startIdx);
4135
5089
  return undefined;
4136
5090
  }
@@ -4174,7 +5128,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4174
5128
  const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
4175
5129
  ? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
4176
5130
  : 0;
4177
- 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;
4178
5132
  const roomPx = curCol.availableHeight - spacing;
4179
5133
  let fragment = null;
4180
5134
  if (result.totalHeight > roomPx + 0.01) {
@@ -4256,7 +5210,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
4256
5210
  const to = fragment ? fragment.to : L.end;
4257
5211
  if (part > 0 || fragment)
4258
5212
  markFragment(placed, part, fragment !== null);
4259
- 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;
4260
5214
  if (balanceBefore > 0) {
4261
5215
  addBalanceExtra(curCol, balanceBefore);
4262
5216
  placed.frame.balancing = { levers: ['trailingCallout'], spaceAbove: balanceBefore };
@@ -4469,6 +5423,29 @@ function placeDocumentPass(content, config, cache, options, hints) {
4469
5423
  releaseStaleCitationGates(blockIdx);
4470
5424
  tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
4471
5425
  enqueueFloatsFor(blockIdx);
5426
+ // Text wrap (#627): running text after a wrapped figure takes the band
5427
+ // the figure was set in back, and runs beside it; anything else that
5428
+ // takes room from the flow goes under what text wraps round first.
5429
+ // Markers and the `::resource` line of a floated resource leave it.
5430
+ if (!transparentToWrap(rawBlock) && !(rawBlock.type === 'resourceBlock' && rawBlock.resourceId !== undefined && floatedIds.has(rawBlock.resourceId))) {
5431
+ const wrapCol = currentColumn(doc, cursor);
5432
+ const beside = runsBesideWrap(rawBlock);
5433
+ if (pendingWrap) {
5434
+ const { col, blocks, cursorY, ex } = pendingWrap;
5435
+ pendingWrap = null;
5436
+ if (beside && col === wrapCol && col.blocks.length === blocks && Math.abs(columnCursorY(col) - cursorY) < 0.01) {
5437
+ // The cursor goes back to the figure's top; the text starts there.
5438
+ col.availableHeight += columnCursorY(col) - ex.y;
5439
+ pendingSpacing = 0;
5440
+ inlineGapOwed = null;
5441
+ openColumnWrap(col, ex, 'top');
5442
+ }
5443
+ }
5444
+ if (!beside && settleColumnWraps(wrapCol)) {
5445
+ pendingSpacing = 0;
5446
+ inlineGapOwed = null;
5447
+ }
5448
+ }
4472
5449
  // --- Directives ----------------------------------------------------
4473
5450
  // (A comic page, `:::page`, and a strip, `:::strip`, are placed below,
4474
5451
  // once a closed part or paper run has taken its page break.)
@@ -4862,14 +5839,14 @@ function placeDocumentPass(content, config, cache, options, hints) {
4862
5839
  const figureGrowPx = rawBlock.type === 'resourceBlock' ? balanceExtraPx?.get(flexFigureKey(blockIdx)) ?? 0 : 0;
4863
5840
  const figureDelta = figureGrowPx > 0 ? { figureHeightDelta: figureGrowPx } : {};
4864
5841
  let measuredBlock = tryLoose
4865
- ? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome)
5842
+ ? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome, looseOffGrid)
4866
5843
  : measureContentBlock(rawBlock, blockIdx, measureWidth, blockMeasureCtx, { styleOverride, ...upright, ...figureDelta });
4867
5844
  // An inline picture with a safe area a little too tall for the room
4868
5845
  // left in its column is cropped within its safe area to stay there
4869
5846
  // rather than move on and leave the column short (#442).
4870
5847
  if (measuredBlock?.kind.vdtType === 'resource' && measuredBlock.resourceBlock?.bodyFlex) {
4871
5848
  const flex = measuredBlock.resourceBlock.bodyFlex;
4872
- 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));
4873
5850
  const over = measuredBlock.measured.totalHeight - room;
4874
5851
  if (over > 0.01 && room > 0 && over <= flex.shrink + 0.01) {
4875
5852
  const cropped = measureContentBlock(rawBlock, blockIdx, measureWidth, blockMeasureCtx, {
@@ -4885,8 +5862,8 @@ function placeDocumentPass(content, config, cache, options, hints) {
4885
5862
  // the page empty. Never below MIN_INLINE_FIGURE_SCALE of its size.
4886
5863
  if (measuredBlock?.kind.vdtType === 'resource' && resolved.layout.fitFiguresToPage) {
4887
5864
  const rb = measuredBlock.resourceBlock;
4888
- const room = col.availableHeight - (col.blocks.length === 0 ? 0 : Math.max(pendingSpacing, floatGapPx));
4889
- 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) {
4890
5867
  let width = rb.bodyRect.width;
4891
5868
  for (let attempt = 0; attempt < 3; attempt++) {
4892
5869
  const current = measuredBlock.resourceBlock;
@@ -4901,12 +5878,12 @@ function placeDocumentPass(content, config, cache, options, hints) {
4901
5878
  if (!smaller)
4902
5879
  break;
4903
5880
  measuredBlock = smaller;
4904
- if (smaller.measured.totalHeight <= room)
5881
+ if (smaller.measured.totalHeight <= room + FIT_EPS)
4905
5882
  break;
4906
5883
  }
4907
5884
  // Still too tall at the smallest acceptable size: back to full size
4908
5885
  // and on to the next column, as without the option.
4909
- if (measuredBlock.measured.totalHeight > room) {
5886
+ if (measuredBlock.measured.totalHeight > room + FIT_EPS) {
4910
5887
  measuredBlock = measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, { styleOverride });
4911
5888
  }
4912
5889
  }
@@ -4962,22 +5939,104 @@ function placeDocumentPass(content, config, cache, options, hints) {
4962
5939
  segments: [],
4963
5940
  isLastLine: true,
4964
5941
  }];
5942
+ // Text wrap (#627): a picture the text after it may run beside, at
5943
+ // its side of the column — unless the text left beside it would be
5944
+ // too narrow, or the picture too short to set lines beside.
5945
+ const wrapSettings = blockMeasureCtx.resolved.layout.wrap;
5946
+ const wrapSpec = kind.resource && !pageIsVertical(blockPage)
5947
+ ? resolveResourceWrap(kind.resource, kind.resourceType, wrapSettings)
5948
+ : undefined;
5949
+ // Vertical text keeps its figures' bands whole (horizontal wrap only).
5950
+ if (kind.resource && pageIsVertical(blockPage) && resolveResourceWrap(kind.resource, kind.resourceType, wrapSettings)) {
5951
+ textWrapNotes.push({
5952
+ kind: 'textWrap', reason: 'verticalText', resourceId: kind.resource.id,
5953
+ sourceStart: rawBlock.sourceStart + bodyOffset, sourceEnd: rawBlock.sourceEnd + bodyOffset, pageIndex: cursor.pageIndex,
5954
+ });
5955
+ }
5956
+ let wrapGap = 0;
5957
+ let wraps = false;
5958
+ if (wrapSpec) {
5959
+ wrapGap = wrapGapPx(wrapSpec.gap, dpi, bodyStyle.fontSizePx, floatGapPx);
5960
+ const textWidth = col.bbox.width * (1 - wrapSpec.width) - wrapGap;
5961
+ const linesBeside = Math.ceil((groupHeight + wrapGap - 0.01) / bodyStyle.lineHeightPx);
5962
+ const reason = textWidth + 0.01 < minTextWidthPx(wrapSettings.minTextWidth, col.bbox.width, dpi, bodyStyle.fontSizePx)
5963
+ ? 'tooNarrow'
5964
+ : linesBeside < wrapSettings.minLinesBeside ? 'fewLines' : undefined;
5965
+ if (reason) {
5966
+ textWrapNotes.push({
5967
+ kind: 'textWrap', reason, resourceId: kind.resource.id,
5968
+ sourceStart: rawBlock.sourceStart + bodyOffset, sourceEnd: rawBlock.sourceEnd + bodyOffset, pageIndex: cursor.pageIndex,
5969
+ });
5970
+ }
5971
+ else {
5972
+ wraps = true;
5973
+ }
5974
+ }
4965
5975
  // An inline resource keeps the float gap (a line) above it, as a
4966
- // float would, unless the block before asked for more.
4967
- const spacingBefore = Math.max(pendingSpacing, floatGapPx);
4968
- enterBand(blockIdx, 0);
4969
- placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx, onAtomicNewPage(groupHeight));
4970
- enterBand(blockIdx, 0);
4971
- // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
4972
- // resource's own caption/table lines live on `resourceBlock` and must be
4973
- // offset to absolute page coordinates here using the placed bbox origin.
4974
- offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
4975
- const grown = figureGrowPx > 0 ? resourceBlock.bodyFlex?.delta ?? 0 : 0;
4976
- if (grown > 0.01) {
4977
- blk.balancing = { levers: ['flexFigure'], spaceAbove: 0, bodyGrowth: grown };
4978
- addBalanceExtra(currentColumn(doc, cursor), grown);
5976
+ // float would, unless the block before asked for more. A wrapped one
5977
+ // stands level with the next line's top instead.
5978
+ const spacingBefore = wraps ? pendingSpacing : Math.max(pendingSpacing, floatGapPx);
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') {
5985
+ flushPendingNumberingAtBoundary();
5986
+ continue;
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
+ }
4979
6039
  }
4980
- doc.blocks.push(blk);
4981
6040
  // Snap the flow position after the resource to the baseline grid (the
4982
6041
  // group height is arbitrary), baking in at least marginBottom — same
4983
6042
  // convention as snapped headings — so the following text lands back on
@@ -5045,7 +6104,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
5045
6104
  // `bulletXOffsetInColumn` is `indentPx` for unordered/task, and
5046
6105
  // `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
5047
6106
  // the right-aligned separator.
5048
- blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn;
6107
+ // Beside a wrapped picture on the left (#627) the marker stands past
6108
+ // it, with its text.
6109
+ blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn + lineInsetsAt(wrapInsets, 0).start;
5049
6110
  if (listBullet.separatorText !== undefined) {
5050
6111
  blk.separatorText = listBullet.separatorText;
5051
6112
  blk.separatorFontString = listBullet.separatorFontString;
@@ -5143,6 +6204,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
5143
6204
  * default opener paints across the page. */
5144
6205
  let brokenForWidth = measureWidth;
5145
6206
  let laterWidths = [];
6207
+ /** Text wrap (#627): the lines of the block set short beside what text
6208
+ * wraps round, as its lines are now set (paragraph line numbers). */
6209
+ let wrapInsets = [];
6210
+ const wrapsBeside = canSplit && runsBesideWrap(rawBlock);
5146
6211
  const rebreaksForColumn = !tryLoose && rawBlock.toc === undefined && rawBlock.index === undefined
5147
6212
  && (canSplit || (vdtType === 'heading' && !opensDefaultOpener(rawBlock)));
5148
6213
  /** Measure the block again for the columns it is set in (see
@@ -5150,6 +6215,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5150
6215
  const measureForColumns = (extra) => measureContentBlock(rawBlock, blockIdx, brokenForWidth, blockMeasureCtx, {
5151
6216
  styleOverride,
5152
6217
  ...(laterWidths.length > 0 ? { restColumnWidths: laterWidths } : {}),
6218
+ ...(wrapInsets.length > 0 ? { lineInsets: wrapInsets } : {}),
5153
6219
  ...extra,
5154
6220
  });
5155
6221
  // "Keep with next" for colon-introduced lists: a paragraph ending in `:`
@@ -5169,6 +6235,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
5169
6235
  while (remainingLines.length > 0) {
5170
6236
  enterBand(blockIdx, partIndex);
5171
6237
  const curCol = currentColumn(doc, cursor);
6238
+ // A wrapped float at the head or foot of this column (#627): running
6239
+ // text takes its band back to run beside it.
6240
+ if (wrapsBeside)
6241
+ openFloatWraps(curCol);
5172
6242
  // The rest of the block goes into a column of another width than the
5173
6243
  // one it was broken for (EF-157): break it again for this one. Up to
5174
6244
  // postext 1.4 it kept the lines of the column it started in, and a
@@ -5183,6 +6253,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5183
6253
  capInfo = again.dropCap;
5184
6254
  brokenForWidth = curCol.bbox.width;
5185
6255
  laterWidths = [];
6256
+ wrapInsets = [];
5186
6257
  letterSpacingPx = again.letterSpacingPx;
5187
6258
  remainingLines = [...raggedLooseLines(again.measured.lines, style.textAlign)];
5188
6259
  paragraphLines = remainingLines;
@@ -5201,8 +6272,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
5201
6272
  const keepBreaks = paragraphBreaks && paragraphBreaks.at.length >= placed
5202
6273
  ? { path: paragraphBreaks.path, at: paragraphBreaks.at.slice(0, placed) }
5203
6274
  : undefined;
6275
+ const keptInsets = insetsBefore(wrapInsets, placed);
5204
6276
  const again = measureContentBlock(rawBlock, blockIdx, brokenForWidth, blockMeasureCtx, {
5205
6277
  styleOverride, restColumnWidths: steps, ...(keepBreaks ? { keepBreaks } : {}),
6278
+ ...(keptInsets.length > 0 ? { lineInsets: keptInsets } : {}),
5206
6279
  });
5207
6280
  const lines = again && (again.letterSpacingPx ?? 0) === (letterSpacingPx ?? 0)
5208
6281
  ? [...raggedLooseLines(again.measured.lines, style.textAlign)]
@@ -5210,6 +6283,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5210
6283
  if (lines && lines.length > placed
5211
6284
  && paragraphLines.slice(0, placed).every((l, i) => lines[i].text === l.text && lines[i].hyphenated === l.hyphenated)) {
5212
6285
  laterWidths = steps;
6286
+ wrapInsets = keptInsets;
5213
6287
  paragraphLines = lines;
5214
6288
  paragraphBreaks = again.measured.breaks;
5215
6289
  remainingLines = lines.slice(placed);
@@ -5230,7 +6304,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
5230
6304
  /** Column balancing: the levers applied to this placement, stamped
5231
6305
  * on the block it places (`VDTBlock.balancing`). */
5232
6306
  let balancing;
5233
- 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)) {
5234
6310
  spacingBefore = pendingSpacing;
5235
6311
  if (vdtType === 'heading' || vdtType === 'mathDisplay') {
5236
6312
  spacingBefore = Math.max(partIndex === 0 ? pendingBeforeSelfSnapping(blockIdx) : spacingBefore, style.marginTopPx);
@@ -5321,6 +6397,53 @@ function placeDocumentPass(content, config, cache, options, hints) {
5321
6397
  if (partIndex === 0 && capInfo && capInfo.rise > 0.5) {
5322
6398
  spacingBefore += Math.ceil((capInfo.rise - 0.5) / baselineGrid) * baselineGrid;
5323
6399
  }
6400
+ // Text wrap (#627): the lines this block would set beside what text
6401
+ // wraps round in this column are broken short of it; the lines it
6402
+ // already placed elsewhere keep their breaks. A block that cannot
6403
+ // keep them (a break trace of the other measuring path) goes under
6404
+ // what it would have run beside instead.
6405
+ if (wrapsBeside) {
6406
+ const placed = paragraphLines.length - remainingLines.length;
6407
+ const y0 = columnCursorY(curCol) + spacingBefore;
6408
+ const wanted = wrapInsetSteps(curCol, y0, style.lineHeightPx, placed);
6409
+ if (!sameInsetsFrom(wrapInsets, wanted, placed)) {
6410
+ const steps = [...insetsBefore(wrapInsets, placed), ...wanted];
6411
+ const keepBreaks = placed > 0 && paragraphBreaks && paragraphBreaks.at.length >= placed
6412
+ ? { path: paragraphBreaks.path, at: paragraphBreaks.at.slice(0, placed) }
6413
+ : undefined;
6414
+ const again = placed > 0 && !keepBreaks ? null : measureContentBlock(rawBlock, blockIdx, brokenForWidth, blockMeasureCtx, {
6415
+ styleOverride,
6416
+ ...(laterWidths.length > 0 ? { restColumnWidths: laterWidths } : {}),
6417
+ ...(steps.length > 0 ? { lineInsets: steps } : {}),
6418
+ ...(keepBreaks ? { keepBreaks } : {}),
6419
+ });
6420
+ const lines = again && (placed === 0 || (again.letterSpacingPx ?? 0) === (letterSpacingPx ?? 0))
6421
+ ? [...raggedLooseLines(again.measured.lines, style.textAlign)]
6422
+ : undefined;
6423
+ if (lines && lines.length > placed
6424
+ && paragraphLines.slice(0, placed).every((l, i) => lines[i].text === l.text && lines[i].hyphenated === l.hyphenated)) {
6425
+ wrapInsets = steps;
6426
+ paragraphLines = lines;
6427
+ paragraphBreaks = again.measured.breaks;
6428
+ remainingLines = lines.slice(placed);
6429
+ if (placed === 0) {
6430
+ capInfo = again.dropCap;
6431
+ letterSpacingPx = again.letterSpacingPx;
6432
+ guardedEnds = [];
6433
+ columnEndHyphenRetried = false;
6434
+ }
6435
+ regateCitedFloats(blockIdx, lines);
6436
+ }
6437
+ else if (wanted.length > 0) {
6438
+ if (balancing && balancing.spaceAbove > 0) {
6439
+ balanceExtraInColumn.set(curCol, (balanceExtraInColumn.get(curCol) ?? 0) - balancing.spaceAbove);
6440
+ }
6441
+ settleColumnWraps(curCol);
6442
+ pendingSpacing = 0;
6443
+ continue;
6444
+ }
6445
+ }
6446
+ }
5324
6447
  // A loose paragraph's extra line is balancing height too (it lands
5325
6448
  // whole in this column — loose candidates are never split parts).
5326
6449
  const looseTracking = looseOutcome.get(blockIdx);
@@ -5418,7 +6541,9 @@ function placeDocumentPass(content, config, cache, options, hints) {
5418
6541
  // the tail follows the list naturally in the next column.
5419
6542
  if (endsWithColon
5420
6543
  && partIndex === 0
5421
- && 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
5422
6547
  && curCol.blocks.length > 0) {
5423
6548
  const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
5424
6549
  const paragraphBottom = usedHeight + totalRemainHeight;
@@ -5442,7 +6567,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5442
6567
  // there, by the rules that split it — a two-line item the orphan
5443
6568
  // and widow rules keep whole needs both. `'line'` keeps the 1.4
5444
6569
  // check, one line.
5445
- if (availableAfter < minSpaceForList
6570
+ if (availableAfter < minSpaceForList - FIT_EPS
5446
6571
  || (resolved.bodyText.colonListRoom === 'item'
5447
6572
  && !firstListItemStarts(blockIdx, availableAfter - effectiveGap, curCol.bbox.width, blockMeasureCtx))) {
5448
6573
  const effectiveWidowMin = resolved.bodyText.avoidWidows
@@ -5644,7 +6769,10 @@ function placeDocumentPass(content, config, cache, options, hints) {
5644
6769
  // A `:::space` between the heading and its text needs room too.
5645
6770
  const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx
5646
6771
  + spaceLinesAfter(contentBlocks, blockIdx) * baselineGrid;
5647
- 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) {
5648
6776
  if (curCol.blocks.length === 0)
5649
6777
  shortColumnMoves++;
5650
6778
  // Roll back any immediately-preceding heading blocks in this
@@ -5747,7 +6875,7 @@ function placeDocumentPass(content, config, cache, options, hints) {
5747
6875
  // its margin and grid snap would carry the block past the foot: it
5748
6876
  // takes the rest of the column instead, as a design reaching past
5749
6877
  // the foot does (the text after it opens the next column or page).
5750
- if (vdtType === 'heading' && effectiveRemainHeight > totalRemainHeight && h > curCol.availableHeight) {
6878
+ if (vdtType === 'heading' && effectiveRemainHeight > totalRemainHeight && h > curCol.availableHeight + FIT_EPS) {
5751
6879
  h = Math.max(effectiveRemainHeight, curCol.availableHeight);
5752
6880
  }
5753
6881
  placeBlockInColumn(blk, h, curCol, cursor);
@@ -6139,6 +7267,25 @@ function placeDocumentPass(content, config, cache, options, hints) {
6139
7267
  // End of the document: level the closing band and place any floats still
6140
7268
  // pending (referenced on the last page) on pages appended after it.
6141
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);
6142
7289
  // The notes, in the room the flow reserved for them.
6143
7290
  reserveLeftoverNotes();
6144
7291
  setColumnNotes();
@@ -6255,11 +7402,24 @@ function placeDocumentPass(content, config, cache, options, hints) {
6255
7402
  joiningSpacingWarnings.set(doc, joiningLetterSpacingWarnings(doc, joiningLetterSpacing));
6256
7403
  if (dropCapNotes.size > 0)
6257
7404
  dropCapWarningsByDoc.set(doc, dropCapWarnings(doc, dropCapNotes));
6258
- return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome, captionUnderProposals };
7405
+ // Pictures scaled to the room of their slot (#626), at the block citing them.
7406
+ const shrunk = floatShrinkWarnings(doc, (idx) => {
7407
+ const b = contentBlocks[idx];
7408
+ return b ? { start: b.sourceStart + bodyOffset, end: b.sourceEnd + bodyOffset } : undefined;
7409
+ });
7410
+ if (shrunk.length > 0)
7411
+ floatShrinkWarningsByDoc.set(doc, shrunk);
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 };
6259
7416
  }
6260
7417
  /** Passes a document printing its own contents gets at most, beyond the
6261
7418
  * first, for the page labels it prints to settle. */
6262
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;
6263
7423
  export function buildDocument(content, config, cache, options) {
6264
7424
  return drainPasses(buildDocumentGen(content, config, cache, options));
6265
7425
  }
@@ -6303,6 +7463,12 @@ export async function buildDocumentAsync(content, config, cache, options) {
6303
7463
  }
6304
7464
  /** The build as a generator: one `yield` after every placement pass. */
6305
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);
6306
7472
  const doc = yield* buildDocumentRounds(content, config, cache, options);
6307
7473
  // References and markup the source names wrongly: read from the source
6308
7474
  // once, located on the pages of the finished layout. Kept apart from
@@ -6315,7 +7481,12 @@ export function* buildDocumentGen(content, config, cache, options) {
6315
7481
  // cap; words of a joining script that run past their line, and styles
6316
7482
  // whose letter-spacing such words do not take.
6317
7483
  // Line numbers set over a float of the side column (#621).
6318
- const loose = [...cjkLooseLineWarnings(doc), ...wordOverflowWarnings(doc), ...(joiningSpacingWarnings.get(doc) ?? []), ...(dropCapWarningsByDoc.get(doc) ?? []), ...lineNumberWarnings(doc), ...codeOverflowWarnings(doc)];
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) ?? []));
7488
+ // Design texts cut to fit their width (#628).
7489
+ loose.push(...designTruncationWarnings(doc));
6319
7490
  // Comic panels whose cell cannot hold their picture's safe area (#556).
6320
7491
  for (const page of doc.pages) {
6321
7492
  if (page.comic)
@@ -6341,8 +7512,12 @@ export function* buildDocumentGen(content, config, cache, options) {
6341
7512
  const harakat = annotateArabicMarks(doc);
6342
7513
  // The rules over `*…*` runs under `bodyText.emphasis: 'overline'`.
6343
7514
  overlineEmphasis(doc);
6344
- if (located.length > 0 || loose.length > 0 || annotations.length > 0 || harakat.length > 0) {
6345
- 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];
6346
7521
  }
6347
7522
  // Config values the build replaced (an unknown number format, a font
6348
7523
  // stack, a side column no column width can take): walked once per build,
@@ -6360,6 +7535,35 @@ const indexWarnings = new WeakMap();
6360
7535
  const joiningSpacingWarnings = new WeakMap();
6361
7536
  /** The `dropCap` warnings of the last pass, per document (#623). */
6362
7537
  const dropCapWarningsByDoc = new WeakMap();
7538
+ /** The `floatShrunk` warnings of a build (#626), found by its last pass. */
7539
+ const floatShrinkWarningsByDoc = new WeakMap();
7540
+ /** The `textWrap` warnings of a build (#627) and its `columnsTooNarrow`
7541
+ * ones (#634), found by its last pass. */
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
+ }
6363
7567
  /** `doc` with the page of each of its index marks (`doc.indexMarks`). */
6364
7568
  function withIndexMarks(doc, content) {
6365
7569
  const { content: body, contentOffset } = extractFrontmatter(content.markdown);
@@ -6378,6 +7582,10 @@ function withIndexMarks(doc, content) {
6378
7582
  /** {@link buildDocumentGen} before the document-wide warnings: the
6379
7583
  * contents rounds around the balanced build. */
6380
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() };
6381
7589
  // A document printing its own table of contents (`:::toc` with no
6382
7590
  // host-supplied outline) is laid out with the page labels of the previous
6383
7591
  // build until they no longer change: the contents' own length moves what
@@ -6394,18 +7602,18 @@ function* buildDocumentRounds(content, config, cache, options) {
6394
7602
  };
6395
7603
  if (hasTocDirective(parsed) || hasIndexDirective(parsed) || pageRefs()) {
6396
7604
  let outline = computeOutline(parsed, resolveAllConfig(config), content.continuation?.headings, content.continuation?.statementCounters);
6397
- 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);
6398
7606
  for (let round = 0; round < MAX_TOC_ROUNDS; round++) {
6399
7607
  const after = outlineFromDoc(doc, outline);
6400
7608
  if (sameOutline(after, outline))
6401
7609
  break;
6402
7610
  outline = after;
6403
- 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);
6404
7612
  }
6405
7613
  return doc;
6406
7614
  }
6407
7615
  }
6408
- return withIndexMarks(yield* buildDocumentNumbered(content, config, cache, options), content);
7616
+ return withIndexMarks(yield* buildDocumentNumbered(content, config, cache, options, 0, citing), content);
6409
7617
  }
6410
7618
  /** The number each note's marker prints in `doc` (its first marker). */
6411
7619
  function printedFootnoteNumbers(doc) {
@@ -6435,8 +7643,8 @@ const MAX_FOOTNOTE_ROUNDS = 3;
6435
7643
  * width whatever its number, so the second build reflows nothing and the
6436
7644
  * numbers hold; a decimal one may move a line (9 → 10), hence the bound.
6437
7645
  */
6438
- function* buildDocumentNumbered(content, config, cache, options, tocRound = 0) {
6439
- 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);
6440
7648
  const f = doc.config.footnotes;
6441
7649
  if ((f.placement !== 'column' && f.placement !== 'spread') || (f.numbering !== 'page' && f.numbering !== 'column' && f.numbering !== 'spread'))
6442
7650
  return doc;
@@ -6454,12 +7662,15 @@ function* buildDocumentNumbered(content, config, cache, options, tocRound = 0) {
6454
7662
  next.set(id, number);
6455
7663
  if (sameFootnoteNumbers(next, used))
6456
7664
  break;
6457
- doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound, next);
7665
+ doc = yield* buildDocumentBalanced(content, config, cache, options, tocRound, next, citing);
6458
7666
  }
6459
7667
  return doc;
6460
7668
  }
6461
7669
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
6462
- 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() }) {
6463
7674
  // Each pass reports its own progress, numbered in build order.
6464
7675
  let passIndex = 0;
6465
7676
  const onProgress = options?.onProgress;
@@ -6470,12 +7681,28 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6470
7681
  // Side captions moved under their figure (see `PassHints.captionUnder`):
6471
7682
  // once a pass asks for one, every later pass keeps it.
6472
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;
6473
7693
  const runPass = (hints) => {
6474
7694
  passIndex++;
6475
7695
  const started = onPass ? now() : 0;
6476
- 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
+ });
6477
7702
  for (const id of result.captionUnderProposals)
6478
7703
  captionUnder.add(id);
7704
+ for (const k of result.looseOffGrid)
7705
+ looseOffGrid.add(k);
6479
7706
  onPass?.({ pass: passIndex, tocRound, ms: now() - started, pages: result.doc.pages.length });
6480
7707
  return result;
6481
7708
  };
@@ -6492,12 +7719,55 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6492
7719
  if (captionUnder.size === before)
6493
7720
  break;
6494
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
+ }
6495
7756
  // --- Band caps (page-span blocks mid-page) -----------------------------
6496
7757
  // A span block that arrived in an uneven band proposes a cap; the driver
6497
7758
  // re-places the document with it (and grows / drops caps whose band
6498
7759
  // overflowed) before balancing runs. Documents without such blocks get
6499
7760
  // their first pass back untouched — no extra pass.
6500
- 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
+ }
6501
7771
  let best = bands.result;
6502
7772
  const bandCaps = bands.bandCaps;
6503
7773
  let passCount = bands.passCount;
@@ -6523,8 +7793,20 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6523
7793
  // balances exactly as its chapters would one by one, instead of one
6524
7794
  // cascade anywhere costing every page of the book a pass.
6525
7795
  const balancing = best.doc.config.headings.balancing;
6526
- 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;
6527
7808
  return best.doc;
7809
+ }
6528
7810
  /** Levers accepted so far, over every segment (keyed by content index /
6529
7811
  * `balanceKey`; each key belongs to exactly one segment). */
6530
7812
  const applied = { lines: new Map(), loose: new Map() };
@@ -6644,6 +7926,11 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6644
7926
  if (!sameContent(blocks, next.doc.blocks))
6645
7927
  return best;
6646
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);
6647
7934
  const pageBest = pageOfContent(best.doc);
6648
7935
  const pageNext = pageOfContent(next.doc);
6649
7936
  const mergeMap = (a, b) => {
@@ -6673,7 +7960,10 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6673
7960
  spanPlacedInBand: mergeSet(best.spanPlacedInBand, next.spanPlacedInBand),
6674
7961
  bandCapsApplied: mergeSet(best.bandCapsApplied, next.bandCapsApplied),
6675
7962
  looseOutcome: mergeMap(best.looseOutcome, next.looseOutcome),
7963
+ looseOffGrid: new Set([...best.looseOffGrid, ...next.looseOffGrid]),
6676
7964
  captionUnderProposals: new Set([...best.captionUnderProposals, ...next.captionUnderProposals]),
7965
+ citingPageProposals: best.citingPageProposals,
7966
+ citingPageRefused: best.citingPageRefused,
6677
7967
  };
6678
7968
  };
6679
7969
  /**
@@ -6743,6 +8033,7 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6743
8033
  failedLoose,
6744
8034
  failedLines,
6745
8035
  closingBox: balancing.closingBox,
8036
+ ...(onGrid && balancing.gridLines === 'off' ? { gridLines: false } : {}),
6746
8037
  });
6747
8038
  // The levers newly proposed, by segment; those of a segment that is
6748
8039
  // done (plateaued, out of attempts) are withdrawn from the pass.
@@ -6808,7 +8099,10 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6808
8099
  // a levelled closing band spills past its cut) is a regression —
6809
8100
  // capped columns without their box are not a layout we may keep.
6810
8101
  const capsDelivered = [...bandCaps.keys()].every((i) => !inRange(s.range, capPage.get(i)) || next.spanPlacedInBand.has(i));
6811
- 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)
6812
8106
  ? gapLinesIn(nextGaps, s.range)
6813
8107
  : Infinity;
6814
8108
  // Loose paragraphs that gained no line at any tracking rung are
@@ -6891,7 +8185,9 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6891
8185
  const frozen = hintsFrom(applied.lines, applied.loose);
6892
8186
  const trailing = yield* resolveTrailingCapsGen(best, bandCaps, (caps) => runPass({ ...frozen, bandCaps: caps, floatPages: floatPagesOf(best.doc) }));
6893
8187
  passCount += trailing.passCount;
6894
- 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) {
6895
8191
  best = trailing.result;
6896
8192
  for (const [i, cap] of trailing.caps)
6897
8193
  bandCaps.set(i, cap);
@@ -6907,8 +8203,41 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0, f
6907
8203
  }
6908
8204
  best.doc.iterationCount = passCount;
6909
8205
  best.doc.converged = segments.every((s) => s.stable || s.bestScore === 0);
8206
+ if (onGrid)
8207
+ markGridRefusals(best.doc, best.forcedBreakPages, looseOffGrid, gridBalancing);
6910
8208
  return best.doc;
6911
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'];
6912
8241
  /** Whether two lines set one bayt of a poem (`VDTLine.verse`), which a
6913
8242
  * column never cuts. */
6914
8243
  function sameBayt(a, b) {