@react-x11/components 0.8.0 → 0.10.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 (721) hide show
  1. package/README.md +105 -38
  2. package/dist/charts/node.d.ts +12 -2
  3. package/dist/charts/node.d.ts.map +1 -1
  4. package/dist/charts/node.js +50 -16
  5. package/dist/charts/node.js.map +1 -1
  6. package/dist/charts/render.d.ts +12 -3
  7. package/dist/charts/render.d.ts.map +1 -1
  8. package/dist/charts/render.js +94 -25
  9. package/dist/charts/render.js.map +1 -1
  10. package/dist/code/index.d.ts.map +1 -1
  11. package/dist/code/index.js +3 -1
  12. package/dist/code/index.js.map +1 -1
  13. package/dist/code-editor/completion.d.ts.map +1 -1
  14. package/dist/code-editor/completion.js +3 -2
  15. package/dist/code-editor/completion.js.map +1 -1
  16. package/dist/code-editor/index.d.ts.map +1 -1
  17. package/dist/code-editor/index.js +19 -0
  18. package/dist/code-editor/index.js.map +1 -1
  19. package/dist/code-editor/node.d.ts +177 -7
  20. package/dist/code-editor/node.d.ts.map +1 -1
  21. package/dist/code-editor/node.js +1329 -164
  22. package/dist/code-editor/node.js.map +1 -1
  23. package/dist/code-language/languages/glsl.d.ts.map +1 -1
  24. package/dist/code-language/languages/glsl.js +10 -2
  25. package/dist/code-language/languages/glsl.js.map +1 -1
  26. package/dist/code-language/languages/javascript.d.ts.map +1 -1
  27. package/dist/code-language/languages/javascript.js +18 -3
  28. package/dist/code-language/languages/javascript.js.map +1 -1
  29. package/dist/code-language/languages/json.d.ts.map +1 -1
  30. package/dist/code-language/languages/json.js +10 -2
  31. package/dist/code-language/languages/json.js.map +1 -1
  32. package/dist/code-language/languages/shell.d.ts.map +1 -1
  33. package/dist/code-language/languages/shell.js +10 -2
  34. package/dist/code-language/languages/shell.js.map +1 -1
  35. package/dist/code-language/languages/sql.d.ts.map +1 -1
  36. package/dist/code-language/languages/sql.js +20 -2
  37. package/dist/code-language/languages/sql.js.map +1 -1
  38. package/dist/code-language/stream.d.ts +22 -0
  39. package/dist/code-language/stream.d.ts.map +1 -1
  40. package/dist/code-language/stream.js +259 -9
  41. package/dist/code-language/stream.js.map +1 -1
  42. package/dist/code-language/types.d.ts +5 -4
  43. package/dist/code-language/types.d.ts.map +1 -1
  44. package/dist/codeblock/index.d.ts +13 -2
  45. package/dist/codeblock/index.d.ts.map +1 -1
  46. package/dist/codeblock/index.js +20 -3
  47. package/dist/codeblock/index.js.map +1 -1
  48. package/dist/embed/client.d.ts +48 -3
  49. package/dist/embed/client.d.ts.map +1 -1
  50. package/dist/embed/client.js +79 -5
  51. package/dist/embed/client.js.map +1 -1
  52. package/dist/embed/index.d.ts +1 -1
  53. package/dist/embed/index.d.ts.map +1 -1
  54. package/dist/embed/index.js +1 -1
  55. package/dist/embed/index.js.map +1 -1
  56. package/dist/flow/draw.d.ts +50 -0
  57. package/dist/flow/draw.d.ts.map +1 -1
  58. package/dist/flow/draw.js +132 -33
  59. package/dist/flow/draw.js.map +1 -1
  60. package/dist/flow/gl/color.d.ts +13 -0
  61. package/dist/flow/gl/color.d.ts.map +1 -0
  62. package/dist/flow/gl/color.js +82 -0
  63. package/dist/flow/gl/color.js.map +1 -0
  64. package/dist/flow/gl/index.d.ts +60 -0
  65. package/dist/flow/gl/index.d.ts.map +1 -0
  66. package/dist/flow/gl/index.js +283 -0
  67. package/dist/flow/gl/index.js.map +1 -0
  68. package/dist/flow/gl/pack.d.ts +133 -0
  69. package/dist/flow/gl/pack.d.ts.map +1 -0
  70. package/dist/flow/gl/pack.js +408 -0
  71. package/dist/flow/gl/pack.js.map +1 -0
  72. package/dist/flow/gl/renderer.d.ts +95 -0
  73. package/dist/flow/gl/renderer.d.ts.map +1 -0
  74. package/dist/flow/gl/renderer.js +459 -0
  75. package/dist/flow/gl/renderer.js.map +1 -0
  76. package/dist/flow/gl/shaders.d.ts +79 -0
  77. package/dist/flow/gl/shaders.d.ts.map +1 -0
  78. package/dist/flow/gl/shaders.js +346 -0
  79. package/dist/flow/gl/shaders.js.map +1 -0
  80. package/dist/flow/gl/text.d.ts +318 -0
  81. package/dist/flow/gl/text.d.ts.map +1 -0
  82. package/dist/flow/gl/text.js +1103 -0
  83. package/dist/flow/gl/text.js.map +1 -0
  84. package/dist/flow/index.d.ts +1 -1
  85. package/dist/flow/index.d.ts.map +1 -1
  86. package/dist/flow/index.js +619 -64
  87. package/dist/flow/index.js.map +1 -1
  88. package/dist/flow/node.d.ts +621 -72
  89. package/dist/flow/node.d.ts.map +1 -1
  90. package/dist/flow/node.js +2253 -1018
  91. package/dist/flow/node.js.map +1 -1
  92. package/dist/flow/paint.d.ts +35 -0
  93. package/dist/flow/paint.d.ts.map +1 -0
  94. package/dist/flow/paint.js +393 -0
  95. package/dist/flow/paint.js.map +1 -0
  96. package/dist/flow/paths.d.ts +14 -0
  97. package/dist/flow/paths.d.ts.map +1 -1
  98. package/dist/flow/paths.js +45 -19
  99. package/dist/flow/paths.js.map +1 -1
  100. package/dist/flow/scene.d.ts +584 -0
  101. package/dist/flow/scene.d.ts.map +1 -0
  102. package/dist/flow/scene.js +1453 -0
  103. package/dist/flow/scene.js.map +1 -0
  104. package/dist/flow/types.d.ts +111 -1
  105. package/dist/flow/types.d.ts.map +1 -1
  106. package/dist/html/controls.d.ts +28 -0
  107. package/dist/html/controls.d.ts.map +1 -1
  108. package/dist/html/controls.js +52 -2
  109. package/dist/html/controls.js.map +1 -1
  110. package/dist/html/css/calc.d.ts +14 -0
  111. package/dist/html/css/calc.d.ts.map +1 -0
  112. package/dist/html/css/calc.js +344 -0
  113. package/dist/html/css/calc.js.map +1 -0
  114. package/dist/html/css/cascade.d.ts +240 -6
  115. package/dist/html/css/cascade.d.ts.map +1 -1
  116. package/dist/html/css/cascade.js +1327 -66
  117. package/dist/html/css/cascade.js.map +1 -1
  118. package/dist/html/css/color.d.ts +23 -0
  119. package/dist/html/css/color.d.ts.map +1 -0
  120. package/dist/html/css/color.js +740 -0
  121. package/dist/html/css/color.js.map +1 -0
  122. package/dist/html/css/content.d.ts +92 -0
  123. package/dist/html/css/content.d.ts.map +1 -0
  124. package/dist/html/css/content.js +388 -0
  125. package/dist/html/css/content.js.map +1 -0
  126. package/dist/html/css/counter-styles.d.ts +76 -0
  127. package/dist/html/css/counter-styles.d.ts.map +1 -0
  128. package/dist/html/css/counter-styles.js +893 -0
  129. package/dist/html/css/counter-styles.js.map +1 -0
  130. package/dist/html/css/decode.d.ts +12 -0
  131. package/dist/html/css/decode.d.ts.map +1 -0
  132. package/dist/html/css/decode.js +116 -0
  133. package/dist/html/css/decode.js.map +1 -0
  134. package/dist/html/css/parse.d.ts +110 -10
  135. package/dist/html/css/parse.d.ts.map +1 -1
  136. package/dist/html/css/parse.js +1473 -180
  137. package/dist/html/css/parse.js.map +1 -1
  138. package/dist/html/css/style.d.ts +498 -15
  139. package/dist/html/css/style.d.ts.map +1 -1
  140. package/dist/html/css/style.js +3698 -206
  141. package/dist/html/css/style.js.map +1 -1
  142. package/dist/html/css/ua.d.ts.map +1 -1
  143. package/dist/html/css/ua.js +57 -11
  144. package/dist/html/css/ua.js.map +1 -1
  145. package/dist/html/css/values.d.ts +37 -3
  146. package/dist/html/css/values.d.ts.map +1 -1
  147. package/dist/html/css/values.js +122 -48
  148. package/dist/html/css/values.js.map +1 -1
  149. package/dist/html/css/vars.d.ts +52 -0
  150. package/dist/html/css/vars.d.ts.map +1 -0
  151. package/dist/html/css/vars.js +358 -0
  152. package/dist/html/css/vars.js.map +1 -0
  153. package/dist/html/dom.d.ts +30 -4
  154. package/dist/html/dom.d.ts.map +1 -1
  155. package/dist/html/dom.js +278 -17
  156. package/dist/html/dom.js.map +1 -1
  157. package/dist/html/fonts.d.ts +106 -0
  158. package/dist/html/fonts.d.ts.map +1 -0
  159. package/dist/html/fonts.js +565 -0
  160. package/dist/html/fonts.js.map +1 -0
  161. package/dist/html/gif.d.ts +15 -0
  162. package/dist/html/gif.d.ts.map +1 -0
  163. package/dist/html/gif.js +229 -0
  164. package/dist/html/gif.js.map +1 -0
  165. package/dist/html/index.d.ts +50 -11
  166. package/dist/html/index.d.ts.map +1 -1
  167. package/dist/html/index.js +115 -9
  168. package/dist/html/index.js.map +1 -1
  169. package/dist/html/layout/block.d.ts +226 -3
  170. package/dist/html/layout/block.d.ts.map +1 -1
  171. package/dist/html/layout/block.js +2937 -197
  172. package/dist/html/layout/block.js.map +1 -1
  173. package/dist/html/layout/boxes.d.ts +340 -17
  174. package/dist/html/layout/boxes.d.ts.map +1 -1
  175. package/dist/html/layout/boxes.js +1714 -182
  176. package/dist/html/layout/boxes.js.map +1 -1
  177. package/dist/html/layout/cache.d.ts +58 -0
  178. package/dist/html/layout/cache.d.ts.map +1 -0
  179. package/dist/html/layout/cache.js +224 -0
  180. package/dist/html/layout/cache.js.map +1 -0
  181. package/dist/html/layout/collapse.d.ts +45 -0
  182. package/dist/html/layout/collapse.d.ts.map +1 -0
  183. package/dist/html/layout/collapse.js +246 -0
  184. package/dist/html/layout/collapse.js.map +1 -0
  185. package/dist/html/layout/css-grid.d.ts +7 -0
  186. package/dist/html/layout/css-grid.d.ts.map +1 -0
  187. package/dist/html/layout/css-grid.js +900 -0
  188. package/dist/html/layout/css-grid.js.map +1 -0
  189. package/dist/html/layout/flex.d.ts.map +1 -1
  190. package/dist/html/layout/flex.js +1025 -89
  191. package/dist/html/layout/flex.js.map +1 -1
  192. package/dist/html/layout/floats.d.ts +45 -6
  193. package/dist/html/layout/floats.d.ts.map +1 -1
  194. package/dist/html/layout/floats.js +113 -14
  195. package/dist/html/layout/floats.js.map +1 -1
  196. package/dist/html/layout/grid-lines.d.ts +39 -0
  197. package/dist/html/layout/grid-lines.d.ts.map +1 -0
  198. package/dist/html/layout/grid-lines.js +119 -0
  199. package/dist/html/layout/grid-lines.js.map +1 -0
  200. package/dist/html/layout/grid.d.ts +53 -0
  201. package/dist/html/layout/grid.d.ts.map +1 -0
  202. package/dist/html/layout/grid.js +153 -0
  203. package/dist/html/layout/grid.js.map +1 -0
  204. package/dist/html/layout/inline.d.ts +151 -1
  205. package/dist/html/layout/inline.d.ts.map +1 -1
  206. package/dist/html/layout/inline.js +3375 -174
  207. package/dist/html/layout/inline.js.map +1 -1
  208. package/dist/html/layout/shaping.d.ts +6 -0
  209. package/dist/html/layout/shaping.d.ts.map +1 -0
  210. package/dist/html/layout/shaping.js +100 -0
  211. package/dist/html/layout/shaping.js.map +1 -0
  212. package/dist/html/layout/table.d.ts +3 -0
  213. package/dist/html/layout/table.d.ts.map +1 -1
  214. package/dist/html/layout/table.js +681 -175
  215. package/dist/html/layout/table.js.map +1 -1
  216. package/dist/html/layout/tracks.d.ts +44 -0
  217. package/dist/html/layout/tracks.d.ts.map +1 -0
  218. package/dist/html/layout/tracks.js +349 -0
  219. package/dist/html/layout/tracks.js.map +1 -0
  220. package/dist/html/node.d.ts +172 -5
  221. package/dist/html/node.d.ts.map +1 -1
  222. package/dist/html/node.js +1336 -103
  223. package/dist/html/node.js.map +1 -1
  224. package/dist/html/paint.d.ts +92 -3
  225. package/dist/html/paint.d.ts.map +1 -1
  226. package/dist/html/paint.js +3503 -141
  227. package/dist/html/paint.js.map +1 -1
  228. package/dist/html/resources.d.ts +60 -14
  229. package/dist/html/resources.d.ts.map +1 -1
  230. package/dist/html/resources.js +122 -36
  231. package/dist/html/resources.js.map +1 -1
  232. package/dist/html/surfaces.d.ts +23 -0
  233. package/dist/html/surfaces.d.ts.map +1 -0
  234. package/dist/html/surfaces.js +68 -0
  235. package/dist/html/surfaces.js.map +1 -0
  236. package/dist/html/svg.d.ts +80 -0
  237. package/dist/html/svg.d.ts.map +1 -0
  238. package/dist/html/svg.js +435 -0
  239. package/dist/html/svg.js.map +1 -0
  240. package/dist/html/url.d.ts +23 -0
  241. package/dist/html/url.d.ts.map +1 -0
  242. package/dist/html/url.js +80 -0
  243. package/dist/html/url.js.map +1 -0
  244. package/dist/index.d.ts +2 -2
  245. package/dist/index.d.ts.map +1 -1
  246. package/dist/index.js +1 -1
  247. package/dist/index.js.map +1 -1
  248. package/dist/internal/collate.d.ts +3 -0
  249. package/dist/internal/collate.d.ts.map +1 -0
  250. package/dist/internal/collate.js +11 -0
  251. package/dist/internal/collate.js.map +1 -0
  252. package/dist/{markdown → internal/markdown}/ast.d.ts +23 -0
  253. package/dist/internal/markdown/ast.d.ts.map +1 -0
  254. package/dist/internal/markdown/ast.js +22 -0
  255. package/dist/internal/markdown/ast.js.map +1 -0
  256. package/dist/internal/markdown/parse.d.ts.map +1 -0
  257. package/dist/{markdown → internal/markdown}/parse.js +221 -30
  258. package/dist/internal/markdown/parse.js.map +1 -0
  259. package/dist/internal/markdown/stringify.d.ts +4 -0
  260. package/dist/internal/markdown/stringify.d.ts.map +1 -0
  261. package/dist/internal/markdown/stringify.js +684 -0
  262. package/dist/internal/markdown/stringify.js.map +1 -0
  263. package/dist/internal/markdown/tags.d.ts.map +1 -0
  264. package/dist/internal/markdown/tags.js.map +1 -0
  265. package/dist/internal/prewarm.d.ts +18 -0
  266. package/dist/internal/prewarm.d.ts.map +1 -0
  267. package/dist/internal/prewarm.js +42 -0
  268. package/dist/internal/prewarm.js.map +1 -0
  269. package/dist/internal/scroll.d.ts +3 -2
  270. package/dist/internal/scroll.d.ts.map +1 -1
  271. package/dist/internal/scroll.js +1 -0
  272. package/dist/internal/scroll.js.map +1 -1
  273. package/dist/internal/sdf.d.ts +20 -0
  274. package/dist/internal/sdf.d.ts.map +1 -0
  275. package/dist/internal/sdf.js +216 -0
  276. package/dist/internal/sdf.js.map +1 -0
  277. package/dist/internal/splice.d.ts +12 -0
  278. package/dist/internal/splice.d.ts.map +1 -0
  279. package/dist/internal/splice.js +23 -0
  280. package/dist/internal/splice.js.map +1 -0
  281. package/dist/internal/window.d.ts +12 -0
  282. package/dist/internal/window.d.ts.map +1 -1
  283. package/dist/internal/window.js +23 -6
  284. package/dist/internal/window.js.map +1 -1
  285. package/dist/maps/anchors.d.ts +103 -0
  286. package/dist/maps/anchors.d.ts.map +1 -0
  287. package/dist/maps/anchors.js +790 -0
  288. package/dist/maps/anchors.js.map +1 -0
  289. package/dist/maps/controller.d.ts +340 -0
  290. package/dist/maps/controller.d.ts.map +1 -0
  291. package/dist/maps/controller.js +822 -0
  292. package/dist/maps/controller.js.map +1 -0
  293. package/dist/maps/gl/buckets.d.ts +99 -0
  294. package/dist/maps/gl/buckets.d.ts.map +1 -0
  295. package/dist/maps/gl/buckets.js +365 -0
  296. package/dist/maps/gl/buckets.js.map +1 -0
  297. package/dist/maps/gl/build-worker.d.ts +15 -0
  298. package/dist/maps/gl/build-worker.d.ts.map +1 -0
  299. package/dist/maps/gl/build-worker.js +44 -0
  300. package/dist/maps/gl/build-worker.js.map +1 -0
  301. package/dist/maps/gl/color.d.ts +8 -0
  302. package/dist/maps/gl/color.d.ts.map +1 -0
  303. package/dist/maps/gl/color.js +57 -0
  304. package/dist/maps/gl/color.js.map +1 -0
  305. package/dist/maps/gl/cover.d.ts +70 -0
  306. package/dist/maps/gl/cover.d.ts.map +1 -0
  307. package/dist/maps/gl/cover.js +137 -0
  308. package/dist/maps/gl/cover.js.map +1 -0
  309. package/dist/maps/gl/index.d.ts +3 -0
  310. package/dist/maps/gl/index.d.ts.map +1 -0
  311. package/dist/maps/gl/index.js +20 -0
  312. package/dist/maps/gl/index.js.map +1 -0
  313. package/dist/maps/gl/markers.d.ts +28 -0
  314. package/dist/maps/gl/markers.d.ts.map +1 -0
  315. package/dist/maps/gl/markers.js +60 -0
  316. package/dist/maps/gl/markers.js.map +1 -0
  317. package/dist/maps/gl/overlays.d.ts +68 -0
  318. package/dist/maps/gl/overlays.d.ts.map +1 -0
  319. package/dist/maps/gl/overlays.js +300 -0
  320. package/dist/maps/gl/overlays.js.map +1 -0
  321. package/dist/maps/gl/pane.d.ts +12 -0
  322. package/dist/maps/gl/pane.d.ts.map +1 -0
  323. package/dist/maps/gl/pane.js +37 -0
  324. package/dist/maps/gl/pane.js.map +1 -0
  325. package/dist/maps/gl/placement.d.ts +124 -0
  326. package/dist/maps/gl/placement.d.ts.map +1 -0
  327. package/dist/maps/gl/placement.js +642 -0
  328. package/dist/maps/gl/placement.js.map +1 -0
  329. package/dist/maps/gl/renderer.d.ts +384 -0
  330. package/dist/maps/gl/renderer.d.ts.map +1 -0
  331. package/dist/maps/gl/renderer.js +1184 -0
  332. package/dist/maps/gl/renderer.js.map +1 -0
  333. package/dist/maps/gl/shaders.d.ts +121 -0
  334. package/dist/maps/gl/shaders.d.ts.map +1 -0
  335. package/dist/maps/gl/shaders.js +453 -0
  336. package/dist/maps/gl/shaders.js.map +1 -0
  337. package/dist/maps/gl/store.d.ts +125 -0
  338. package/dist/maps/gl/store.d.ts.map +1 -0
  339. package/dist/maps/gl/store.js +512 -0
  340. package/dist/maps/gl/store.js.map +1 -0
  341. package/dist/maps/gl/text.d.ts +283 -0
  342. package/dist/maps/gl/text.d.ts.map +1 -0
  343. package/dist/maps/gl/text.js +698 -0
  344. package/dist/maps/gl/text.js.map +1 -0
  345. package/dist/maps/gl/view.d.ts +345 -0
  346. package/dist/maps/gl/view.d.ts.map +1 -0
  347. package/dist/maps/gl/view.js +1209 -0
  348. package/dist/maps/gl/view.js.map +1 -0
  349. package/dist/maps/icons.d.ts +32 -0
  350. package/dist/maps/icons.d.ts.map +1 -0
  351. package/dist/maps/icons.js +151 -0
  352. package/dist/maps/icons.js.map +1 -0
  353. package/dist/maps/index.d.ts +3 -1
  354. package/dist/maps/index.d.ts.map +1 -1
  355. package/dist/maps/index.js +218 -52
  356. package/dist/maps/index.js.map +1 -1
  357. package/dist/maps/labels.d.ts +91 -18
  358. package/dist/maps/labels.d.ts.map +1 -1
  359. package/dist/maps/labels.js +376 -140
  360. package/dist/maps/labels.js.map +1 -1
  361. package/dist/maps/node.d.ts +179 -56
  362. package/dist/maps/node.d.ts.map +1 -1
  363. package/dist/maps/node.js +663 -537
  364. package/dist/maps/node.js.map +1 -1
  365. package/dist/maps/overlay.d.ts +72 -9
  366. package/dist/maps/overlay.d.ts.map +1 -1
  367. package/dist/maps/overlay.js +108 -28
  368. package/dist/maps/overlay.js.map +1 -1
  369. package/dist/maps/paint.d.ts +3 -0
  370. package/dist/maps/paint.d.ts.map +1 -1
  371. package/dist/maps/paint.js.map +1 -1
  372. package/dist/maps/proj.d.ts +62 -0
  373. package/dist/maps/proj.d.ts.map +1 -1
  374. package/dist/maps/proj.js +101 -0
  375. package/dist/maps/proj.js.map +1 -1
  376. package/dist/maps/renderer.d.ts +69 -0
  377. package/dist/maps/renderer.d.ts.map +1 -0
  378. package/dist/maps/renderer.js +66 -0
  379. package/dist/maps/renderer.js.map +1 -0
  380. package/dist/maps/sources.d.ts +30 -6
  381. package/dist/maps/sources.d.ts.map +1 -1
  382. package/dist/maps/sources.js +22 -3
  383. package/dist/maps/sources.js.map +1 -1
  384. package/dist/maps/style.d.ts +35 -0
  385. package/dist/maps/style.d.ts.map +1 -1
  386. package/dist/maps/style.js.map +1 -1
  387. package/dist/maps/styles.d.ts +12 -0
  388. package/dist/maps/styles.d.ts.map +1 -1
  389. package/dist/maps/styles.js +120 -6
  390. package/dist/maps/styles.js.map +1 -1
  391. package/dist/maps/theme.d.ts +19 -0
  392. package/dist/maps/theme.d.ts.map +1 -0
  393. package/dist/maps/theme.js +44 -0
  394. package/dist/maps/theme.js.map +1 -0
  395. package/dist/maps/tiles.d.ts +147 -27
  396. package/dist/maps/tiles.d.ts.map +1 -1
  397. package/dist/maps/tiles.js +221 -54
  398. package/dist/maps/tiles.js.map +1 -1
  399. package/dist/maps/types.d.ts +213 -21
  400. package/dist/maps/types.d.ts.map +1 -1
  401. package/dist/markdown/expressions.d.ts +1 -1
  402. package/dist/markdown/expressions.d.ts.map +1 -1
  403. package/dist/markdown/expressions.js.map +1 -1
  404. package/dist/markdown/index.d.ts +7 -6
  405. package/dist/markdown/index.d.ts.map +1 -1
  406. package/dist/markdown/index.js +121 -50
  407. package/dist/markdown/index.js.map +1 -1
  408. package/dist/markdown/spans.d.ts +1 -1
  409. package/dist/markdown/spans.d.ts.map +1 -1
  410. package/dist/markdown/spans.js +6 -0
  411. package/dist/markdown/spans.js.map +1 -1
  412. package/dist/media-player/index.d.ts +11 -3
  413. package/dist/media-player/index.d.ts.map +1 -1
  414. package/dist/media-player/index.js +13 -2
  415. package/dist/media-player/index.js.map +1 -1
  416. package/dist/qml/containers.d.ts.map +1 -1
  417. package/dist/qml/containers.js +6 -1
  418. package/dist/qml/containers.js.map +1 -1
  419. package/dist/qml/interaction.d.ts.map +1 -1
  420. package/dist/qml/interaction.js +38 -5
  421. package/dist/qml/interaction.js.map +1 -1
  422. package/dist/qml/layouts.d.ts.map +1 -1
  423. package/dist/qml/layouts.js +21 -6
  424. package/dist/qml/layouts.js.map +1 -1
  425. package/dist/qml/qtquick.d.ts.map +1 -1
  426. package/dist/qml/qtquick.js +9 -2
  427. package/dist/qml/qtquick.js.map +1 -1
  428. package/dist/qml/react.d.ts.map +1 -1
  429. package/dist/qml/react.js +9 -7
  430. package/dist/qml/react.js.map +1 -1
  431. package/dist/reorder/clock.d.ts +7 -0
  432. package/dist/reorder/clock.d.ts.map +1 -0
  433. package/dist/reorder/clock.js +26 -0
  434. package/dist/reorder/clock.js.map +1 -0
  435. package/dist/reorder/index.d.ts +25 -3
  436. package/dist/reorder/index.d.ts.map +1 -1
  437. package/dist/reorder/index.js +64 -19
  438. package/dist/reorder/index.js.map +1 -1
  439. package/dist/rich-text-editor/clipboard.d.ts +23 -0
  440. package/dist/rich-text-editor/clipboard.d.ts.map +1 -0
  441. package/dist/rich-text-editor/clipboard.js +307 -0
  442. package/dist/rich-text-editor/clipboard.js.map +1 -0
  443. package/dist/rich-text-editor/collab.d.ts +32 -0
  444. package/dist/rich-text-editor/collab.d.ts.map +1 -0
  445. package/dist/rich-text-editor/collab.js +55 -0
  446. package/dist/rich-text-editor/collab.js.map +1 -0
  447. package/dist/rich-text-editor/commands.d.ts +50 -0
  448. package/dist/rich-text-editor/commands.d.ts.map +1 -0
  449. package/dist/rich-text-editor/commands.js +376 -0
  450. package/dist/rich-text-editor/commands.js.map +1 -0
  451. package/dist/rich-text-editor/drag.d.ts +68 -0
  452. package/dist/rich-text-editor/drag.d.ts.map +1 -0
  453. package/dist/rich-text-editor/drag.js +148 -0
  454. package/dist/rich-text-editor/drag.js.map +1 -0
  455. package/dist/rich-text-editor/html.d.ts +145 -0
  456. package/dist/rich-text-editor/html.d.ts.map +1 -0
  457. package/dist/rich-text-editor/html.js +516 -0
  458. package/dist/rich-text-editor/html.js.map +1 -0
  459. package/dist/rich-text-editor/index.d.ts +252 -0
  460. package/dist/rich-text-editor/index.d.ts.map +1 -0
  461. package/dist/rich-text-editor/index.js +856 -0
  462. package/dist/rich-text-editor/index.js.map +1 -0
  463. package/dist/rich-text-editor/inline.d.ts +94 -0
  464. package/dist/rich-text-editor/inline.d.ts.map +1 -0
  465. package/dist/rich-text-editor/inline.js +252 -0
  466. package/dist/rich-text-editor/inline.js.map +1 -0
  467. package/dist/rich-text-editor/keymap.d.ts +71 -0
  468. package/dist/rich-text-editor/keymap.d.ts.map +1 -0
  469. package/dist/rich-text-editor/keymap.js +394 -0
  470. package/dist/rich-text-editor/keymap.js.map +1 -0
  471. package/dist/rich-text-editor/keys.d.ts +40 -0
  472. package/dist/rich-text-editor/keys.d.ts.map +1 -0
  473. package/dist/rich-text-editor/keys.js +202 -0
  474. package/dist/rich-text-editor/keys.js.map +1 -0
  475. package/dist/rich-text-editor/look.d.ts +71 -0
  476. package/dist/rich-text-editor/look.d.ts.map +1 -0
  477. package/dist/rich-text-editor/look.js +342 -0
  478. package/dist/rich-text-editor/look.js.map +1 -0
  479. package/dist/rich-text-editor/markdown.d.ts +18 -0
  480. package/dist/rich-text-editor/markdown.d.ts.map +1 -0
  481. package/dist/rich-text-editor/markdown.js +496 -0
  482. package/dist/rich-text-editor/markdown.js.map +1 -0
  483. package/dist/rich-text-editor/marks.d.ts +9 -0
  484. package/dist/rich-text-editor/marks.d.ts.map +1 -0
  485. package/dist/rich-text-editor/marks.js +83 -0
  486. package/dist/rich-text-editor/marks.js.map +1 -0
  487. package/dist/rich-text-editor/nodes.d.ts +135 -0
  488. package/dist/rich-text-editor/nodes.d.ts.map +1 -0
  489. package/dist/rich-text-editor/nodes.js +397 -0
  490. package/dist/rich-text-editor/nodes.js.map +1 -0
  491. package/dist/rich-text-editor/render.d.ts +68 -0
  492. package/dist/rich-text-editor/render.d.ts.map +1 -0
  493. package/dist/rich-text-editor/render.js +518 -0
  494. package/dist/rich-text-editor/render.js.map +1 -0
  495. package/dist/rich-text-editor/schema.d.ts +16 -0
  496. package/dist/rich-text-editor/schema.d.ts.map +1 -0
  497. package/dist/rich-text-editor/schema.js +386 -0
  498. package/dist/rich-text-editor/schema.js.map +1 -0
  499. package/dist/rich-text-editor/store.d.ts +11 -0
  500. package/dist/rich-text-editor/store.d.ts.map +1 -0
  501. package/dist/rich-text-editor/store.js +43 -0
  502. package/dist/rich-text-editor/store.js.map +1 -0
  503. package/dist/rich-text-editor/suggest.d.ts +127 -0
  504. package/dist/rich-text-editor/suggest.d.ts.map +1 -0
  505. package/dist/rich-text-editor/suggest.js +0 -0
  506. package/dist/rich-text-editor/suggest.js.map +1 -0
  507. package/dist/rich-text-editor/tables.d.ts +41 -0
  508. package/dist/rich-text-editor/tables.d.ts.map +1 -0
  509. package/dist/rich-text-editor/tables.js +204 -0
  510. package/dist/rich-text-editor/tables.js.map +1 -0
  511. package/dist/rich-text-editor/toolbar.d.ts +58 -0
  512. package/dist/rich-text-editor/toolbar.d.ts.map +1 -0
  513. package/dist/rich-text-editor/toolbar.js +548 -0
  514. package/dist/rich-text-editor/toolbar.js.map +1 -0
  515. package/dist/rich-text-editor/view.d.ts +368 -0
  516. package/dist/rich-text-editor/view.d.ts.map +1 -0
  517. package/dist/rich-text-editor/view.js +1968 -0
  518. package/dist/rich-text-editor/view.js.map +1 -0
  519. package/dist/rich-text-editor/virtual.d.ts +54 -0
  520. package/dist/rich-text-editor/virtual.d.ts.map +1 -0
  521. package/dist/rich-text-editor/virtual.js +279 -0
  522. package/dist/rich-text-editor/virtual.js.map +1 -0
  523. package/dist/richtext/node.d.ts +71 -4
  524. package/dist/richtext/node.d.ts.map +1 -1
  525. package/dist/richtext/node.js +233 -28
  526. package/dist/richtext/node.js.map +1 -1
  527. package/dist/richtext/runs.d.ts +13 -8
  528. package/dist/richtext/runs.d.ts.map +1 -1
  529. package/dist/richtext/runs.js +18 -8
  530. package/dist/richtext/runs.js.map +1 -1
  531. package/dist/table/index.d.ts.map +1 -1
  532. package/dist/table/index.js +23 -0
  533. package/dist/table/index.js.map +1 -1
  534. package/dist/table/rows.d.ts +3 -2
  535. package/dist/table/rows.d.ts.map +1 -1
  536. package/dist/table/rows.js +20 -6
  537. package/dist/table/rows.js.map +1 -1
  538. package/dist/terminal/backends.d.ts +4 -1
  539. package/dist/terminal/backends.d.ts.map +1 -1
  540. package/dist/terminal/backends.js.map +1 -1
  541. package/dist/terminal/index.d.ts +18 -7
  542. package/dist/terminal/index.d.ts.map +1 -1
  543. package/dist/terminal/index.js +32 -11
  544. package/dist/terminal/index.js.map +1 -1
  545. package/dist/terminal/vt/pty.d.ts +13 -2
  546. package/dist/terminal/vt/pty.d.ts.map +1 -1
  547. package/dist/terminal/vt/pty.js +18 -3
  548. package/dist/terminal/vt/pty.js.map +1 -1
  549. package/dist/terminal-output/index.d.ts.map +1 -1
  550. package/dist/terminal-output/index.js +6 -1
  551. package/dist/terminal-output/index.js.map +1 -1
  552. package/dist/three/canvas.d.ts.map +1 -1
  553. package/dist/three/canvas.js +85 -7
  554. package/dist/three/canvas.js.map +1 -1
  555. package/dist/three/events.d.ts +22 -4
  556. package/dist/three/events.d.ts.map +1 -1
  557. package/dist/three/events.js +34 -4
  558. package/dist/three/events.js.map +1 -1
  559. package/dist/tree/index.d.ts.map +1 -1
  560. package/dist/tree/index.js +17 -0
  561. package/dist/tree/index.js.map +1 -1
  562. package/package.json +59 -31
  563. package/src/charts/node.ts +68 -28
  564. package/src/charts/render.ts +120 -30
  565. package/src/code/index.ts +3 -1
  566. package/src/code-editor/completion.ts +3 -2
  567. package/src/code-editor/index.ts +19 -0
  568. package/src/code-editor/node.ts +1574 -184
  569. package/src/code-language/languages/glsl.ts +12 -2
  570. package/src/code-language/languages/javascript.ts +21 -4
  571. package/src/code-language/languages/json.ts +12 -2
  572. package/src/code-language/languages/shell.ts +12 -2
  573. package/src/code-language/languages/sql.ts +22 -2
  574. package/src/code-language/stream.ts +257 -17
  575. package/src/code-language/types.ts +5 -4
  576. package/src/codeblock/index.ts +20 -3
  577. package/src/embed/client.ts +102 -8
  578. package/src/embed/index.ts +2 -0
  579. package/src/flow/draw.ts +167 -41
  580. package/src/flow/gl/color.ts +85 -0
  581. package/src/flow/gl/index.ts +342 -0
  582. package/src/flow/gl/pack.ts +585 -0
  583. package/src/flow/gl/renderer.ts +679 -0
  584. package/src/flow/gl/shaders.ts +371 -0
  585. package/src/flow/gl/text.ts +1295 -0
  586. package/src/flow/index.ts +794 -67
  587. package/src/flow/node.ts +2496 -1256
  588. package/src/flow/paint.ts +511 -0
  589. package/src/flow/paths.ts +80 -15
  590. package/src/flow/scene.ts +2199 -0
  591. package/src/flow/types.ts +113 -1
  592. package/src/html/controls.ts +73 -2
  593. package/src/html/css/calc.ts +385 -0
  594. package/src/html/css/cascade.ts +1403 -62
  595. package/src/html/css/color.ts +755 -0
  596. package/src/html/css/content.ts +417 -0
  597. package/src/html/css/counter-styles.ts +973 -0
  598. package/src/html/css/decode.ts +130 -0
  599. package/src/html/css/parse.ts +1514 -160
  600. package/src/html/css/style.ts +4102 -222
  601. package/src/html/css/ua.ts +58 -12
  602. package/src/html/css/values.ts +150 -50
  603. package/src/html/css/vars.ts +348 -0
  604. package/src/html/dom.ts +284 -17
  605. package/src/html/fonts.ts +641 -0
  606. package/src/html/gif.ts +233 -0
  607. package/src/html/index.ts +180 -20
  608. package/src/html/layout/block.ts +3456 -230
  609. package/src/html/layout/boxes.ts +2100 -190
  610. package/src/html/layout/cache.ts +289 -0
  611. package/src/html/layout/collapse.ts +317 -0
  612. package/src/html/layout/css-grid.ts +1063 -0
  613. package/src/html/layout/flex.ts +1115 -106
  614. package/src/html/layout/floats.ts +128 -14
  615. package/src/html/layout/grid-lines.ts +141 -0
  616. package/src/html/layout/grid.ts +181 -0
  617. package/src/html/layout/inline.ts +3943 -192
  618. package/src/html/layout/shaping.ts +140 -0
  619. package/src/html/layout/table.ts +696 -186
  620. package/src/html/layout/tracks.ts +437 -0
  621. package/src/html/node.ts +1486 -121
  622. package/src/html/paint.ts +4293 -169
  623. package/src/html/resources.ts +161 -50
  624. package/src/html/surfaces.ts +81 -0
  625. package/src/html/svg.ts +493 -0
  626. package/src/html/url.ts +86 -0
  627. package/src/index.ts +4 -0
  628. package/src/internal/collate.ts +12 -0
  629. package/src/{markdown → internal/markdown}/ast.ts +35 -7
  630. package/src/{markdown → internal/markdown}/parse.ts +288 -35
  631. package/src/internal/markdown/stringify.ts +772 -0
  632. package/src/internal/prewarm.ts +45 -0
  633. package/src/internal/scroll.ts +5 -3
  634. package/src/internal/sdf.ts +227 -0
  635. package/src/internal/splice.ts +25 -0
  636. package/src/internal/window.ts +36 -5
  637. package/src/maps/anchors.ts +913 -0
  638. package/src/maps/controller.ts +1084 -0
  639. package/src/maps/gl/buckets.ts +461 -0
  640. package/src/maps/gl/build-worker.ts +66 -0
  641. package/src/maps/gl/color.ts +62 -0
  642. package/src/maps/gl/cover.ts +205 -0
  643. package/src/maps/gl/index.ts +23 -0
  644. package/src/maps/gl/markers.ts +83 -0
  645. package/src/maps/gl/overlays.ts +379 -0
  646. package/src/maps/gl/pane.ts +42 -0
  647. package/src/maps/gl/placement.ts +968 -0
  648. package/src/maps/gl/renderer.ts +1660 -0
  649. package/src/maps/gl/shaders.ts +489 -0
  650. package/src/maps/gl/store.ts +615 -0
  651. package/src/maps/gl/text.ts +868 -0
  652. package/src/maps/gl/view.ts +1489 -0
  653. package/src/maps/icons.ts +183 -0
  654. package/src/maps/index.ts +274 -81
  655. package/src/maps/labels.ts +457 -144
  656. package/src/maps/node.ts +832 -665
  657. package/src/maps/overlay.ts +148 -43
  658. package/src/maps/paint.ts +3 -0
  659. package/src/maps/proj.ts +118 -0
  660. package/src/maps/renderer.ts +155 -0
  661. package/src/maps/sources.ts +43 -6
  662. package/src/maps/style.ts +32 -0
  663. package/src/maps/styles.ts +215 -4
  664. package/src/maps/theme.ts +51 -0
  665. package/src/maps/tiles.ts +253 -62
  666. package/src/maps/types.ts +213 -21
  667. package/src/markdown/expressions.ts +5 -1
  668. package/src/markdown/index.ts +163 -78
  669. package/src/markdown/spans.ts +6 -1
  670. package/src/media-player/index.ts +18 -4
  671. package/src/qml/containers.tsx +6 -1
  672. package/src/qml/interaction.tsx +42 -5
  673. package/src/qml/layouts.tsx +21 -6
  674. package/src/qml/qtquick.tsx +9 -2
  675. package/src/qml/react.tsx +7 -5
  676. package/src/reorder/clock.ts +27 -0
  677. package/src/reorder/index.ts +100 -27
  678. package/src/rich-text-editor/clipboard.ts +439 -0
  679. package/src/rich-text-editor/collab.ts +91 -0
  680. package/src/rich-text-editor/commands.ts +448 -0
  681. package/src/rich-text-editor/dom-globals.d.ts +23 -0
  682. package/src/rich-text-editor/drag.ts +211 -0
  683. package/src/rich-text-editor/html.ts +619 -0
  684. package/src/rich-text-editor/index.ts +1364 -0
  685. package/src/rich-text-editor/inline.ts +345 -0
  686. package/src/rich-text-editor/keymap.ts +510 -0
  687. package/src/rich-text-editor/keys.ts +228 -0
  688. package/src/rich-text-editor/look.ts +427 -0
  689. package/src/rich-text-editor/markdown.ts +653 -0
  690. package/src/rich-text-editor/marks.ts +101 -0
  691. package/src/rich-text-editor/nodes.ts +497 -0
  692. package/src/rich-text-editor/render.ts +837 -0
  693. package/src/rich-text-editor/schema.ts +431 -0
  694. package/src/rich-text-editor/store.ts +39 -0
  695. package/src/rich-text-editor/suggest.ts +597 -0
  696. package/src/rich-text-editor/tables.ts +272 -0
  697. package/src/rich-text-editor/toolbar.ts +724 -0
  698. package/src/rich-text-editor/view.ts +2287 -0
  699. package/src/rich-text-editor/virtual.ts +372 -0
  700. package/src/richtext/node.ts +294 -44
  701. package/src/richtext/runs.ts +23 -12
  702. package/src/table/index.ts +29 -0
  703. package/src/table/rows.ts +18 -8
  704. package/src/terminal/backends.ts +4 -1
  705. package/src/terminal/index.ts +47 -15
  706. package/src/terminal/vt/pty.ts +16 -2
  707. package/src/terminal-output/index.ts +6 -1
  708. package/src/three/canvas.ts +102 -8
  709. package/src/three/events.ts +49 -9
  710. package/src/tree/index.ts +19 -0
  711. package/dist/markdown/ast.d.ts.map +0 -1
  712. package/dist/markdown/ast.js +0 -17
  713. package/dist/markdown/ast.js.map +0 -1
  714. package/dist/markdown/parse.d.ts.map +0 -1
  715. package/dist/markdown/parse.js.map +0 -1
  716. package/dist/markdown/tags.d.ts.map +0 -1
  717. package/dist/markdown/tags.js.map +0 -1
  718. /package/dist/{markdown → internal/markdown}/parse.d.ts +0 -0
  719. /package/dist/{markdown → internal/markdown}/tags.d.ts +0 -0
  720. /package/dist/{markdown → internal/markdown}/tags.js +0 -0
  721. /package/src/{markdown → internal/markdown}/tags.ts +0 -0
@@ -25,21 +25,40 @@
25
25
  // and hands them back on the laid-out runs, so the decoration a `<span>`
26
26
  // carries reaches paint on the same object the glyphs did — which is exactly
27
27
  // the trick `<richtext>` is built on, and it is why paint reuses richtext's
28
- // decoration pass unchanged instead of reimplementing it.
29
- import type { Element } from 'domhandler';
28
+ // decoration pass unchanged instead of reimplementing it. What a run does
29
+ // *not* carry is its element: a layout outlives the parse it was made for
30
+ // (`TextLayoutCache`), so the element under a run is found from its text's
31
+ // place in the document (`LineText.spans`) instead.
30
32
 
31
- import { codeUnitOffsets } from '../../internal/text.js';
33
+ import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
32
34
  import type { TextRun } from '../../richtext/index.js';
33
35
  import type { ComputedStyle } from '../css/style.js';
34
- import { inkColor, isTransparent } from '../css/values.js';
36
+ import {
37
+ CONTAIN_LAYOUT,
38
+ INLINE_BEFORE_ABSOLUTE,
39
+ scrolls,
40
+ } from '../css/style.js';
41
+ import { inkColor, isTransparent, resolve } from '../css/values.js';
42
+ import {
43
+ BOX_RAISES,
44
+ LINE_BOX_RAISES,
45
+ isOffset,
46
+ SHADOWED_TEXT,
47
+ SHIFTED_LINES,
48
+ TEXT_RAISES,
49
+ } from './boxes.js';
35
50
  import type {
36
51
  AtomicPlacement,
37
52
  Box,
53
+ EdgePlacement,
54
+ FirstLineStyler,
55
+ InlineDecoration,
38
56
  LineBox,
39
57
  LineText,
40
58
  TextLayoutLike,
41
59
  } from './boxes.js';
42
60
  import type { FloatContext } from './floats.js';
61
+ import { tableGrid } from './grid.js';
43
62
 
44
63
  /** The slice of ntk's font manager this needs. Structural, as everywhere. */
45
64
  export interface FontsLike {
@@ -52,6 +71,15 @@ export interface FontsLike {
52
71
  align?: string;
53
72
  direction?: string;
54
73
  maxLines?: number;
74
+ /** What a `maxLines` cut looks like: an ellipsis, or nothing. */
75
+ overflow?: 'clip' | 'ellipsis';
76
+ /** A word too long for its line kept whole, past the line's end, or
77
+ * cut inside itself — ntk's option, which an engine without it
78
+ * leaves at cutting. */
79
+ overflowWrap?: 'normal' | 'break-word';
80
+ /** Whether soft wraps are made at all: false is a line to each
81
+ * forced break, cut at `maxWidth` where `overflow` says so. ntk's. */
82
+ wrap?: boolean;
55
83
  },
56
84
  ): TextLayoutLike;
57
85
  match(
@@ -78,6 +106,128 @@ export interface FontsLike {
78
106
  */
79
107
  const LAYOUT_RUNS = new WeakMap<TextLayoutLike, TextRun[] | number[]>();
80
108
 
109
+ const WIDEST_WORD = new WeakMap<TextLayoutLike, number>();
110
+
111
+ /**
112
+ * The widest word between spaces in a layout's text: its runs laid out
113
+ * again with every space a line break, at no width limit. Where a text's
114
+ * only breaks are spaces that is its min-content width, and where it has
115
+ * others — a hyphen, a slash — more: an upper bound of it either way. The
116
+ * other way to ask, a layout of no width, has ntk search every word for a
117
+ * place to cut it, since none fits, which costs fifteen layouts of this
118
+ * one. Infinity where the runs are no longer kept.
119
+ */
120
+ export function widestWord(
121
+ fonts: FontsLike,
122
+ layout: TextLayoutLike,
123
+ measured = true,
124
+ ): number {
125
+ if (!measured) return longestWord(layout);
126
+ let widest = WIDEST_WORD.get(layout);
127
+ if (widest !== undefined) return widest;
128
+ const held = LAYOUT_RUNS.get(layout);
129
+ if (!held || (held.length > 0 && typeof held[0] === 'number')) {
130
+ return Infinity;
131
+ }
132
+ const words = fonts.layout(
133
+ (held as TextRun[]).map((run) => ({
134
+ ...run,
135
+ text: run.text.replace(/[ \t\u200b]+/g, '\n'),
136
+ })),
137
+ {},
138
+ {},
139
+ );
140
+ widest = 0;
141
+ for (const line of words.lines) widest = Math.max(widest, line.width);
142
+ WIDEST_WORD.set(layout, widest);
143
+ return widest;
144
+ }
145
+
146
+ const SPACE_BREAKS = new WeakMap<TextLayoutLike, boolean>();
147
+
148
+ /**
149
+ * Whether a layout's text breaks only at its spaces: letters and digits of
150
+ * the Latin, Greek and Cyrillic scripts, and the punctuation that UAX #14
151
+ * never breaks after between two letters — no hyphen, slash, `!`, `?`,
152
+ * `|`, `}`, soft hyphen or ellipsis, and nothing of a script that breaks
153
+ * between its letters or by dictionary. Where it does, its widest word
154
+ * (`widestWord`) is exactly its min-content width.
155
+ */
156
+ export function spaceBreaksOnly(layout: TextLayoutLike): boolean {
157
+ let only = SPACE_BREAKS.get(layout);
158
+ if (only !== undefined) return only;
159
+ const held = LAYOUT_RUNS.get(layout);
160
+ only = !!held && (held.length === 0 || typeof held[0] !== 'number');
161
+ if (only) {
162
+ outer: for (const run of held as TextRun[]) {
163
+ const text = run.text;
164
+ for (let i = 0; i < text.length; i += 1) {
165
+ if (!breaksOnlyAtSpace(text.charCodeAt(i))) {
166
+ only = false;
167
+ break outer;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ SPACE_BREAKS.set(layout, only);
173
+ return only;
174
+ }
175
+
176
+ function breaksOnlyAtSpace(c: number): boolean {
177
+ if (c < 0x80) {
178
+ // `-` `/` `!` `?` `|` `}`: a break may follow each
179
+ return (
180
+ c >= 0x20 &&
181
+ c !== 0x2d &&
182
+ c !== 0x2f &&
183
+ c !== 0x21 &&
184
+ c !== 0x3f &&
185
+ c !== 0x7c &&
186
+ c !== 0x7d &&
187
+ c !== 0x7f
188
+ );
189
+ }
190
+ // Latin-1 but the soft hyphen and the acute accent, which a break may
191
+ // precede; Latin Extended, the combining marks, Greek and Cyrillic
192
+ if (c >= 0xa0 && c < 0x530) return c !== 0xad && c !== 0xb4 && c !== 0x2c8;
193
+ // the curly quotes
194
+ return c >= 0x2018 && c <= 0x201f;
195
+ }
196
+
197
+ const LONGEST_WORD = new WeakMap<TextLayoutLike, number>();
198
+
199
+ /**
200
+ * `widestWord` estimated from the text alone, with no layout: each word's
201
+ * characters at half again their size, which no glyph a document sets
202
+ * words in is wider than, and their letter spacing. What is under it
203
+ * needs no measuring, and most words are.
204
+ */
205
+ function longestWord(layout: TextLayoutLike): number {
206
+ let longest = LONGEST_WORD.get(layout);
207
+ if (longest !== undefined) return longest;
208
+ const held = LAYOUT_RUNS.get(layout);
209
+ if (!held || (held.length > 0 && typeof held[0] === 'number')) {
210
+ return Infinity;
211
+ }
212
+ longest = 0;
213
+ let word = 0;
214
+ for (const run of held as TextRun[]) {
215
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
216
+ const text = run.text;
217
+ for (let i = 0; i < text.length; i += 1) {
218
+ const c = text.charCodeAt(i);
219
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x200b) word = 0;
220
+ else if (c < 0xdc00 || c > 0xdfff) {
221
+ // a surrogate pair is one character
222
+ word += per;
223
+ if (word > longest) longest = word;
224
+ }
225
+ }
226
+ }
227
+ LONGEST_WORD.set(layout, longest);
228
+ return longest;
229
+ }
230
+
81
231
  /** Code-unit → code-point offsets for a layout's text, built once. */
82
232
  export function layoutOffsets(layout: TextLayoutLike): number[] {
83
233
  const held = LAYOUT_RUNS.get(layout);
@@ -92,16 +242,44 @@ export function layoutOffsets(layout: TextLayoutLike): number[] {
92
242
 
93
243
  const EMPTY_OFFSETS: number[] = [0];
94
244
 
95
- /** One thing in the inline stream. */
245
+ /** One thing in the inline stream. An `edge` is an inline box's own margin,
246
+ * border and padding on one side — before its first fragment, or after its
247
+ * last (CSS 2.1 8.x, 10.3.1) — which takes room on the line and draws no
248
+ * glyph. */
96
249
  type Item =
97
- | { kind: 'text'; run: TextRun; box: Box; length: number }
98
- | { kind: 'atomic'; box: Box };
250
+ | {
251
+ kind: 'text';
252
+ run: TextRun;
253
+ box: Box;
254
+ length: number;
255
+ /** Where the run's text starts in the document index: its box's
256
+ * start, or further in, where the box's text is split. */
257
+ start: number;
258
+ /** A bidi control `unicode-bidi` stands for (`bidiControls`): laid
259
+ * out, and no text of the document's. */
260
+ control?: true;
261
+ }
262
+ | { kind: 'atomic'; box: Box }
263
+ | { kind: 'edge'; box: Box; side: 'start' | 'end'; width: number }
264
+ /** A float, where it is in the content: placed when the lines reach it
265
+ * (`InlineOptions.floatBoxes`). */
266
+ | { kind: 'float'; box: Box };
267
+
268
+ function isText(item: Item): item is Extract<Item, { kind: 'text' }> {
269
+ return item.kind === 'text';
270
+ }
99
271
 
100
272
  export interface InlineResult {
101
273
  lines: LineBox[];
102
274
  height: number;
103
275
  /** The widest line — what a shrink-to-fit width takes. */
104
276
  width: number;
277
+ /** The widest row of floats the paragraph placed beside none of its
278
+ * lines — all of them, where it has nothing else — side by side. */
279
+ floatRow?: number;
280
+ /** Whether a clamp cut the text short: there was more of it than the
281
+ * lines it left (`InlineOptions.clamp`). */
282
+ cut?: boolean;
105
283
  }
106
284
 
107
285
  export interface InlineOptions {
@@ -113,6 +291,36 @@ export interface InlineOptions {
113
291
  floats: FloatContext | null;
114
292
  /** The block's content-box left edge, in the float context's space. */
115
293
  originX: number;
294
+ /** A `::first-line` colour, for the text before `end` — the first line's
295
+ * end, in the document index — whose colour is `from`, the block's. */
296
+ firstLine?: { color: string; from: string; end: number };
297
+ /**
298
+ * A `::first-line` that sets the first line's fonts, which move where it
299
+ * breaks: its style, and each box's style on the line (`FirstLineStyler`)
300
+ * — which the line is found in, a piece at a time (CSS 2.1 5.12.1).
301
+ */
302
+ firstLineStyle?: { style: ComputedStyle; styler: FirstLineStyler };
303
+ /** Whether any box in the document paints its background through its
304
+ * text (`BoxTree.clipText`). */
305
+ clipText?: boolean;
306
+ /**
307
+ * The lines a line-clamp container leaves the block (CSS Overflow 4,
308
+ * 5.3.1): no more than `lines` of them show, and the last of a cut ends
309
+ * in an ellipsis where `ellipsis` says — as it does where they are all
310
+ * the block has and more of the container `follows` them, the clamp
311
+ * point just after them (4.2).
312
+ */
313
+ clamp?: { lines: number; ellipsis: boolean; follows: boolean };
314
+ /**
315
+ * The floats in the content, placed as the lines reach them: `size` lays
316
+ * one out and answers its outer width, `place` puts it at a height in the
317
+ * float context's space. Absent, the floats are not the lines' to place —
318
+ * they were, by an earlier pass over the same lines.
319
+ */
320
+ floatBoxes?: {
321
+ size(box: Box): number;
322
+ place(box: Box, y: number): void;
323
+ };
116
324
  }
117
325
 
118
326
  /**
@@ -120,12 +328,153 @@ export interface InlineOptions {
120
328
  * relative to the containing block's content box; the caller translates.
121
329
  */
122
330
  export function layoutInline(block: Box, options: InlineOptions): InlineResult {
123
- const fonts = options.fonts;
331
+ const result = layoutLines(block, options);
332
+ const clamp = options.clamp?.lines;
333
+ // A clamped block shows its first lines and is as tall as they are. The
334
+ // one-layout paths below had the engine cut them, with its ellipsis; a
335
+ // block laid out a line at a time — an image on a line, a float beside
336
+ // it — is cut here, and ends where its last line does, with none.
337
+ if (clamp !== undefined && result.lines.length > clamp) {
338
+ const lines = result.lines.slice(0, clamp);
339
+ const last = lines[clamp - 1];
340
+ let widest = 0;
341
+ for (const line of lines) widest = Math.max(widest, line.width);
342
+ return {
343
+ lines,
344
+ height: last ? last.y + last.height : 0,
345
+ width: widest,
346
+ cut: true,
347
+ };
348
+ }
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * The cut a block's text is laid out with, as the engine takes it: the
354
+ * lines a line-clamp container leaves it, or the one line of a
355
+ * `white-space: nowrap` block
356
+ * that clips with `text-overflow: ellipsis` — Tailwind's `truncate`, cut at
357
+ * the box's width with an ellipsis. That line is laid out unwrapped and
358
+ * cut where the box ends, inside a word if need be, as a browser cuts it,
359
+ * by an engine that reads `wrap` (ntk from 8.13.0); one that does not
360
+ * wraps it first and ends it after the word that fits.
361
+ */
362
+ /** A layout cut at a number of lines, with an ellipsis or with none. */
363
+ interface Cut {
364
+ maxLines: number;
365
+ overflow: 'clip' | 'ellipsis';
366
+ wrap?: false;
367
+ }
368
+
369
+ function cutOf(
370
+ style: ComputedStyle,
371
+ clamp: InlineOptions['clamp'],
372
+ ): Cut | null {
373
+ // the ellipsis a clamp ends in is placed apart (`ellipsized`)
374
+ if (clamp) return { maxLines: clamp.lines, overflow: 'clip' };
375
+ if (
376
+ style.textOverflow === 'ellipsis' &&
377
+ !wraps(style) &&
378
+ style.overflowX !== 'visible'
379
+ ) {
380
+ return { maxLines: 1, overflow: 'ellipsis', wrap: false };
381
+ }
382
+ return null;
383
+ }
384
+
385
+ function layoutLines(block: Box, options: InlineOptions): InlineResult {
124
386
  const items: Item[] = [];
125
- collect(block, items);
126
- if (!items.length || !fonts) return EMPTY;
387
+ const statics: StaticMark[] = [];
388
+ const floatCount = collect(
389
+ block,
390
+ items,
391
+ options.width,
392
+ options.fonts,
393
+ block.style,
394
+ !!options.floatBoxes,
395
+ statics,
396
+ );
397
+ const result = linesOf(block, options, items, floatCount);
398
+ // whichever way the lines were made, one pass over them
399
+ if (statics.length) {
400
+ staticPositions(statics, items, result.lines, block, options);
401
+ }
402
+ return result;
403
+ }
404
+
405
+ /** The lines of a block's inline content, gathered into `items`. */
406
+ function linesOf(
407
+ block: Box,
408
+ options: InlineOptions,
409
+ items: Item[],
410
+ floatCount: number,
411
+ ): InlineResult {
412
+ let fonts = options.fonts;
413
+ const placing = options.floatBoxes;
414
+ if (items.length === floatCount || !fonts) {
415
+ // no line to wait for: the floats go at the top
416
+ const placed: Box[] = [];
417
+ for (const item of items) {
418
+ if (item.kind === 'float') {
419
+ placing!.size(item.box);
420
+ placing!.place(item.box, options.startY);
421
+ placed.push(item.box);
422
+ }
423
+ }
424
+ if (!placed.length) return EMPTY;
425
+ return { ...EMPTY, floatRow: besideLines([], placed, options.startY) };
426
+ }
427
+ // text a box's background shows through (`background-clip: text`): its
428
+ // layouts are made through a recorder, so paint can lay the same text out
429
+ // again with no ink of its own and fill it with that background
430
+ if (options.clipText && clipsText(block, items)) {
431
+ CLIPPED_TEXT.add(block);
432
+ fonts = recording(fonts);
433
+ }
434
+ if (wraps(block.style)) holdNoWrap(items, block);
435
+ // one walk for what few paragraphs have: text that casts a shadow, and a
436
+ // tab, which only a `white-space` that keeps it leaves — so no text is
437
+ // searched for one where it cannot be
438
+ let tabbed = false;
439
+ let shadowed = false;
440
+ for (const item of items) {
441
+ if (item.kind !== 'text' || item.control) continue;
442
+ const style = item.box.style;
443
+ if (style.textShadow) shadowed = true;
444
+ if (
445
+ !tabbed &&
446
+ (style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
447
+ item.run.text.includes('\t')
448
+ ) {
449
+ tabbed = true;
450
+ }
451
+ }
452
+ if (shadowed) SHADOWED_TEXT.add(block);
453
+ if (tabbed) {
454
+ setTabs(items, fonts, block.style, blockIndent(block, options.width));
455
+ }
456
+ // an override on the block is one on all of its inline content (CSS 2.1
457
+ // 9.10); its embedding or isolation is the paragraph's own direction
458
+ const own =
459
+ block.style.unicodeBidi === 'bidi-override' ||
460
+ block.style.unicodeBidi === 'isolate-override'
461
+ ? bidiControls(block.style, true)
462
+ : null;
463
+ if (own) {
464
+ const first = items.find(isText);
465
+ let last: Extract<Item, { kind: 'text' }> | undefined;
466
+ for (const item of items) if (item.kind === 'text') last = item;
467
+ if (first && last) {
468
+ const opening: Item[] = [];
469
+ pushControls(opening, own[0], block, first.start);
470
+ items.unshift(...opening);
471
+ pushControls(items, own[1], block, last.start + last.length);
472
+ }
473
+ }
474
+ if (options.firstLine) firstLineColour(items, options.firstLine);
127
475
 
128
476
  const style = block.style;
477
+ const wrapWords = overflowWrapOf(style, items);
129
478
  const base = {
130
479
  family: style.fontFamily,
131
480
  size: style.fontSize,
@@ -134,16 +483,89 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
134
483
  color: style.color,
135
484
  };
136
485
  const lineHeightMul = lineHeightMultiplier(fonts, style);
137
- const align = alignFor(style);
138
- const indent = indentOf(style, options.width);
486
+ // The lines the end of the text or a forced break ends take
487
+ // `text-align-last` (CSS Text 3, 7.2), and a text that does not wrap is
488
+ // all such lines. Where they are aligned otherwise than the rest, and
489
+ // neither is justified — which fills a line whatever its alignment — the
490
+ // lines are made one at a time, each aligned its own way.
491
+ const lastAlign = lastAlignOf(style);
492
+ const align = engineAlign(wraps(style) ? style.textAlign : lastAlign);
493
+ const rtl = style.direction === 'rtl';
494
+ const alignedApart =
495
+ wraps(style) &&
496
+ style.textAlign !== 'justify' &&
497
+ lastAlign !== 'justify' &&
498
+ alignShift(style.textAlign, rtl) !== alignShift(lastAlign, rtl);
499
+ const indent = blockIndent(block, options.width);
500
+
501
+ let hasAtomics = false;
502
+ let hasEdges = false;
503
+ let hasOffset = false;
504
+ let raised = false;
505
+ for (const item of items) {
506
+ if (item.kind === 'atomic') hasAtomics = true;
507
+ else if (item.kind === 'text') {
508
+ // text in an inline box with a line height of its own, which an
509
+ // inline box of no edges has as much as one with them
510
+ if (!raised && fonts && inBoxOwningLeading(fonts, block, item.box)) {
511
+ hasOffset = true;
512
+ raised = true;
513
+ }
514
+ } else if (item.kind === 'edge') {
515
+ hasEdges = true;
516
+ if (
517
+ item.box.style.verticalAlign !== 'baseline' ||
518
+ ownsLeading(fonts, block.style, item.box.style) ||
519
+ tallerStrut(fonts, block.style, item.box)
520
+ ) {
521
+ hasOffset = true;
522
+ raised = true;
523
+ } else if (!hasOffset && isOffset(item.box.style)) hasOffset = true;
524
+ }
525
+ }
526
+ // a float in the content is placed on the line it is on, which only the
527
+ // line-at-a-time loop knows, as it does whatever floats are beside it
528
+ const floated =
529
+ floatCount > 0 ||
530
+ (options.floats?.intersects(options.startY, Infinity) ?? false);
531
+ // An inline box's edges take room on their lines. Where nothing else on
532
+ // the lines has to be placed a piece at a time, they go into the one
533
+ // layout as spacers (`spacerRun`) — which only holds where no bidi
534
+ // reordering can move one off the side of its box it belongs to, and no
535
+ // box between edges is moved afterwards, which needs its text apart.
536
+ const spaced =
537
+ hasEdges && !hasAtomics && !hasOffset && spacersHold(style, items);
538
+ // a first line in fonts of its own is found a piece at a time, in them
539
+ const restyled = options.firstLineStyle !== undefined;
139
540
 
140
- const hasAtomics = items.some((i) => i.kind === 'atomic');
141
- const floated = options.floats?.intersects(options.startY, Infinity) ?? false;
541
+ // Inline boxes' edges as spacers in one layout, where the engine left
542
+ // every edge on the line of the content it belongs to (`layoutSpaced`);
543
+ // the lines a piece at a time, below, where it did not.
544
+ if (spaced && !floated && !indent && !alignedApart && !restyled) {
545
+ const laid = layoutSpaced(
546
+ items,
547
+ base,
548
+ style,
549
+ options.width,
550
+ lineHeightMul,
551
+ align,
552
+ fonts,
553
+ );
554
+ if (laid) return laid;
555
+ }
142
556
 
143
557
  // The text-only, float-free, unindented case: one call, every line — or
144
558
  // one call per *chunk*, when the text is long and carries hard breaks.
145
- if (!hasAtomics && !floated && !indent) {
146
- // `hasAtomics` is false, so every item is text; the cast is that fact.
559
+ if (
560
+ !hasAtomics &&
561
+ !hasEdges &&
562
+ !raised &&
563
+ !floated &&
564
+ !indent &&
565
+ !alignedApart &&
566
+ !restyled
567
+ ) {
568
+ // no atomics and no edges, so everything is text
147
569
  const textItems = items as Extract<Item, { kind: 'text' }>[];
148
570
  let total = 0;
149
571
  let hasNewline = false;
@@ -152,7 +574,28 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
152
574
  if (!hasNewline && item.run.text.includes('\n')) hasNewline = true;
153
575
  }
154
576
  if (!total) return EMPTY;
155
- if (total > CHUNK_TRIGGER_CHARS && hasNewline) {
577
+ // `text-overflow` cuts every line that overflows, and a layout can cut
578
+ // only its last: one that a forced break ends is laid out apart, a hard
579
+ // line at a time, or the lines after the first were lost. A clamp is
580
+ // the paragraph's, and stays one layout.
581
+ const clamp = options.clamp;
582
+ let cut = cutOf(style, clamp);
583
+ const perLine = cut !== null && !clamp && hasNewline;
584
+ // an embedding cannot be cut into chunks
585
+ if (perLine && !hasControls(items)) {
586
+ return layoutChunked(
587
+ textItems,
588
+ base,
589
+ style,
590
+ options.width,
591
+ lineHeightMul,
592
+ align,
593
+ fonts,
594
+ { chars: Infinity, hardLines: 1, cut },
595
+ );
596
+ }
597
+ if (perLine) cut = null;
598
+ if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
156
599
  return layoutChunked(
157
600
  textItems,
158
601
  base,
@@ -163,22 +606,108 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
163
606
  fonts,
164
607
  );
165
608
  }
166
- const runs: TextRun[] = [];
167
- const spans = new SpanMap();
609
+ let runs: TextRun[] = [];
610
+ let spans = new SpanMap();
168
611
  for (const item of textItems) {
169
- spans.add(item.box.textStart, item.run.text.length);
612
+ spans.add(
613
+ item.start,
614
+ item.run.text.length,
615
+ item.control ? null : item.box,
616
+ );
170
617
  runs.push(item.run);
171
618
  }
172
- const layout = fonts.layout(runs, base, {
173
- maxWidth: wraps(style) ? options.width : undefined,
619
+ // justified lines fill their width, so where all but the last are,
620
+ // the layout is aligned as those are
621
+ const justifies = wraps(style) && style.textJustify !== 'none';
622
+ const justifyRest = style.textAlign === 'justify' && justifies;
623
+ const justifyLast = lastAlign === 'justify' && justifies;
624
+ const layoutOptions = {
625
+ maxWidth: wraps(style) || cut ? options.width : undefined,
174
626
  lineHeight: lineHeightMul,
175
- align,
627
+ align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
176
628
  direction: style.direction,
177
- });
629
+ overflowWrap: cutWrap(style, cut, wrapWords),
630
+ ...cut,
631
+ };
632
+ let layout = fonts.layout(runs, base, layoutOptions);
178
633
  LAYOUT_RUNS.set(layout, runs);
634
+ // A clamp that cut the text short says so, for the clamp point after
635
+ // it, and its last line ends in an ellipsis — as it does where the
636
+ // clamp point falls just after the lines, more of the container after
637
+ // them (CSS Overflow 4, 4.2).
638
+ let clampCut = false;
639
+ if (clamp) {
640
+ const shown = layout.lines.length;
641
+ clampCut =
642
+ shown > 0 &&
643
+ shown >= clamp.lines &&
644
+ (layout.truncated ?? inkBeyond(runs, layout.lines[shown - 1].end));
645
+ if (
646
+ clamp.ellipsis &&
647
+ shown > 0 &&
648
+ shown === clamp.lines &&
649
+ (clampCut || clamp.follows)
650
+ ) {
651
+ const ended = ellipsized(
652
+ fonts,
653
+ textItems,
654
+ runs,
655
+ base,
656
+ layoutOptions,
657
+ layout,
658
+ options.width,
659
+ style,
660
+ );
661
+ layout = ended.layout;
662
+ runs = ended.runs;
663
+ spans = ended.spans;
664
+ LAYOUT_RUNS.set(layout, runs);
665
+ }
666
+ }
667
+ // `text-wrap: balance`: the lines broken at the narrowest width that
668
+ // keeps as many of them, and then set in the whole width
669
+ let balanced = false;
670
+ if (
671
+ style.textWrapStyle === 'balance' &&
672
+ layoutOptions.maxWidth !== undefined &&
673
+ !cut &&
674
+ style.textAlign !== 'justify' &&
675
+ layout.lines.length > 1 &&
676
+ layout.lines.length <= BALANCE_LINES
677
+ ) {
678
+ const width = layoutOptions.maxWidth;
679
+ const at = balancedWidth(fonts, runs, base, layoutOptions, width, layout);
680
+ if (at < width) {
681
+ layout = fonts.layout(runs, base, { ...layoutOptions, maxWidth: at });
682
+ LAYOUT_RUNS.set(layout, runs);
683
+ balanced = true;
684
+ }
685
+ }
686
+ layout = justifiedLayout(
687
+ fonts,
688
+ runs,
689
+ base,
690
+ layoutOptions,
691
+ layout,
692
+ options.width,
693
+ { rest: justifyRest, last: justifyLast },
694
+ ).layout;
179
695
  const lines: LineBox[] = [];
180
- const widest = emitLayout(layout, spans, 0, 0, lines);
181
- return { lines, height: layout.height, width: widest };
696
+ const widest = emitLayout(
697
+ layout,
698
+ spans,
699
+ 0,
700
+ 0,
701
+ lines,
702
+ layoutOptions.maxWidth === undefined
703
+ ? unwrappedPlacer(style, options.width, true)
704
+ : balanced
705
+ ? unwrappedPlacer(style, options.width, false)
706
+ : null,
707
+ );
708
+ return clampCut
709
+ ? { lines, height: layout.height, width: widest, cut: true }
710
+ : { lines, height: layout.height, width: widest };
182
711
  }
183
712
 
184
713
  // --- the general case: line at a time -------------------------------------
@@ -188,38 +717,236 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
188
717
  let index = 0;
189
718
  let offset = 0;
190
719
  let open = openLine(indent);
720
+ /** Start edges waiting for the content they belong to: an inline box's
721
+ * opening margin, border and padding go on the line its first content
722
+ * goes on, so they are placed with it, never left at a line's end. */
723
+ let pending: Extract<Item, { kind: 'edge' }>[] = [];
724
+ let pendingWidth = 0;
725
+ const placePending = (left: number): void => {
726
+ if (pending.length) open.left = left;
727
+ for (const edge of pending) {
728
+ const placed: EdgePlacement = {
729
+ box: edge.box,
730
+ side: 'start',
731
+ x: left + open.x,
732
+ width: edge.width,
733
+ };
734
+ open.edges.push(placed);
735
+ open.order.push({ kind: 'edge', at: open.x, item: placed });
736
+ open.x += edge.width;
737
+ }
738
+ pending = [];
739
+ pendingWidth = 0;
740
+ };
741
+ /** Floats met on a line they did not fit beside: they go at the top of
742
+ * the next line, as the line they were met on closes. */
743
+ let deferred: Box[] = [];
744
+ /** Every float placed, in order, for the lines they stand beside. */
745
+ const floatsPlaced: Box[] = [];
746
+ const placeDeferred = (): void => {
747
+ for (const box of deferred) {
748
+ options.floatBoxes!.place(box, options.startY + y);
749
+ floatsPlaced.push(box);
750
+ }
751
+ deferred = [];
752
+ };
191
753
 
192
- const close = (): void => {
193
- const line = finishLine(open, y);
754
+ const strut = fonts ? strutOf(fonts, style) : null;
755
+ // How tall a line is taken to be before it is made, for its room beside
756
+ // the floats: the paragraph's strut, a line of its text. An item taller
757
+ // asks over its own height, and the line made over its whole (`close`).
758
+ // A 1.4em guess put a line of `line-height: 0` below floats it fitted
759
+ // beside.
760
+ const guess = strut ? strut.ascent + strut.descent : style.fontSize * 1.4;
761
+ const lifts = raised && fonts ? new Lifts(fonts, block.style) : null;
762
+ const restShift = alignShift(style.textAlign, rtl);
763
+ const lastShift = alignShift(lastAlign, rtl);
764
+ // a justified line fills its room, whatever made it a piece at a time
765
+ const justify = justification(style);
766
+ const lineJustify: LineJustify | null =
767
+ fonts && (justify.rest || justify.last)
768
+ ? {
769
+ fonts,
770
+ base,
771
+ options: {
772
+ lineHeight: lineHeightMul,
773
+ align: 'left',
774
+ direction: style.direction,
775
+ overflowWrap: wrapWords,
776
+ },
777
+ }
778
+ : null;
779
+ // The first line in its `::first-line` fonts, until a line is closed
780
+ const lineOne = options.firstLineStyle
781
+ ? firstLineSetting(
782
+ block,
783
+ items,
784
+ options.firstLineStyle,
785
+ fonts,
786
+ strut,
787
+ lineJustify,
788
+ )
789
+ : null;
790
+ /** Close the open line; `last` where the text or a forced break ends it,
791
+ * which `text-align-last` aligns. */
792
+ const close = (last = false): void => {
793
+ const onFirst = lineOne !== null && !lines.length;
794
+ const line = finishLine(
795
+ open,
796
+ y,
797
+ // the room beside the floats over the whole line box, which an
798
+ // inline-block can make taller than its text (CSS 2.1 9.5)
799
+ (height) => bandAt(options, y, Math.max(height, guess)),
800
+ last || !wraps(style) ? lastShift : restShift,
801
+ rtl,
802
+ onFirst ? lineOne.strut : strut,
803
+ onFirst ? lineOne.lifts : lifts,
804
+ (last || !wraps(style) ? justify.last : justify.rest)
805
+ ? onFirst
806
+ ? lineOne.justify
807
+ : lineJustify
808
+ : null,
809
+ );
194
810
  if (!line) {
195
811
  open = openLine(0);
812
+ placeDeferred();
196
813
  return;
197
814
  }
198
815
  lines.push(line);
199
816
  widest = Math.max(widest, line.width);
200
817
  y += line.height;
201
818
  open = openLine(0);
819
+ placeDeferred();
202
820
  };
203
821
 
204
822
  while (index < items.length) {
205
- const band = bandAt(options, y, style.fontSize * 1.4);
823
+ const band = bandAt(options, y, guess);
206
824
  const available = band.right - band.left;
207
825
  const item = items[index];
826
+ const onFirst = lineOne !== null && !lines.length;
827
+ const lineItems = onFirst ? lineOne.items : items;
828
+ const lineBase = onFirst ? lineOne.base : base;
829
+
830
+ if (item.kind === 'float') {
831
+ // No higher than the top of the line it is met on (CSS 2.1 9.5.1):
832
+ // at that top where it fits beside what the line holds already, and
833
+ // what the line holds moves over for it; under the line where it
834
+ // does not, with the rest of the line's content still on the line.
835
+ // Where the line cannot break at the float, what follows it up to
836
+ // where it can is on the line too, and has to fit beside it as well,
837
+ // as browsers place one: `nowrap` text it is in the middle of runs on
838
+ // past it otherwise, under it.
839
+ const outer = options.floatBoxes!.size(item.box);
840
+ // and after a float that waits for the next line, since none goes
841
+ // higher than one before it (9.5.1, rule 5)
842
+ let fits =
843
+ !deferred.length &&
844
+ (isEmpty(open) || open.x + pendingWidth + outer <= available);
845
+ if (fits && !isEmpty(open) && !breaksAtEnd(open, style)) {
846
+ const after = unbreakableAfter(
847
+ lineItems,
848
+ index + 1,
849
+ style,
850
+ fonts!,
851
+ lineBase,
852
+ );
853
+ fits = open.x + pendingWidth + after + outer <= available;
854
+ }
855
+ if (fits) {
856
+ options.floatBoxes!.place(item.box, options.startY + y);
857
+ floatsPlaced.push(item.box);
858
+ const left = bandAt(options, y, guess).left;
859
+ if (!isEmpty(open) && left !== open.left) {
860
+ const dx = left - open.left;
861
+ for (const text of open.texts) text.drawX += dx;
862
+ for (const placed of open.atomics) placed.x += dx;
863
+ for (const edge of open.edges) edge.x += dx;
864
+ open.left = left;
865
+ }
866
+ } else {
867
+ deferred.push(item.box);
868
+ }
869
+ index += 1;
870
+ continue;
871
+ }
872
+
873
+ if (item.kind === 'edge') {
874
+ if (item.side === 'start') {
875
+ pending.push(item);
876
+ pendingWidth += item.width;
877
+ } else {
878
+ // an end edge follows the content it closes onto its line; it may
879
+ // run past the line's end, as a trailing space does, rather than
880
+ // leave the content it closes
881
+ placePending(band.left);
882
+ const placed: EdgePlacement = {
883
+ box: item.box,
884
+ side: 'end',
885
+ x: band.left + open.x,
886
+ width: item.width,
887
+ };
888
+ open.edges.push(placed);
889
+ open.order.push({ kind: 'edge', at: open.x, item: placed });
890
+ open.x += item.width;
891
+ open.left = band.left;
892
+ }
893
+ index += 1;
894
+ continue;
895
+ }
208
896
 
209
897
  if (item.kind === 'atomic') {
210
898
  const box = item.box;
211
899
  const outer = box.width + box.marginLeft + box.marginRight;
212
- if (open.x > 0 && open.x + outer > available) {
900
+ // An inline-block makes its line as tall as itself, and the line box
901
+ // must not run into a float over that height either (CSS 2.1 9.5):
902
+ // the room is what the floats leave beside all of it.
903
+ const tall = box.marginTop + box.height + box.marginBottom;
904
+ const lineTall = Math.max(guess, tall);
905
+ const room = tall > guess ? bandAt(options, y, tall) : band;
906
+ const roomWidth = room.right - room.left;
907
+ // a line with nothing on it keeps what comes first, indent or not:
908
+ // there is no break before it (a first line's `text-indent` is room
909
+ // taken, and no content)
910
+ if (open.x !== open.indent && open.x + pendingWidth + outer > roomWidth) {
213
911
  close();
214
912
  continue;
215
913
  }
216
- open.atomics.push({ box, x: band.left + open.x + box.marginLeft, y: 0 });
914
+ // an inline-block wider than the room a float left goes below it,
915
+ // as a word does (below)
916
+ if (
917
+ isEmpty(open) &&
918
+ pendingWidth + outer > roomWidth &&
919
+ roomWidth < options.width
920
+ ) {
921
+ const below = belowFloats(options, y, lineTall);
922
+ if (below !== null) {
923
+ y = below;
924
+ continue;
925
+ }
926
+ }
927
+ placePending(room.left);
928
+ const placed: AtomicPlacement = {
929
+ box,
930
+ x: room.left + open.x + box.marginLeft,
931
+ y: 0,
932
+ };
933
+ const raise = atomicRaise(fonts, box, (box.parent ?? block).style);
934
+ if (raise) placed.raise = raise;
935
+ open.atomics.push(placed);
936
+ open.order.push({ kind: 'atomic', at: open.x, item: placed });
217
937
  open.x += outer;
938
+ open.hang = 0;
939
+ open.left = room.left;
218
940
  index += 1;
219
941
  continue;
220
942
  }
221
943
 
222
- const segment = segmentFrom(items, index, offset);
944
+ const plain = segmentFrom(lineItems, index, offset);
945
+ // measured as it will be drawn where it is justified, its spaces
946
+ // spaced apart (`HAIR`) — the same text, so every offset holds
947
+ const segment = lineJustify
948
+ ? { ...plain, runs: spacedApart(plain.runs) }
949
+ : plain;
223
950
  // `segmentFrom` always passes at least the text item it started on, so
224
951
  // this advances even when the slice came out empty — which is what stops
225
952
  // a zero-length tail from spinning the loop.
@@ -234,19 +961,43 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
234
961
  // one layout, every line of it. This is the exit that keeps the loop
235
962
  // below from being quadratic on an ordinary paragraph: it runs line by
236
963
  // line only while it has to.
964
+ // An inline box's edges of no width on the open line are no room it
965
+ // takes, and are still on it: an empty `<span>` with a tall line height
966
+ // before the text was left on a line of its own after it, and the
967
+ // text's line was as short as the paragraph's
968
+ // (and its lines all aligned alike, which the last is not where
969
+ // `text-align-last` sets it apart)
237
970
  const tailIsPlain =
971
+ !alignedApart &&
972
+ !onFirst &&
238
973
  segment.nextIndex >= items.length &&
239
974
  open.x === 0 &&
240
975
  !open.atomics.length &&
976
+ !open.edges.length &&
977
+ !pending.length &&
978
+ !deferred.length &&
241
979
  !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
242
980
  if (tailIsPlain) {
243
- const layout = fonts.layout(segment.runs, base, {
981
+ const tailOptions = {
244
982
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
245
983
  lineHeight: lineHeightMul,
246
984
  align,
247
985
  direction: style.direction,
248
- });
986
+ overflowWrap: wrapWords,
987
+ };
988
+ let layout = fonts.layout(segment.runs, base, tailOptions);
249
989
  LAYOUT_RUNS.set(layout, segment.runs);
990
+ // the lines past the floats are justified as the lines beside them
991
+ // were (`finishLine`), in the room they have
992
+ layout = justifiedLayout(
993
+ fonts,
994
+ segment.runs,
995
+ base,
996
+ tailOptions,
997
+ layout,
998
+ Math.max(1, available),
999
+ justify,
1000
+ ).layout;
250
1001
  for (let i = 0; i < layout.lines.length; i += 1) {
251
1002
  const natural = layout.lines[i];
252
1003
  const text: LineText = {
@@ -257,13 +1008,15 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
257
1008
  textStart: segment.spans.documentAt(natural.start),
258
1009
  textEnd: segment.spans.documentAt(natural.end),
259
1010
  layoutStart: natural.start,
1011
+ spans: segment.spans,
260
1012
  };
1013
+ segment.spans.giveGaps(text, natural.start, natural.end);
261
1014
  lines.push({
262
1015
  x: band.left + natural.x,
263
1016
  y: y + natural.y,
264
1017
  width: natural.width,
265
1018
  height: natural.height,
266
- baseline: natural.baseline,
1019
+ baseline: natural.baseline - natural.y,
267
1020
  texts: [text],
268
1021
  textStart: text.textStart,
269
1022
  textEnd: text.textEnd,
@@ -283,15 +1036,30 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
283
1036
  // re-cut (which is what an earlier shape paid, per line, beside every
284
1037
  // float). ntk's shaping memo makes the successive cuts cheap; only the
285
1038
  // line breaker re-runs.
286
- const room = Math.max(1, available - open.x);
287
- const fragment = fonts.layout(segment.runs, base, {
288
- maxWidth: wraps(style) ? room : undefined,
289
- lineHeight: lineHeightMul,
290
- // A fragment that does not start at the line's left edge cannot be
291
- // aligned by the text layout: the alignment belongs to the whole line,
292
- // which only this loop can see.
293
- align: open.x > 0 ? 'left' : align,
1039
+ const room = Math.max(1, available - open.x - pendingWidth);
1040
+ // A float met inside a word, or inside a `nowrap` element, is no place
1041
+ // for the line to break: the text after it up to where it may break
1042
+ // is on this line too, so this line keeps room for it — or breaks
1043
+ // before the word it is tied to. It overran the line otherwise, the
1044
+ // float having gone below, and the break it could have taken passed.
1045
+ const tied =
1046
+ wraps(style) &&
1047
+ items[segment.nextIndex]?.kind === 'float' &&
1048
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
1049
+ ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
1050
+ : 0;
1051
+ const fragment = fonts.layout(segment.runs, lineBase, {
1052
+ maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
1053
+ lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
1054
+ // Never aligned by the text layout: the alignment belongs to the whole
1055
+ // line — its text, its atomics and its inline boxes' edges together —
1056
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1057
+ // out aligned, the first fragment of a centred line was centred alone
1058
+ // and whatever followed it on the line was placed as though it had
1059
+ // not been, over it.
1060
+ align: 'left',
294
1061
  direction: style.direction,
1062
+ overflowWrap: wrapWords,
295
1063
  maxLines: 1,
296
1064
  });
297
1065
  LAYOUT_RUNS.set(fragment, segment.runs);
@@ -301,96 +1069,436 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
301
1069
  offset = 0;
302
1070
  continue;
303
1071
  }
304
- open.texts.push({
1072
+ // Nothing fits beside the floats — the first word is wider than the
1073
+ // room they leave, and was either run past it or cut inside itself to
1074
+ // fit — so the line moves down to where a float ends, and tries again
1075
+ // there (CSS 2.1 9.5). Only where a float took some of the line's width:
1076
+ // past the floats the line has the whole of it, and a word wider still
1077
+ // is `overflow-wrap`'s to break or to let run past.
1078
+ if (
1079
+ isEmpty(open) &&
1080
+ available < options.width &&
1081
+ tooNarrow(segment.runs, first.end, first.width, room)
1082
+ ) {
1083
+ const below = belowFloats(options, y, guess);
1084
+ if (below !== null) {
1085
+ y = below;
1086
+ continue;
1087
+ }
1088
+ }
1089
+ // An inline box's opening edge goes with the first word after it: where
1090
+ // the word does not fit the room the edge leaves, both go to the next
1091
+ // line, rather than the edge being left at this one's end. And a word
1092
+ // that may start a line — after a space the line ends on, or after an
1093
+ // atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
1094
+ // next line whole where it does not fit what is left of this one,
1095
+ // rather than being broken inside itself to fit it: the text after an
1096
+ // inline-block, or after a float it was cut at, was its first letter
1097
+ // at the line's end and the rest on the next.
1098
+ const startsLine =
1099
+ wraps(style) &&
1100
+ (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
1101
+ if (
1102
+ !isEmpty(open) &&
1103
+ (pendingWidth > 0
1104
+ ? tooNarrow(segment.runs, first.end, first.width, room)
1105
+ : startsLine &&
1106
+ // run past the room — its white space is no part of the width —
1107
+ // or cut inside itself to fit it
1108
+ (first.width > room + 0.5 ||
1109
+ tooNarrow(segment.runs, first.end, first.width, room)))
1110
+ ) {
1111
+ close();
1112
+ continue;
1113
+ }
1114
+ placePending(band.left);
1115
+ const placed: LineText = {
305
1116
  layout: fragment,
306
1117
  layoutLine: 0,
307
- // The layout's own `first.x` carries the alignment offset, so the
308
- // origin is the band edge plus the cursor — subtracting `first.x`
309
- // here would left-align a centred line. Mid-line continuations were
310
- // laid out `left`, where `first.x` is zero and the two agree.
1118
+ // laid out `left`, so the fragment starts at the cursor
311
1119
  drawX: band.left + open.x,
312
1120
  drawY: 0, // filled in by `finishLine`, which is where the top is known
313
1121
  textStart: segment.spans.documentAt(first.start),
314
1122
  textEnd: segment.spans.documentAt(first.end),
315
1123
  layoutStart: first.start,
1124
+ spans: segment.spans,
1125
+ };
1126
+ segment.spans.giveGaps(placed, first.start, first.end);
1127
+ open.texts.push(placed);
1128
+ open.order.push({
1129
+ kind: 'text',
1130
+ at: open.x,
1131
+ item: placed,
1132
+ reads: readingOf(segment.runs, first.start, first.end),
316
1133
  });
317
1134
  open.x += first.width;
318
1135
  open.left = band.left;
1136
+ open.hang = 0;
319
1137
 
320
1138
  // Did the segment wrap? ntk answers with `truncated`. A layout that does
321
1139
  // not carry the flag — react-x11's Cocoa engine reports none — is asked
322
1140
  // the same of its line ends instead: it wrapped if anything but
323
1141
  // whitespace follows the first line. Read as "fitted", a cut fragment
324
1142
  // advanced past the whole segment, and a paragraph beside a float lost
325
- // every line after its first.
326
- const wrapped = fragment.truncated ?? inkBeyond(segment.runs, first.end);
1143
+ // every line after its first. And a line that ends at a `<br>` is over
1144
+ // whether or not anything followed it in this segment: what comes next
1145
+ // — an atomic, an element's edge — is the next line's.
1146
+ const wrapped =
1147
+ breakBefore(segment.runs, first.end) ||
1148
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
327
1149
  if (!wrapped) {
328
1150
  // It fitted: the cursor stays on this line for whatever comes next.
329
1151
  //
330
- // With one correction first. ntk strips a line's trailing whitespace —
331
- // right at a real line end, wrong here, where the "line" is only a
332
- // fragment and an atomic follows on the same one: `Name <input>` laid
333
- // the space out to width zero and the control sat flush against the
334
- // label (nbsp included; ntk's whitespace set has U+00A0 in it). The
335
- // stripped advance is measured back and added to the cursor.
1152
+ // With one correction first. A text engine strips a line's trailing
1153
+ // whitespace — right at a real line end, wrong here, where the "line"
1154
+ // is only a fragment and an atomic follows on the same one: `Name
1155
+ // <input>` laid the space out to width zero and the control sat flush
1156
+ // against the label. The stripped advance is measured back and added
1157
+ // to the cursor: what the engine strips, and no more (`hungSpaces`).
336
1158
  if (segment.nextIndex < items.length) {
1159
+ const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
337
1160
  let trailing = '';
338
1161
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
339
1162
  const text = segment.runs[i].text;
340
- const m = /[ \t\u00A0]+$/.exec(text);
1163
+ const m = hung.exec(text);
341
1164
  if (!m) break;
342
1165
  trailing = m[0] + trailing;
343
1166
  if (m[0].length < text.length) break;
344
1167
  }
345
1168
  if (trailing) {
346
- open.x +=
1169
+ // and noted: if what follows goes to the next line after all,
1170
+ // these spaces end this one, where CSS removes them (16.6.1)
1171
+ open.hang =
347
1172
  spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
348
1173
  trailing.length;
1174
+ open.x += open.hang;
349
1175
  }
350
1176
  }
351
1177
  index = segment.nextIndex;
352
1178
  offset = 0;
353
1179
  continue;
354
1180
  }
355
- close();
1181
+ close(breakBefore(segment.runs, first.end));
356
1182
  const advanced = advance(items, index, offset, first.end);
357
1183
  index = advanced.index;
358
1184
  offset = advanced.offset;
359
1185
  }
360
1186
 
361
- if (open.texts.length || open.atomics.length) close();
1187
+ if (pending.length) placePending(bandAt(options, y, guess).left);
1188
+ if (open.texts.length || open.atomics.length || open.edges.length) {
1189
+ close(true);
1190
+ }
1191
+ placeDeferred();
362
1192
 
363
- return { lines, height: y, width: widest };
1193
+ if (!floatsPlaced.length) return { lines, height: y, width: widest };
1194
+ const floatRow = besideLines(
1195
+ Number.isFinite(options.width) ? [] : lines,
1196
+ floatsPlaced,
1197
+ options.startY,
1198
+ );
1199
+ return { lines, height: y, width: widest, floatRow };
364
1200
  }
365
1201
 
366
1202
  const EMPTY: InlineResult = { lines: [], height: 0, width: 0 };
367
1203
 
368
- /** Append one layout's lines as LineBoxes at an offset; returns the widest. */
1204
+ /**
1205
+ * Which line each float a paragraph placed stands beside: the one whose top
1206
+ * it went at, whose content moved over for it — added up on the line, for
1207
+ * what the line is measured with (`LineBox.floats`). The widest row of the
1208
+ * rest, side by side where they went at one top, is the answer. Both lines
1209
+ * and floats come in order down the paragraph, since a float goes no
1210
+ * higher than the one before it.
1211
+ *
1212
+ * The lines are handed over only where the paragraph had no width limit,
1213
+ * where its content is measured at its widest and a float is beside a line
1214
+ * because nothing made the line go under it. At a width, the least a
1215
+ * paragraph can be is its widest word or float, not the two side by side
1216
+ * (where the word ran past the room a float left it), and its floats are
1217
+ * rows of their own.
1218
+ */
1219
+ function besideLines(lines: LineBox[], placed: Box[], startY: number): number {
1220
+ let row = 0;
1221
+ let rowTop = NaN;
1222
+ let widest = 0;
1223
+ let at = 0;
1224
+ for (const box of placed) {
1225
+ const top = box.y - box.marginTop - startY;
1226
+ const width = box.width + box.marginLeft + box.marginRight;
1227
+ while (at < lines.length && lines[at].y < top - 0.01) at += 1;
1228
+ const line = lines[at];
1229
+ if (line && Math.abs(line.y - top) <= 0.01) {
1230
+ line.floats = (line.floats ?? 0) + width;
1231
+ } else if (Math.abs(top - rowTop) <= 0.01) row += width;
1232
+ else {
1233
+ widest = Math.max(widest, row);
1234
+ row = width;
1235
+ rowTop = top;
1236
+ }
1237
+ }
1238
+ return Math.max(widest, row);
1239
+ }
1240
+
1241
+ /** A no-break space in a paragraph's face, and its advance, per font
1242
+ * manager and per style — which the cascade shares between every element
1243
+ * of a kind, so a document of paragraphs asks once. */
1244
+ const SPACERS = new WeakMap<
1245
+ object,
1246
+ WeakMap<ComputedStyle, { run: TextRun; advance: number }>
1247
+ >();
1248
+
1249
+ /**
1250
+ * An inline box's edge as a run of the one layout: a no-break space in the
1251
+ * paragraph's face, letter-spaced to the edge's width. A no-break space
1252
+ * glues to the text on either side, so a start edge goes to whichever line
1253
+ * the box's first word goes to, and an end edge stays with its last; a line
1254
+ * may still break after a space before it (UAX #14, LB12a). It is drawn as
1255
+ * nothing, and is no part of the document's text: the line's text notes
1256
+ * where it is (`LineText.gaps`). Laid out piece by piece instead, a
1257
+ * paragraph with an inline `<code>` in it — whose padding is an edge —
1258
+ * cost five layouts where one does, and a document of them reflowed two
1259
+ * and a half times slower.
1260
+ */
1261
+ function spacerRun(
1262
+ fonts: FontsLike,
1263
+ style: ComputedStyle,
1264
+ width: number,
1265
+ ): TextRun {
1266
+ let byStyle = SPACERS.get(fonts);
1267
+ if (!byStyle) {
1268
+ byStyle = new WeakMap();
1269
+ SPACERS.set(fonts, byStyle);
1270
+ }
1271
+ let spacer = byStyle.get(style);
1272
+ if (!spacer) {
1273
+ const face = {
1274
+ family: style.fontFamily,
1275
+ size: style.fontSize,
1276
+ weight: style.fontWeight,
1277
+ style:
1278
+ style.fontStyle === 'normal'
1279
+ ? ('normal' as const)
1280
+ : ('italic' as const),
1281
+ };
1282
+ const run: TextRun = { text: '\u00a0', ...face };
1283
+ spacer = { run, advance: fonts.layout([run], face, {}).width };
1284
+ byStyle.set(style, spacer);
1285
+ }
1286
+ return { ...spacer.run, letterSpacing: width - spacer.advance };
1287
+ }
1288
+
1289
+ /** Text that bidi could reorder around a spacer: any right-to-left
1290
+ * letter, and the explicit embedding and isolate controls. */
1291
+ const REORDERS =
1292
+ /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc\u202a-\u202e\u2066-\u2069]|[\ud802\ud803\ud83a\ud83b]/;
1293
+
1294
+ /** Whether a paragraph's edges can be spacers: it reads left to right, and
1295
+ * nothing in it can be reordered. */
1296
+ function spacersHold(style: ComputedStyle, items: Item[]): boolean {
1297
+ if (style.direction !== 'ltr') return false;
1298
+ for (const item of items) {
1299
+ if (item.kind === 'text' && REORDERS.test(item.run.text)) return false;
1300
+ // A negative margin takes room back. As a spacer it is a space with
1301
+ // negative letter spacing, which ntk's line fill counts and CoreText's
1302
+ // typesetter breaks the line before: the line at a time does the sum.
1303
+ if (
1304
+ item.kind === 'edge' &&
1305
+ (item.box.style.direction !== 'ltr' || item.width < 0)
1306
+ )
1307
+ return false;
1308
+ }
1309
+ return true;
1310
+ }
1311
+
1312
+ /**
1313
+ * A paragraph whose inline boxes have edges, as one layout with the edges
1314
+ * in it as spacers (`spacerRun`), each line told where its spacers are and
1315
+ * where they put the edges. Null where the engine began a line with an
1316
+ * edge that closes a box: a break after a box's last character is after
1317
+ * its end edge, which stays on the line of the content it closes (CSS Text
1318
+ * 3, 5.1), and a line break after a space may come before a no-break space
1319
+ * (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
1320
+ * keep it there.
1321
+ */
1322
+ function layoutSpaced(
1323
+ items: Item[],
1324
+ base: Record<string, unknown>,
1325
+ style: ComputedStyle,
1326
+ width: number,
1327
+ lineHeightMul: number,
1328
+ align: string,
1329
+ fonts: FontsLike,
1330
+ ): InlineResult | null {
1331
+ const wrapWords = overflowWrapOf(style, items);
1332
+ const runs: TextRun[] = [];
1333
+ const spans = new SpanMap();
1334
+ const spacers: { at: number; edge: Extract<Item, { kind: 'edge' }> }[] = [];
1335
+ let doc = 0;
1336
+ for (const item of items) {
1337
+ if (item.kind === 'text') {
1338
+ doc = item.start;
1339
+ break;
1340
+ }
1341
+ }
1342
+ for (const item of items) {
1343
+ if (item.kind === 'edge') {
1344
+ spacers.push({ at: spans.laidOut, edge: item });
1345
+ spans.add(doc, 1, null);
1346
+ runs.push(spacerRun(fonts, style, item.width));
1347
+ } else if (item.kind === 'text') {
1348
+ spans.add(
1349
+ item.start,
1350
+ item.run.text.length,
1351
+ item.control ? null : item.box,
1352
+ );
1353
+ runs.push(item.run);
1354
+ if (!item.control) doc = item.start + item.run.text.length;
1355
+ }
1356
+ }
1357
+ const layoutOptions = {
1358
+ maxWidth: wraps(style) ? width : undefined,
1359
+ lineHeight: lineHeightMul,
1360
+ align,
1361
+ direction: style.direction,
1362
+ overflowWrap: wrapWords,
1363
+ };
1364
+ let layout = fonts.layout(runs, base, layoutOptions);
1365
+ LAYOUT_RUNS.set(layout, runs);
1366
+ // justified with its spacers in it, which are edges and not spaces
1367
+ layout = justifiedLayout(
1368
+ fonts,
1369
+ runs,
1370
+ base,
1371
+ layoutOptions,
1372
+ layout,
1373
+ width,
1374
+ justification(style),
1375
+ new Set(spacers.map((spacer) => spacer.at)),
1376
+ ).layout;
1377
+ const place = wraps(style) ? null : unwrappedPlacer(style, width);
1378
+ let offsets: number[] | null = null;
1379
+ const lines: LineBox[] = [];
1380
+ let widest = 0;
1381
+ let next = 0;
1382
+ for (let i = 0; i < layout.lines.length; i += 1) {
1383
+ const natural = layout.lines[i];
1384
+ const dx = place ? place(natural) : 0;
1385
+ const edges: EdgePlacement[] = [];
1386
+ while (next < spacers.length && spacers[next].at < natural.end) {
1387
+ const { at, edge } = spacers[next];
1388
+ next += 1;
1389
+ if (at < natural.start) continue;
1390
+ if (i > 0 && edge.side === 'end' && at === natural.start) return null;
1391
+ const run = natural.runs?.find((r) => r.start === at);
1392
+ const x = run
1393
+ ? natural.x + run.x
1394
+ : layout.caretPosition(
1395
+ codePointAt((offsets ??= layoutOffsets(layout)), at),
1396
+ ).x;
1397
+ edges.push({
1398
+ box: edge.box,
1399
+ side: edge.side,
1400
+ x: x + dx,
1401
+ width: edge.width,
1402
+ });
1403
+ }
1404
+ const text: LineText = {
1405
+ layout,
1406
+ layoutLine: i,
1407
+ drawX: dx,
1408
+ drawY: 0,
1409
+ textStart: spans.documentAt(natural.start),
1410
+ textEnd: spans.documentAt(natural.end),
1411
+ layoutStart: natural.start,
1412
+ spans,
1413
+ };
1414
+ spans.giveGaps(text, natural.start, natural.end);
1415
+ lines.push({
1416
+ x: natural.x + dx,
1417
+ y: natural.y,
1418
+ width: natural.width,
1419
+ height: natural.height,
1420
+ baseline: natural.baseline - natural.y,
1421
+ texts: [text],
1422
+ textStart: text.textStart,
1423
+ textEnd: text.textEnd,
1424
+ atomics: [],
1425
+ ...(edges.length ? { edges } : null),
1426
+ });
1427
+ widest = Math.max(widest, natural.width);
1428
+ }
1429
+ return { lines, height: layout.height, width: widest };
1430
+ }
1431
+
1432
+ /** The code point a code-unit offset into a layout's text starts. */
1433
+ function codePointAt(offsets: number[], units: number): number {
1434
+ if (!offsets.length) return units;
1435
+ let lo = 0;
1436
+ let hi = offsets.length - 1;
1437
+ while (lo < hi) {
1438
+ const mid = (lo + hi + 1) >> 1;
1439
+ if (offsets[mid] <= units) lo = mid;
1440
+ else hi = mid - 1;
1441
+ }
1442
+ return lo;
1443
+ }
1444
+
1445
+ /**
1446
+ * A code-unit offset in a line's layout as a document index, and back:
1447
+ * `textStart` is `layoutStart`, and every spacer before the offset is a
1448
+ * unit of the layout that is no text of the document's. A document offset
1449
+ * with a spacer right at it is past the spacer, where a caret or a range's
1450
+ * start goes — inside a padded box, after its padding — and before it as
1451
+ * a range's `end`, so a selection does not take in the padding after it.
1452
+ */
1453
+ export function documentOffsetOf(text: LineText, units: number): number {
1454
+ let doc = text.textStart + (units - text.layoutStart);
1455
+ for (const gap of text.gaps ?? []) if (gap < units) doc -= 1;
1456
+ return doc;
1457
+ }
1458
+
1459
+ export function layoutOffsetOf(
1460
+ text: LineText,
1461
+ doc: number,
1462
+ end = false,
1463
+ ): number {
1464
+ let units = text.layoutStart + (doc - text.textStart);
1465
+ for (const gap of text.gaps ?? []) {
1466
+ if (end ? gap < units : gap <= units) units += 1;
1467
+ }
1468
+ return units;
1469
+ }
1470
+
1471
+ /** Append one layout's lines as LineBoxes at an offset, each moved where
1472
+ * `place` says; returns the widest. */
369
1473
  function emitLayout(
370
1474
  layout: TextLayoutLike,
371
1475
  spans: SpanMap,
372
1476
  xOff: number,
373
1477
  yOff: number,
374
1478
  lines: LineBox[],
1479
+ place: LinePlacer | null = null,
375
1480
  ): number {
376
1481
  let widest = 0;
377
1482
  for (let i = 0; i < layout.lines.length; i += 1) {
378
1483
  const natural = layout.lines[i];
1484
+ const dx = place ? place(natural) : 0;
379
1485
  const text: LineText = {
380
1486
  layout,
381
1487
  layoutLine: i,
382
- drawX: xOff,
1488
+ drawX: xOff + dx,
383
1489
  drawY: yOff,
384
1490
  textStart: spans.documentAt(natural.start),
385
1491
  textEnd: spans.documentAt(natural.end),
386
1492
  layoutStart: natural.start,
1493
+ spans,
387
1494
  };
1495
+ spans.giveGaps(text, natural.start, natural.end);
388
1496
  lines.push({
389
- x: xOff + natural.x,
1497
+ x: xOff + dx + natural.x,
390
1498
  y: yOff + natural.y,
391
1499
  width: natural.width,
392
1500
  height: natural.height,
393
- baseline: natural.baseline,
1501
+ baseline: natural.baseline - natural.y,
394
1502
  texts: [text],
395
1503
  textStart: text.textStart,
396
1504
  textEnd: text.textEnd,
@@ -410,6 +1518,20 @@ const CHUNK_TRIGGER_CHARS = 16384;
410
1518
  const CHUNK_MAX_CHARS = 8192;
411
1519
  const CHUNK_MAX_HARD_LINES = 64;
412
1520
 
1521
+ /** Where `layoutChunked` cuts a text — a chunk's most characters and hard
1522
+ * lines — and the cut each chunk's layout makes, if any. */
1523
+ interface Chunking {
1524
+ chars: number;
1525
+ hardLines: number;
1526
+ cut: Cut | null;
1527
+ }
1528
+
1529
+ const CHUNKS: Chunking = {
1530
+ chars: CHUNK_MAX_CHARS,
1531
+ hardLines: CHUNK_MAX_HARD_LINES,
1532
+ cut: null,
1533
+ };
1534
+
413
1535
  /**
414
1536
  * A long hard-broken text — a `<pre>` holding a log, a wall of `<br>`s — laid
415
1537
  * out as a sequence of layouts split at newline boundaries.
@@ -431,7 +1553,9 @@ function layoutChunked(
431
1553
  lineHeightMul: number,
432
1554
  align: string,
433
1555
  fonts: FontsLike,
1556
+ chunking: Chunking = CHUNKS,
434
1557
  ): InlineResult {
1558
+ const wrapWords = overflowWrapOf(style, items);
435
1559
  const lines: LineBox[] = [];
436
1560
  let widest = 0;
437
1561
  let y = 0;
@@ -441,16 +1565,35 @@ function layoutChunked(
441
1565
  let chars = 0;
442
1566
  let hardLines = 0;
443
1567
 
1568
+ const { cut } = chunking;
1569
+ const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
444
1570
  const flush = (): void => {
445
1571
  if (!chunkRuns.length) return;
446
- const layout = fonts.layout(chunkRuns, base, {
447
- maxWidth: wraps(style) ? width : undefined,
1572
+ const chunkOptions = {
1573
+ maxWidth: wraps(style) || cut ? width : undefined,
448
1574
  lineHeight: lineHeightMul,
449
1575
  align,
450
1576
  direction: style.direction,
451
- });
1577
+ overflowWrap: cutWrap(style, cut, wrapWords),
1578
+ ...cut,
1579
+ };
1580
+ let layout = fonts.layout(chunkRuns, base, chunkOptions);
452
1581
  LAYOUT_RUNS.set(layout, chunkRuns);
453
- widest = Math.max(widest, emitLayout(layout, chunkSpans, 0, y, lines));
1582
+ // a chunk ends at a forced break or at the paragraph's end, which is
1583
+ // where `justifiedRuns` takes a chunk's last line to end
1584
+ layout = justifiedLayout(
1585
+ fonts,
1586
+ chunkRuns,
1587
+ base,
1588
+ chunkOptions,
1589
+ layout,
1590
+ width,
1591
+ justification(style),
1592
+ ).layout;
1593
+ widest = Math.max(
1594
+ widest,
1595
+ emitLayout(layout, chunkSpans, 0, y, lines, place),
1596
+ );
454
1597
  y += layout.height;
455
1598
  chunkRuns = [];
456
1599
  chunkSpans = new SpanMap();
@@ -460,7 +1603,7 @@ function layoutChunked(
460
1603
 
461
1604
  for (const item of items) {
462
1605
  let text = item.run.text;
463
- let docStart = item.box.textStart;
1606
+ let docStart = item.start;
464
1607
  while (text.length) {
465
1608
  // Scan up to the budgets, remembering the last newline: the split has
466
1609
  // to land just after one for the chunking to be exact.
@@ -470,8 +1613,8 @@ function layoutChunked(
470
1613
  let taken = chars;
471
1614
  while (
472
1615
  i < text.length &&
473
- taken < CHUNK_MAX_CHARS &&
474
- lines2 < CHUNK_MAX_HARD_LINES
1616
+ taken < chunking.chars &&
1617
+ lines2 < chunking.hardLines
475
1618
  ) {
476
1619
  if (text.charCodeAt(i) === 10) {
477
1620
  lines2 += 1;
@@ -485,7 +1628,7 @@ function layoutChunked(
485
1628
  chunkRuns.push(
486
1629
  text === item.run.text ? item.run : { ...item.run, text },
487
1630
  );
488
- chunkSpans.add(docStart, text.length);
1631
+ chunkSpans.add(docStart, text.length, item.box);
489
1632
  chars = taken;
490
1633
  hardLines = lines2;
491
1634
  break;
@@ -501,7 +1644,7 @@ function layoutChunked(
501
1644
  const next = text.indexOf('\n', i);
502
1645
  const end = next < 0 ? text.length : next + 1;
503
1646
  chunkRuns.push({ ...item.run, text: text.slice(0, end) });
504
- chunkSpans.add(docStart, end);
1647
+ chunkSpans.add(docStart, end, item.box);
505
1648
  flush();
506
1649
  docStart += end;
507
1650
  text = text.slice(end);
@@ -509,7 +1652,7 @@ function layoutChunked(
509
1652
  }
510
1653
  const cut = lastBreak + 1;
511
1654
  chunkRuns.push({ ...item.run, text: text.slice(0, cut) });
512
- chunkSpans.add(docStart, cut);
1655
+ chunkSpans.add(docStart, cut, item.box);
513
1656
  flush();
514
1657
  docStart += cut;
515
1658
  text = text.slice(cut);
@@ -527,100 +1670,868 @@ interface OpenLine {
527
1670
  left: number;
528
1671
  texts: LineText[];
529
1672
  atomics: AtomicPlacement[];
1673
+ edges: EdgePlacement[];
1674
+ /** All of the above in the order they were placed, which is the logical
1675
+ * order, and where on the line each began: what a line with
1676
+ * right-to-left text on it is put into visual order by. */
1677
+ order: Placed[];
1678
+ /** The advance of the spaces the line ends with, kept in `x` for what
1679
+ * may follow them on it and no part of the line if nothing does. */
1680
+ hang: number;
1681
+ /** The first line's `text-indent`, which `x` starts at. */
1682
+ indent: number;
530
1683
  }
531
1684
 
1685
+ type Placed =
1686
+ | { kind: 'text'; at: number; item: LineText; reads: Reading }
1687
+ | { kind: 'atomic'; at: number; item: AtomicPlacement }
1688
+ | { kind: 'edge'; at: number; item: EdgePlacement };
1689
+
1690
+ /** Which way a fragment's letters read: all one way, both, or neither —
1691
+ * spaces, digits and punctuation, which are neutrals. */
1692
+ type Reading = 'ltr' | 'rtl' | 'mixed' | null;
1693
+
532
1694
  function openLine(indent: number): OpenLine {
533
- return { x: indent, left: 0, texts: [], atomics: [] };
1695
+ return {
1696
+ x: indent,
1697
+ left: 0,
1698
+ texts: [],
1699
+ atomics: [],
1700
+ edges: [],
1701
+ order: [],
1702
+ hang: 0,
1703
+ indent,
1704
+ };
534
1705
  }
535
1706
 
536
- function finishLine(open: OpenLine, y: number): LineBox | null {
537
- if (!open.texts.length && !open.atomics.length) return null;
538
- let ascent = 0;
539
- let descent = 0;
540
- let height = 0;
541
- for (const text of open.texts) {
542
- const natural = text.layout.lines[text.layoutLine];
543
- ascent = Math.max(ascent, natural.baseline);
544
- descent = Math.max(descent, natural.height - natural.baseline);
545
- height = Math.max(height, natural.height);
546
- }
547
- for (const placed of open.atomics) {
548
- const h =
549
- placed.box.height + placed.box.marginTop + placed.box.marginBottom;
550
- const va = placed.box.style.verticalAlign;
551
- if (va === 'top' || va === 'bottom' || va === 'middle')
552
- height = Math.max(height, h);
553
- else ascent = Math.max(ascent, h);
554
- }
555
- height = Math.max(height, ascent + descent);
556
- const baseline = Math.max(ascent, (height - ascent - descent) / 2 + ascent);
1707
+ /** Whether a line may break where it has got to: after the white space it
1708
+ * ends on, which hangs, or after an inline-block, where its block wraps. */
1709
+ function breaksAtEnd(open: OpenLine, style: ComputedStyle): boolean {
1710
+ return (
1711
+ wraps(style) &&
1712
+ (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic')
1713
+ );
1714
+ }
557
1715
 
558
- let textStart = Infinity;
559
- let textEnd = 0;
560
- for (const text of open.texts) {
561
- textStart = Math.min(textStart, text.textStart);
562
- textEnd = Math.max(textEnd, text.textEnd);
563
- }
564
- const line: LineBox = {
565
- x: open.left,
566
- y,
567
- width: open.x,
568
- height,
569
- baseline,
570
- texts: open.texts,
571
- textStart: Number.isFinite(textStart) ? textStart : 0,
1716
+ /**
1717
+ * How wide the content from `from` on is up to where its line may break:
1718
+ * its text up to a space it may break at — one `holdNoWrap` left a space —
1719
+ * or, in a block that does not wrap, up to a line's end, with the edges of
1720
+ * inline boxes on the way, and an inline-block, which has a break before it
1721
+ * where the block wraps. Floats take no room on the line.
1722
+ */
1723
+ function unbreakableAfter(
1724
+ items: readonly Item[],
1725
+ from: number,
1726
+ style: ComputedStyle,
1727
+ fonts: FontsLike,
1728
+ base: Record<string, unknown>,
1729
+ ): number {
1730
+ const wrapping = wraps(style);
1731
+ const runs: TextRun[] = [];
1732
+ let width = 0;
1733
+ for (let i = from; i < items.length; i += 1) {
1734
+ const item = items[i];
1735
+ if (item.kind === 'float') continue;
1736
+ if (item.kind === 'edge') {
1737
+ width += item.width;
1738
+ continue;
1739
+ }
1740
+ if (item.kind === 'atomic') {
1741
+ if (wrapping) break;
1742
+ width += item.box.width + item.box.marginLeft + item.box.marginRight;
1743
+ continue;
1744
+ }
1745
+ const text = item.run.text;
1746
+ const stop = wrapping ? text.search(/[ \t\n]/) : text.indexOf('\n');
1747
+ if (stop < 0) {
1748
+ runs.push(item.run);
1749
+ continue;
1750
+ }
1751
+ if (stop > 0) runs.push({ ...item.run, text: text.slice(0, stop) });
1752
+ break;
1753
+ }
1754
+ return runs.length ? width + fonts.layout(runs, base, {}).width : width;
1755
+ }
1756
+
1757
+ /**
1758
+ * Close a line: its height and baseline from what is on it, then where it
1759
+ * sits — in the room `bandFor` answers for a line that tall, and aligned in
1760
+ * it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
1761
+ * centred), moved onto every text, atomic and edge on it at once.
1762
+ */
1763
+ function finishLine(
1764
+ open: OpenLine,
1765
+ y: number,
1766
+ bandFor: (height: number) => { left: number; right: number },
1767
+ shift: number,
1768
+ rtl: boolean,
1769
+ strut: InlineDecoration | null,
1770
+ lifts: Lifts | null = null,
1771
+ justify: LineJustify | null = null,
1772
+ ): LineBox | null {
1773
+ if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1774
+ return null;
1775
+ }
1776
+ const reordered = open.order.some((p) => needsOrdering(p, rtl));
1777
+ if (reordered) reorderLine(open, rtl);
1778
+ // Every line box starts with the block's strut, its face at its line
1779
+ // height, so a line of images alone is still as tall as `line-height`
1780
+ // makes a line (CSS 2.1 10.8.1). Not one with nothing on it that takes
1781
+ // room, which has no height at all (9.4.2), nor one that holds a block
1782
+ // an inline box was split around, which is no line of text.
1783
+ const held =
1784
+ strut &&
1785
+ (open.texts.length > 0 ||
1786
+ open.atomics.some((placed) =>
1787
+ placed.box.style.display.startsWith('inline'),
1788
+ ) ||
1789
+ open.edges.some(
1790
+ (edge) => edge.width !== 0 || hasEdgeOn(edge.box, edge.side),
1791
+ ));
1792
+ let ascent = held ? strut.ascent : 0;
1793
+ let descent = held ? strut.descent : 0;
1794
+ // A text `vertical-align` raises takes its own box's room about where it
1795
+ // is raised to. A `top` or `bottom` box takes the room of all it holds
1796
+ // on the line about its baseline, and the line is as tall as that.
1797
+ const lifted = lifts ? open.texts.map((text) => lifts.of(text)) : null;
1798
+ let edges: Map<
1799
+ Box,
1800
+ { to: 'top' | 'bottom'; ascent: number; descent: number }
1801
+ > | null = null;
1802
+ for (let i = 0; i < open.texts.length; i += 1) {
1803
+ const lift = lifted?.[i];
1804
+ if (lift) {
1805
+ if (lift.edge) {
1806
+ edges ??= new Map();
1807
+ let room = edges.get(lift.edge.box);
1808
+ if (!room) {
1809
+ room = {
1810
+ to: lift.edge.to,
1811
+ ascent: lift.edge.ascent,
1812
+ descent: lift.edge.descent,
1813
+ };
1814
+ edges.set(lift.edge.box, room);
1815
+ }
1816
+ room.ascent = Math.max(room.ascent, lift.ascent + lift.raise);
1817
+ room.descent = Math.max(room.descent, lift.descent - lift.raise);
1818
+ } else {
1819
+ ascent = Math.max(ascent, lift.ascent + lift.raise);
1820
+ descent = Math.max(descent, lift.descent - lift.raise);
1821
+ }
1822
+ continue;
1823
+ }
1824
+ const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1825
+ const own = natural.baseline - natural.y;
1826
+ ascent = Math.max(ascent, own);
1827
+ descent = Math.max(descent, natural.height - own);
1828
+ }
1829
+ // an inline box with no text on the line is on it all the same, as tall
1830
+ // as its own face and line height make it (CSS 2.1 10.8): an empty one,
1831
+ // or one whose text is on another line — raised, or with a line height
1832
+ // of its own, which an empty `<span>` made the line's no taller for.
1833
+ // Only on a line that is one at all, which is none for boxes of no
1834
+ // edges and no text (9.4.2)
1835
+ if (lifts && held) {
1836
+ for (const placed of open.edges) {
1837
+ if (placed.side !== 'start' || placed.box.kind !== 'inline') continue;
1838
+ const room = lifts.ofBox(placed.box);
1839
+ if (!room) continue;
1840
+ ascent = Math.max(ascent, room.ascent + room.raise);
1841
+ descent = Math.max(descent, room.descent - room.raise);
1842
+ }
1843
+ }
1844
+ // `top` and `bottom` boxes are set against the line's edges rather than
1845
+ // its baseline, so all they ask of it is to be as tall as they are
1846
+ let top = 0;
1847
+ let bottom = 0;
1848
+ for (const placed of open.atomics) {
1849
+ const box = placed.box;
1850
+ const h = box.height + box.marginTop + box.marginBottom;
1851
+ const va = box.style.verticalAlign;
1852
+ if (va === 'top' || va === 'bottom') {
1853
+ if (va === 'top') top = Math.max(top, h);
1854
+ else bottom = Math.max(bottom, h);
1855
+ continue;
1856
+ }
1857
+ // on the line's baseline by its own, raised from it by its
1858
+ // `vertical-align`, and below it by the rest of it
1859
+ const raise = placed.raise ?? 0;
1860
+ const b = atomicBaseline(box);
1861
+ ascent = Math.max(ascent, b + raise);
1862
+ descent = Math.max(descent, h - b - raise);
1863
+ }
1864
+ if (edges) {
1865
+ for (const room of edges.values()) {
1866
+ const h = room.ascent + room.descent;
1867
+ if (room.to === 'top') top = Math.max(top, h);
1868
+ else bottom = Math.max(bottom, h);
1869
+ }
1870
+ }
1871
+ // Where the baseline goes in a line one of them made taller than the
1872
+ // rest is left open (CSS 2.1 10.8.1). Browsers keep it where the rest
1873
+ // puts it, under the line's top, and only a taller `bottom` box moves it
1874
+ // down: an image set `top` beside a line of text has the text at its
1875
+ // top, not halfway down it.
1876
+ let height = ascent + descent;
1877
+ let baseline = ascent;
1878
+ if (bottom > height) {
1879
+ baseline += bottom - height;
1880
+ height = bottom;
1881
+ }
1882
+ height = Math.max(height, top);
1883
+
1884
+ // Placed beside the floats at the text's height; a float the whole line
1885
+ // reaches moves it, and the alignment divides the room that is left.
1886
+ // The indent is at the line's start, which is its right in a
1887
+ // right-to-left paragraph: the content moves back over it there, and
1888
+ // the room it takes is at the other end (CSS 2.1 16.1).
1889
+ const band = bandFor(height);
1890
+ const indent = rtl ? open.indent : 0;
1891
+ let used = open.x - open.hang - indent;
1892
+ let free = band.right - indent - band.left - used;
1893
+ // A justified line fills its room (CSS Text 3, 7.4). Not yet one that
1894
+ // reads right to left, whose pieces are in visual order by now and would
1895
+ // take their shares from the wrong end: that is left at its start, as it
1896
+ // was.
1897
+ if (justify && !rtl && !reordered && Number.isFinite(free) && free > 0) {
1898
+ const grew = justifyLine(open, free, justify);
1899
+ used += grew;
1900
+ free -= grew;
1901
+ }
1902
+ let dx = band.left - open.left - indent;
1903
+ if (shift > 0 && Number.isFinite(free) && free > 0) dx += free * shift;
1904
+ if (dx) {
1905
+ for (const text of open.texts) text.drawX += dx;
1906
+ for (const placed of open.atomics) placed.x += dx;
1907
+ for (const edge of open.edges) edge.x += dx;
1908
+ open.left += dx;
1909
+ }
1910
+
1911
+ let textStart = Infinity;
1912
+ let textEnd = 0;
1913
+ for (const text of open.texts) {
1914
+ textStart = Math.min(textStart, text.textStart);
1915
+ textEnd = Math.max(textEnd, text.textEnd);
1916
+ }
1917
+ const line: LineBox = {
1918
+ x: open.left + indent,
1919
+ y,
1920
+ width: used,
1921
+ height,
1922
+ baseline,
1923
+ texts: open.texts,
1924
+ textStart: Number.isFinite(textStart) ? textStart : 0,
572
1925
  textEnd,
573
1926
  atomics: open.atomics,
1927
+ ...(open.edges.length ? { edges: open.edges } : null),
574
1928
  };
575
- for (const placed of open.atomics)
576
- placed.y = y + alignAtomic(placed.box, line);
1929
+ for (const placed of open.atomics) placed.y = y + alignAtomic(placed, line);
577
1930
  // Every fragment on this line shares the line's baseline, whatever its own
578
- // layout thinks: that is what makes a small `<sup>` beside body text sit on
579
- // the same baseline rather than on its own.
580
- for (const text of open.texts) {
1931
+ // layout thinks: that is what makes a small `<span>` beside body text sit
1932
+ // on the same baseline rather than on its own. One that `vertical-align`
1933
+ // raises is drawn that far above it.
1934
+ for (let i = 0; i < open.texts.length; i += 1) {
1935
+ const text = open.texts[i];
581
1936
  const natural = text.layout.lines[text.layoutLine];
582
- text.drawY = y + baseline - natural.baseline - natural.y;
1937
+ let at = y + baseline;
1938
+ const lift = lifted?.[i];
1939
+ if (lift) {
1940
+ const room = lift.edge && edges!.get(lift.edge.box)!;
1941
+ if (lift.edge?.to === 'top') at = y + room!.ascent - lift.raise;
1942
+ else if (lift.edge) at = y + height - room!.descent - lift.raise;
1943
+ else at -= lift.raise;
1944
+ if (at !== y + baseline) {
1945
+ TEXT_RAISES.set(text, y + baseline - at);
1946
+ SHIFTED_LINES.add(line);
1947
+ }
1948
+ // and the boxes it is in, up to the `top` or `bottom` one, whose
1949
+ // backgrounds are drawn from their own baselines
1950
+ if (lift.edge && lifts) {
1951
+ const owner = text.spans.boxAt?.(text.layoutStart);
1952
+ for (
1953
+ let at2 = owner?.parent;
1954
+ at2?.kind === 'inline';
1955
+ at2 = at2.parent
1956
+ ) {
1957
+ const placed = lifts.edgeOf(at2);
1958
+ if (!placed) break;
1959
+ const room = edges!.get(placed.edge);
1960
+ if (!room) break;
1961
+ const own =
1962
+ placed.edge.style.verticalAlign === 'top'
1963
+ ? y + room.ascent
1964
+ : y + height - room.descent;
1965
+ let raises = LINE_BOX_RAISES.get(line);
1966
+ if (!raises) LINE_BOX_RAISES.set(line, (raises = new Map()));
1967
+ raises.set(at2, y + baseline - (own - placed.raise));
1968
+ SHIFTED_LINES.add(line);
1969
+ }
1970
+ }
1971
+ }
1972
+ text.drawY = at - natural.baseline;
583
1973
  }
584
1974
  return line;
585
1975
  }
586
1976
 
1977
+ /**
1978
+ * Put a line's pieces in visual order. The text and the atomics first, by
1979
+ * UAX #9's L2 over pieces rather than characters: a fragment is in order
1980
+ * inside itself already — the engine laid it out bidirectionally — so what
1981
+ * is left to order is the fragments and the atomics between them, each at
1982
+ * a level — a fragment the direction its letters read in, or the
1983
+ * paragraph's where they read both ways, and a neutral (an atomic, a
1984
+ * fragment of spaces and digits) its neighbours' where they agree and the
1985
+ * paragraph's where they do not (N1, N2).
1986
+ *
1987
+ * Then each element's edges, innermost first, around what of it is on the
1988
+ * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
1989
+ * on its leftmost box and a rtl one's right ones on its rightmost, which is
1990
+ * a matter of its `direction` rather than of which way its text reads — so
1991
+ * an edge is placed beside its element's pieces, never ordered among them.
1992
+ * Each piece keeps the room it took, and a right-to-left fragment's hung
1993
+ * trailing space goes to its left, where its logical end is.
1994
+ */
1995
+ function reorderLine(open: OpenLine, rtl: boolean): void {
1996
+ const order = open.order;
1997
+ const n = order.length;
1998
+ if (!n) return;
1999
+ const content: number[] = [];
2000
+ for (let i = 0; i < n; i += 1) if (order[i].kind !== 'edge') content.push(i);
2001
+ const strong = content.map((i): boolean | null => {
2002
+ const p = order[i];
2003
+ if (p.kind !== 'text' || p.reads === null) return null;
2004
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2005
+ });
2006
+ const strongAt = (from: number, step: number): boolean => {
2007
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2008
+ if (strong[k] !== null) return strong[k]!;
2009
+ }
2010
+ return rtl;
2011
+ };
2012
+ const isRtl = strong.map((r, k) => {
2013
+ if (r !== null) return r;
2014
+ const before = strongAt(k, -1);
2015
+ return before === strongAt(k, 1) ? before : rtl;
2016
+ });
2017
+ // R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
2018
+ // in a right-to-left one
2019
+ const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
2020
+ const visual = content.map((_, k) => k);
2021
+ for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
2022
+ for (let a = 0; a < visual.length;) {
2023
+ if (levels[visual[a]] < level) {
2024
+ a += 1;
2025
+ continue;
2026
+ }
2027
+ let b = a;
2028
+ while (b < visual.length && levels[visual[b]] >= level) b += 1;
2029
+ for (let i = a, j = b - 1; i < j; i += 1, j -= 1) {
2030
+ [visual[i], visual[j]] = [visual[j], visual[i]];
2031
+ }
2032
+ a = b;
2033
+ }
2034
+ }
2035
+ const seq = visual.map((k) => content[k]);
2036
+
2037
+ const elements = new Map<Box, { start: number | null; end: number | null }>();
2038
+ for (let i = 0; i < n; i += 1) {
2039
+ const p = order[i];
2040
+ if (p.kind !== 'edge') continue;
2041
+ const sides = elements.get(p.item.box) ?? { start: null, end: null };
2042
+ sides[p.item.side] = i;
2043
+ elements.set(p.item.box, sides);
2044
+ }
2045
+ const inside = [...elements].sort(([a], [b]) => depthOf(b) - depthOf(a));
2046
+ for (const [box, { start, end }] of inside) {
2047
+ // what of the element is on this line: everything between its edges,
2048
+ // or the line's end where one of them is on another line
2049
+ const from = start ?? -1;
2050
+ const to = end ?? n;
2051
+ let lo = -1;
2052
+ let hi = -1;
2053
+ for (let k = 0; k < seq.length; k += 1) {
2054
+ if (seq[k] > from && seq[k] < to) {
2055
+ if (lo < 0) lo = k;
2056
+ hi = k;
2057
+ }
2058
+ }
2059
+ if (lo < 0) {
2060
+ // Nothing of it here but its edges: they go where it is in the text,
2061
+ // after what comes before it, in the paragraph's direction.
2062
+ let at = rtl ? seq.length : 0;
2063
+ for (let i = from - 1; i >= 0; i -= 1) {
2064
+ const k = seq.indexOf(i);
2065
+ if (k >= 0) {
2066
+ at = rtl ? k : k + 1;
2067
+ break;
2068
+ }
2069
+ }
2070
+ lo = at;
2071
+ hi = at - 1;
2072
+ }
2073
+ const ltr = box.style.direction !== 'rtl';
2074
+ const left = ltr ? start : end;
2075
+ const right = ltr ? end : start;
2076
+ if (right !== null) seq.splice(hi + 1, 0, right);
2077
+ if (left !== null) seq.splice(lo, 0, left);
2078
+ }
2079
+
2080
+ let x = order[0].at;
2081
+ for (const i of seq) {
2082
+ const piece = order[i];
2083
+ const room = (i + 1 < n ? order[i + 1].at : open.x) - piece.at;
2084
+ let dx = x - piece.at;
2085
+ if (piece.kind === 'text') {
2086
+ const natural = piece.item.layout.lines[piece.item.layoutLine];
2087
+ const k = content.indexOf(i);
2088
+ if (isRtl[k] && natural) dx += Math.max(0, room - natural.width);
2089
+ piece.item.drawX += dx;
2090
+ } else {
2091
+ piece.item.x += dx;
2092
+ }
2093
+ x += room;
2094
+ }
2095
+ }
2096
+
2097
+ /** Whether a piece can be out of place in logical order: anything on a
2098
+ * right-to-left line, a fragment that reads right to left, or the edge of
2099
+ * an element whose direction is not the line's. A line of left-to-right
2100
+ * text, the common one, is placed as it was built. */
2101
+ function needsOrdering(piece: Placed, rtl: boolean): boolean {
2102
+ if (rtl) return true;
2103
+ if (piece.kind === 'text') return piece.reads === 'rtl';
2104
+ return piece.kind === 'edge' && piece.item.box.style.direction === 'rtl';
2105
+ }
2106
+
2107
+ /** How deep a box is in the tree — an element inside another is deeper. */
2108
+ export function depthOf(box: Box): number {
2109
+ let depth = 0;
2110
+ for (let at = box.parent; at; at = at.parent) depth += 1;
2111
+ return depth;
2112
+ }
2113
+
2114
+ /**
2115
+ * Which way the text from `start` to `end` of a segment's runs reads, from
2116
+ * its letters. Asked of the text rather than of the engine's runs: a run of
2117
+ * the space before a word reads the paragraph's way wherever it starts a
2118
+ * fragment, which says nothing about the word, and an engine may not say
2119
+ * which way its runs went at all.
2120
+ */
2121
+ function readingOf(runs: TextRun[], start: number, end: number): Reading {
2122
+ let right = false;
2123
+ let left = false;
2124
+ let at = 0;
2125
+ for (const run of runs) {
2126
+ const length = run.text.length;
2127
+ const from = Math.max(start, at);
2128
+ const to = Math.min(end, at + length);
2129
+ if (to > from) {
2130
+ const text =
2131
+ to - from === length ? run.text : run.text.slice(from - at, to - at);
2132
+ right ||= RIGHT_TO_LEFT.test(text);
2133
+ left ||= LEFT_TO_RIGHT.test(text);
2134
+ }
2135
+ at += length;
2136
+ if (at >= end) break;
2137
+ }
2138
+ return right ? (left ? 'mixed' : 'rtl') : left ? 'ltr' : null;
2139
+ }
2140
+
2141
+ /** A letter of a script written right to left — Hebrew, Arabic, Syriac,
2142
+ * Thaana, N'Ko and their neighbours, their presentation forms, and the
2143
+ * supplementary planes' right-to-left blocks — and any other letter. */
2144
+ const RIGHT_TO_LEFT =
2145
+ /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
2146
+ const LEFT_TO_RIGHT =
2147
+ /(?![\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}])\p{L}/u;
2148
+
587
2149
  /** Where an atomic's top edge sits, relative to the line box top. */
588
- function alignAtomic(box: Box, line: LineBox): number {
589
- const h = box.height + box.marginTop + box.marginBottom;
2150
+ function alignAtomic(placed: AtomicPlacement, line: LineBox): number {
2151
+ const box = placed.box;
590
2152
  switch (box.style.verticalAlign) {
591
2153
  case 'top':
592
2154
  return 0;
593
2155
  case 'bottom':
594
- return line.height - h;
595
- case 'middle':
596
- return (line.height - h) / 2;
2156
+ return line.height - (box.height + box.marginTop + box.marginBottom);
2157
+ default:
2158
+ return line.baseline - atomicBaseline(box) - (placed.raise ?? 0);
2159
+ }
2160
+ }
2161
+
2162
+ /**
2163
+ * How far an atomic's `vertical-align` raises its baseline above its
2164
+ * parent's (CSS 2.1 10.8.1), as `Lifts` raises an inline box's: `sub` and
2165
+ * `super` by a fifth and a third of the parent's font size and a pixel,
2166
+ * `text-top` and `text-bottom` to the edges of the parent's font, `middle`
2167
+ * by its middle to half the parent's x-height above the baseline, a length
2168
+ * by itself and a percentage of its own line height. `top` and `bottom` are
2169
+ * the line box's edges, and raise nothing from a baseline.
2170
+ *
2171
+ * `middle` is the parent's baseline, not the middle of the line: an image
2172
+ * set `middle` beside text in a line an image before it made tall sat
2173
+ * where that image left room, and drew the text beside it a pixel off.
2174
+ * Read before the line moves the atomic, as `atomicBaseline` is.
2175
+ */
2176
+ function atomicRaise(
2177
+ fonts: FontsLike,
2178
+ box: Box,
2179
+ parent: ComputedStyle,
2180
+ ): number {
2181
+ const va = box.style.verticalAlign;
2182
+ switch (va) {
2183
+ case 'baseline':
2184
+ case 'top':
2185
+ case 'bottom':
2186
+ return 0;
597
2187
  case 'sub':
598
- return line.baseline - h + line.height * 0.1;
2188
+ return -(parent.fontSize / 5 + parent.fontSize / 16);
599
2189
  case 'super':
600
- return line.baseline - h - line.height * 0.25;
2190
+ return parent.fontSize / 3 + parent.fontSize / 16;
2191
+ }
2192
+ const h = box.height + box.marginTop + box.marginBottom;
2193
+ const b = atomicBaseline(box);
2194
+ switch (va) {
2195
+ case 'text-top':
2196
+ return faceExtent(fonts, parent).ascent - b;
2197
+ case 'text-bottom':
2198
+ return h - b - faceExtent(fonts, parent).descent;
2199
+ case 'middle':
2200
+ return xHeightOf(fonts, parent) / 2 + h / 2 - b;
601
2201
  default:
602
- if (typeof box.style.verticalAlign === 'number') {
603
- return line.baseline - h - box.style.verticalAlign;
2202
+ return typeof va === 'number'
2203
+ ? va
2204
+ : resolve(va, lineHeightOf(fonts, box.style));
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * Where an atomic's baseline is, from the top of its margin box (CSS 2.1
2210
+ * 10.8.1, 17.5.3): an inline-block's is its last line box's, an
2211
+ * inline-table's its first row's — its first line box's — and the bottom of
2212
+ * its margin box where it has none, is replaced, or clips what overflows
2213
+ * it. Set bottom-on-baseline, an inline-block's text sat its descent above
2214
+ * the text beside it, and a line holding one grew by as much.
2215
+ *
2216
+ * Read while the atomic is still where it was laid out, before the line
2217
+ * moves it: its lines are then at the same offset from its top as they will
2218
+ * be after.
2219
+ */
2220
+ function atomicBaseline(box: Box): number {
2221
+ const bottom = box.height + box.marginTop + box.marginBottom;
2222
+ if (
2223
+ box.kind === 'replaced' ||
2224
+ scrolls(box.style) ||
2225
+ // layout containment keeps its baseline in (CSS Containment 2, 3.3)
2226
+ box.style.contain & CONTAIN_LAYOUT
2227
+ ) {
2228
+ return bottom;
2229
+ }
2230
+ const baseline =
2231
+ box.kind === 'table'
2232
+ ? tableBaseline(box)
2233
+ : // an inline flex box sits on its first (CSS Flexbox 8.5), where an
2234
+ // inline block sits on its last line's
2235
+ box.kind === 'flex'
2236
+ ? firstBaselineIn(box)
2237
+ : lastBaselineIn(box);
2238
+ return baseline === null ? bottom : box.marginTop + (baseline - box.y);
2239
+ }
2240
+
2241
+ /**
2242
+ * A flex container's first baseline, or its last (CSS Flexbox 8.5): that
2243
+ * of the first item on its first line that is aligned by its baseline — the
2244
+ * last on its last line — or where none is, of its first item, or last,
2245
+ * in `order`. An item gives its own, or where it has none, its border
2246
+ * box's bottom edge. Not its last child's: an `inline-flex` of a small
2247
+ * item and a big one sat on the big one's baseline. Null for a box with no
2248
+ * item.
2249
+ */
2250
+ function flexBaseline(box: Box, first: boolean): number | null {
2251
+ let items: Box[] = [];
2252
+ let reordered = false;
2253
+ for (const child of box.children) {
2254
+ if (child.outOfFlow || child.isFloat) continue;
2255
+ if (child.kind === 'text' || child.kind === 'break') continue;
2256
+ if (child.style.order !== 0) reordered = true;
2257
+ items.push(child);
2258
+ }
2259
+ if (!items.length) return null;
2260
+ if (reordered) items.sort((a, b) => a.style.order - b.style.order);
2261
+ if (!first) items = items.reverse();
2262
+ const style = box.style;
2263
+ if (style.flexDirection.startsWith('row') && !style.grid) {
2264
+ // a line ends where the next item does not go on along the main axis,
2265
+ // which from the last item is where it goes on
2266
+ const back =
2267
+ ((style.flexDirection === 'row-reverse') !==
2268
+ (style.direction === 'rtl')) !==
2269
+ !first;
2270
+ let last = NaN;
2271
+ for (const item of items) {
2272
+ if (!Number.isNaN(last) && (back ? item.x >= last : item.x <= last)) {
2273
+ break;
2274
+ }
2275
+ last = item.x;
2276
+ const align =
2277
+ item.style.alignSelf === 'auto'
2278
+ ? style.alignItems
2279
+ : item.style.alignSelf;
2280
+ if (align === 'baseline') return itemBaselineOf(item, first, style);
2281
+ }
2282
+ }
2283
+ return itemBaselineOf(items[0], first, style);
2284
+ }
2285
+
2286
+ /** A flex item's baseline, in document coordinates: its first line's or
2287
+ * its last's — its bottom margin edge where it clips what it holds — or
2288
+ * where it has none, its border box's bottom edge, which a grid does not
2289
+ * take from its item, and has none of its own then (CSS Grid 1, 9). */
2290
+ function itemBaselineOf(
2291
+ item: Box,
2292
+ first: boolean,
2293
+ container: ComputedStyle,
2294
+ ): number | null {
2295
+ const found = childBaseline(item, first ? firstBaselineIn : lastBaselineIn);
2296
+ if (found !== null) return found;
2297
+ return container.grid ? null : item.y + item.height;
2298
+ }
2299
+
2300
+ /**
2301
+ * A table's baseline: its first row's (CSS 2.1 17.5.3), which is the first
2302
+ * line of a cell in the row, or where no cell in it has a line, the bottom
2303
+ * content edge of the row's lowest cell. Not the table's bottom: a table
2304
+ * of empty rows stands on its first one, and the rest hang below the line.
2305
+ */
2306
+ function tableBaseline(table: Box): number | null {
2307
+ const { cells } = tableGrid(table);
2308
+ let lowest = -Infinity;
2309
+ for (const cell of cells) {
2310
+ if (cell.row > 0) break;
2311
+ const found = firstBaselineIn(cell.box);
2312
+ if (found !== null) return found;
2313
+ const b = cell.box;
2314
+ lowest = Math.max(lowest, b.y + b.height - b.padBottom - b.borderBottom);
2315
+ }
2316
+ return lowest === -Infinity ? null : lowest;
2317
+ }
2318
+
2319
+ /**
2320
+ * The baseline a box's content gives it, in document coordinates: its last
2321
+ * line box's, or its last in-flow child's with one — and a child that clips
2322
+ * what overflows it gives its bottom margin edge and no deeper, whatever
2323
+ * text it holds (CSS Box Alignment's synthesized baseline). Null where
2324
+ * nothing in it has one.
2325
+ */
2326
+ function lastBaselineIn(box: Box): number | null {
2327
+ // a flex box's is its items', in the order it lays them out
2328
+ if (box.kind === 'flex') return flexBaseline(box, false);
2329
+ if (box.lines?.length) {
2330
+ const line = box.lines[box.lines.length - 1];
2331
+ return line.y + line.baseline;
2332
+ }
2333
+ for (let i = box.children.length - 1; i >= 0; i -= 1) {
2334
+ const found = childBaseline(box.children[i], lastBaselineIn);
2335
+ if (found !== null) return found;
2336
+ }
2337
+ return null;
2338
+ }
2339
+
2340
+ /** The same, from the first line box or child: a table's baseline is its
2341
+ * first row's, and so is a table cell's. */
2342
+ export function firstBaselineIn(box: Box): number | null {
2343
+ if (box.kind === 'flex') return flexBaseline(box, true);
2344
+ if (box.lines?.length) return box.lines[0].y + box.lines[0].baseline;
2345
+ for (const child of box.children) {
2346
+ const found = childBaseline(child, firstBaselineIn);
2347
+ if (found !== null) return found;
2348
+ }
2349
+ return null;
2350
+ }
2351
+
2352
+ function childBaseline(
2353
+ child: Box,
2354
+ inside: (box: Box) => number | null,
2355
+ ): number | null {
2356
+ if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
2357
+ return null;
2358
+ }
2359
+ if (scrolls(child.style)) {
2360
+ return child.y + child.height + child.marginBottom;
2361
+ }
2362
+ return inside(child);
2363
+ }
2364
+
2365
+ /** An absolutely positioned box among a paragraph's content, before the
2366
+ * item at `index`. */
2367
+ interface StaticMark {
2368
+ index: number;
2369
+ box: Box;
2370
+ }
2371
+
2372
+ /**
2373
+ * The static position of each absolutely positioned box among a
2374
+ * paragraph's content (CSS 2.1 10.3.7, 10.6.4): where it would have been
2375
+ * in flow, found from the content before it. A block-level one would have
2376
+ * broken the line that content is on, so it goes under that line at the
2377
+ * line's start; an inline-level one goes on it, where the pen stood after
2378
+ * that content, or under it where a forced break ends the text; either goes
2379
+ * at the top where nothing comes before it. Taken as the block's content
2380
+ * top for every one of them, a box after a line of text was drawn over that
2381
+ * line, and a menu under its link came up on it. Not from a probe of
2382
+ * intrinsic width, whose room is no place for one.
2383
+ */
2384
+ function staticPositions(
2385
+ statics: StaticMark[],
2386
+ items: Item[],
2387
+ lines: LineBox[],
2388
+ block: Box,
2389
+ options: InlineOptions,
2390
+ ): void {
2391
+ if (!Number.isFinite(options.width)) return;
2392
+ for (const { index, box } of statics) {
2393
+ const inline = INLINE_BEFORE_ABSOLUTE.has(box.style);
2394
+ let top = lines.length ? lines[0].y : 0;
2395
+ let x = 0;
2396
+ const after = lines.length ? penAfter(items, index, lines) : null;
2397
+ if (after) {
2398
+ const { line, pen, broken } = after;
2399
+ if (inline && !broken) {
2400
+ top = line.y;
2401
+ x = pen;
2402
+ } else {
2403
+ top = line.y + line.height;
2404
+ }
2405
+ }
2406
+ box.staticPosition = {
2407
+ from: block,
2408
+ x: options.originX + x - block.x,
2409
+ right: options.originX + options.width - block.x,
2410
+ y: options.startY + top - block.y,
2411
+ };
2412
+ }
2413
+ }
2414
+
2415
+ /** The line the content before `items[index]` ended on, where the pen
2416
+ * stood after it, and whether it ended in a forced break. Null where no
2417
+ * text or atomic comes before. */
2418
+ function penAfter(
2419
+ items: Item[],
2420
+ index: number,
2421
+ lines: LineBox[],
2422
+ ): { line: LineBox; pen: number; broken: boolean } | null {
2423
+ for (let i = index - 1; i >= 0; i -= 1) {
2424
+ const item = items[i];
2425
+ if (item.kind === 'edge') {
2426
+ // an inline box's margin, border or padding is content, which keeps
2427
+ // its line from being one of no height (CSS 2.1 9.4.2), even where
2428
+ // they add up to no width
2429
+ if (!hasEdgeOn(item.box, item.side)) continue;
2430
+ for (const line of lines) {
2431
+ const placed = line.edges?.find(
2432
+ (e) => e.box === item.box && e.side === item.side,
2433
+ );
2434
+ if (placed) {
2435
+ return { line, pen: placed.x + placed.width, broken: false };
2436
+ }
2437
+ }
2438
+ continue;
2439
+ }
2440
+ if (item.kind === 'atomic') {
2441
+ for (const line of lines) {
2442
+ const placed = line.atomics.find((a) => a.box === item.box);
2443
+ if (!placed) continue;
2444
+ const pen = placed.x + item.box.width + item.box.marginRight;
2445
+ return { line, pen, broken: false };
604
2446
  }
605
- return line.baseline - h;
2447
+ return null;
2448
+ }
2449
+ if (item.kind !== 'text' || item.control) continue;
2450
+ const end = item.start + item.length;
2451
+ const line =
2452
+ lines.find((l) => l.textStart < end && end <= l.textEnd) ??
2453
+ lines[lines.length - 1];
2454
+ const broken = item.run.text.endsWith('\n');
2455
+ return { line, pen: broken ? 0 : caretX(line, end), broken };
2456
+ }
2457
+ return null;
2458
+ }
2459
+
2460
+ /** Whether an inline box has margin, border or padding on a side. */
2461
+ function hasEdgeOn(box: Box, side: 'start' | 'end'): boolean {
2462
+ const left = (side === 'start') !== (box.style.direction === 'rtl');
2463
+ return left
2464
+ ? box.marginLeft !== 0 || box.borderLeft !== 0 || box.padLeft !== 0
2465
+ : box.marginRight !== 0 || box.borderRight !== 0 || box.padRight !== 0;
2466
+ }
2467
+
2468
+ /** Where the caret at document offset `at` stands on a line, in the
2469
+ * paragraph's coordinates: the line's end where no text of it holds the
2470
+ * offset. */
2471
+ function caretX(line: LineBox, at: number): number {
2472
+ for (const text of line.texts) {
2473
+ if (at < text.textStart || at > text.textEnd) continue;
2474
+ const offsets = layoutOffsets(text.layout);
2475
+ const units = layoutOffsetOf(text, at, true);
2476
+ const caret = text.layout.caretPosition(codePointAtOffset(offsets, units));
2477
+ return text.drawX + caret.x;
606
2478
  }
2479
+ return line.x + line.width;
607
2480
  }
608
2481
 
609
2482
  // --- gathering --------------------------------------------------------------
610
2483
 
611
- /** Flatten an inline subtree into a stream of runs, atomics and breaks. */
612
- function collect(box: Box, out: Item[]): void {
2484
+ /** Flatten an inline subtree into a stream of runs, atomics, breaks and the
2485
+ * edges of the inline boxes the text sits in. */
2486
+ /** Gather an inline formatting context's content into `out`, and answer
2487
+ * how many floats it gathered — none unless `floats` asks for them. */
2488
+ function collect(
2489
+ box: Box,
2490
+ out: Item[],
2491
+ width: number,
2492
+ fonts: FontsLike | null,
2493
+ block: ComputedStyle,
2494
+ floats: boolean,
2495
+ /** Where each absolutely positioned box among the content is: before the
2496
+ * item at `index`. No item of the stream, which reads none of them. */
2497
+ statics?: StaticMark[],
2498
+ /** Whether the text is an inline box's that has a margin, border or
2499
+ * padding at a side, which parts it from the text beside it. */
2500
+ apart = false,
2501
+ ): number {
2502
+ let floated = 0;
613
2503
  for (const child of box.children) {
614
- if (child.outOfFlow || child.isFloat) continue;
2504
+ if (child.outOfFlow) {
2505
+ statics?.push({ index: out.length, box: child });
2506
+ continue;
2507
+ }
2508
+ if (child.isFloat) {
2509
+ if (floats) {
2510
+ out.push({ kind: 'float', box: child });
2511
+ floated += 1;
2512
+ }
2513
+ continue;
2514
+ }
615
2515
  switch (child.kind) {
616
2516
  case 'text':
617
2517
  if (child.text) {
618
- out.push({
619
- kind: 'text',
620
- run: runFor(child.text, child.style, child.el),
621
- box: child,
622
- length: child.text.length,
623
- });
2518
+ const run = runFor(heldText(child), child.style);
2519
+ // shaping is broken across the edge (CSS Text 3, 7.3), and the
2520
+ // engine shapes a word that runs across spans shaped alike as one:
2521
+ // its text is shaped on its own. At both of its sides, the one
2522
+ // with no edge among them, as the engine takes it
2523
+ if (apart) run.shapeApart = true;
2524
+ if (child.style.wordSpacing) {
2525
+ wordSpaced(out, child, run, child.style.wordSpacing);
2526
+ } else {
2527
+ out.push({
2528
+ kind: 'text',
2529
+ run,
2530
+ box: child,
2531
+ length: child.text.length,
2532
+ start: child.textStart,
2533
+ });
2534
+ }
624
2535
  }
625
2536
  break;
626
2537
  case 'break':
@@ -641,11 +2552,63 @@ function collect(box: Box, out: Item[]): void {
641
2552
  },
642
2553
  box: child,
643
2554
  length: 1,
2555
+ start: child.textStart,
644
2556
  });
645
2557
  break;
646
- case 'inline':
647
- collect(child, out);
2558
+ case 'inline': {
2559
+ const [start, end] = inlineEdges(child, width, fonts);
2560
+ // Both edges or neither, so that an element with any is bounded on
2561
+ // the line: its pieces are what lies between them, which is how a
2562
+ // right-to-left line finds them (`reorderLine`), and how a rounded
2563
+ // background knows the fragments that open and close it, to round
2564
+ // only those corners — so it gets them even at width zero.
2565
+ // And a box that `position: relative` moves or `vertical-align`
2566
+ // raises has them, so that its text is laid out apart from the text
2567
+ // around it, to be drawn where the box goes (`offsetInline`,
2568
+ // `Lifts`)
2569
+ // and a margin a border cancels is still an edge, which keeps its
2570
+ // line from being one of no height (CSS 2.1 9.4.2)
2571
+ const edged =
2572
+ start !== 0 ||
2573
+ end !== 0 ||
2574
+ hasEdgeOn(child, 'start') ||
2575
+ hasEdgeOn(child, 'end') ||
2576
+ (child.decoration !== null &&
2577
+ child.style.borderRadius.some((r) => r !== 0)) ||
2578
+ isOffset(child.style) ||
2579
+ child.style.verticalAlign !== 'baseline' ||
2580
+ (fonts !== null && ownsLeading(fonts, block, child.style));
2581
+ if (edged) {
2582
+ out.push({ kind: 'edge', box: child, side: 'start', width: start });
2583
+ }
2584
+ // `unicode-bidi` as the controls it stands for, inside the box's
2585
+ // edges and around its text, where it has any
2586
+ const controls =
2587
+ child.style.unicodeBidi !== 'normal' &&
2588
+ child.subtreeTextEnd > child.subtreeTextStart
2589
+ ? bidiControls(child.style, false)
2590
+ : null;
2591
+ if (controls) {
2592
+ pushControls(out, controls[0], child, child.subtreeTextStart);
2593
+ }
2594
+ floated += collect(
2595
+ child,
2596
+ out,
2597
+ width,
2598
+ fonts,
2599
+ block,
2600
+ floats,
2601
+ statics,
2602
+ start !== 0 || end !== 0,
2603
+ );
2604
+ if (controls) {
2605
+ pushControls(out, controls[1], child, child.subtreeTextEnd);
2606
+ }
2607
+ if (edged) {
2608
+ out.push({ kind: 'edge', box: child, side: 'end', width: end });
2609
+ }
648
2610
  break;
2611
+ }
649
2612
  default:
650
2613
  // Everything else that reached an inline context is inline-level and
651
2614
  // atomic: a replaced element, an `inline-block`, an `inline-table`.
@@ -653,54 +2616,801 @@ function collect(box: Box, out: Item[]): void {
653
2616
  break;
654
2617
  }
655
2618
  }
2619
+ return floated;
2620
+ }
2621
+
2622
+ /**
2623
+ * What a paragraph does with a word too long for its line: cuts it where
2624
+ * its style or any text in it says a word may be cut — `overflow-wrap`, or
2625
+ * `word-break` or `line-break: anywhere`, which cut one whatever
2626
+ * `overflow-wrap` says (CSS Text 3, 5.2 and 5.3) — and lets it run past the
2627
+ * line's end where nothing does, as a browser does. The engine takes one answer for the paragraph, so a span
2628
+ * that breaks its words breaks the paragraph's.
2629
+ */
2630
+ function overflowWrapOf(
2631
+ style: ComputedStyle,
2632
+ items: readonly Item[],
2633
+ ): 'normal' | 'break-word' {
2634
+ if (breaksWords(style)) return 'break-word';
2635
+ for (const item of items) {
2636
+ if (item.kind === 'text' && breaksWords(item.box.style)) {
2637
+ return 'break-word';
2638
+ }
2639
+ }
2640
+ return 'normal';
2641
+ }
2642
+
2643
+ /** A `text-overflow` cut on a line that does not wrap puts its ellipsis at
2644
+ * the box's edge, inside a word where one runs past it, as a browser
2645
+ * does — which is the engine cutting the word there. */
2646
+ function cutWrap(
2647
+ style: ComputedStyle,
2648
+ cut: Cut | null | undefined,
2649
+ wrapWords: 'normal' | 'break-word',
2650
+ ): 'normal' | 'break-word' {
2651
+ return cut && !wraps(style) ? 'break-word' : wrapWords;
2652
+ }
2653
+
2654
+ function breaksWords(style: ComputedStyle): boolean {
2655
+ return (
2656
+ style.overflowWrap !== 'normal' ||
2657
+ style.wordBreak === 'break-all' ||
2658
+ style.wordBreak === 'break-word' ||
2659
+ style.lineBreakAnywhere
2660
+ );
2661
+ }
2662
+
2663
+ /**
2664
+ * Keep the text of a `nowrap` element together where its block wraps (CSS
2665
+ * 2.1 16.6). Its runs share the element as their `nowrap`, which ntk makes
2666
+ * no break inside or between; and, for an engine that does not read it,
2667
+ * a space it may not break after is a no-break space, as wide and as many,
2668
+ * so every offset holds. Whether a line breaks between two characters is
2669
+ * the call of the nearest element holding both (CSS Text 3, 5.1), so the
2670
+ * space such an element ends on stays one to break at where the text after
2671
+ * it is not the same element's: text that wraps, or another `nowrap`
2672
+ * element's — two tags side by side, a collapsed space between them.
2673
+ */
2674
+ function holdNoWrap(items: Item[], block: Box): void {
2675
+ let last = true;
2676
+ let nextGroup: Box | null = null;
2677
+ for (let i = items.length - 1; i >= 0; i -= 1) {
2678
+ const item = items[i];
2679
+ if (item.kind !== 'text') continue;
2680
+ const group = wraps(item.box.style) ? null : noWrapGroup(item.box, block);
2681
+ if (group) {
2682
+ const text = item.run.text;
2683
+ let held = text;
2684
+ // `pre`'s are no-break spaces already (`runFor`)
2685
+ if (item.box.style.whiteSpace === 'nowrap' && text.includes(' ')) {
2686
+ held = text.replace(/ /g, '\u00a0');
2687
+ if (text.endsWith(' ') && (last || nextGroup !== group)) {
2688
+ held = held.slice(0, -1) + ' ';
2689
+ }
2690
+ }
2691
+ item.run = { ...item.run, text: held, nowrap: group };
2692
+ }
2693
+ last = false;
2694
+ nextGroup = group;
2695
+ }
2696
+ }
2697
+
2698
+ /** The element a text that does not wrap is held together by: the outermost
2699
+ * one inside the block that does not wrap either. */
2700
+ function noWrapGroup(box: Box, block: Box): Box {
2701
+ let group = box;
2702
+ for (let up = box.parent; up && up !== block; up = up.parent) {
2703
+ if (wraps(up.style)) break;
2704
+ group = up;
2705
+ }
2706
+ return group;
2707
+ }
2708
+
2709
+ /** The bidi controls `unicode-bidi` stands for, opening and closing (CSS
2710
+ * Writing Modes 3, 2.4.2), in the direction of the box; null for `normal`.
2711
+ * On a block only an override opens anything: the block's embedding is
2712
+ * its paragraph's direction. */
2713
+ function bidiControls(
2714
+ style: ComputedStyle,
2715
+ block: boolean,
2716
+ ): [string, string] | null {
2717
+ const rtl = style.direction === 'rtl';
2718
+ const override = rtl ? '\u202E' : '\u202D';
2719
+ switch (style.unicodeBidi) {
2720
+ case 'embed':
2721
+ return [rtl ? '\u202B' : '\u202A', '\u202C'];
2722
+ case 'bidi-override':
2723
+ return [override, '\u202C'];
2724
+ case 'isolate':
2725
+ return [rtl ? '\u2067' : '\u2066', '\u2069'];
2726
+ case 'isolate-override':
2727
+ return block
2728
+ ? [override, '\u202C']
2729
+ : [(rtl ? '\u2067' : '\u2066') + override, '\u202C\u2069'];
2730
+ case 'plaintext':
2731
+ return ['\u2068', '\u2069'];
2732
+ default:
2733
+ return null;
2734
+ }
2735
+ }
2736
+
2737
+ /** Bidi controls as items of their own, one a unit, at a document index
2738
+ * none of them takes up. */
2739
+ function pushControls(
2740
+ out: Item[],
2741
+ controls: string,
2742
+ box: Box,
2743
+ start: number,
2744
+ ): void {
2745
+ for (const control of controls) {
2746
+ out.push({
2747
+ kind: 'text',
2748
+ run: runFor(control, box.style),
2749
+ box,
2750
+ length: 1,
2751
+ start,
2752
+ control: true,
2753
+ });
2754
+ }
2755
+ }
2756
+
2757
+ function hasControls(items: Item[]): boolean {
2758
+ for (const item of items)
2759
+ if (item.kind === 'text' && item.control) return true;
2760
+ return false;
2761
+ }
2762
+
2763
+ /**
2764
+ * A text box with `word-spacing`, as runs of its words and of the spaces
2765
+ * between them, each space run carrying the extra as letter spacing: the
2766
+ * engines space letters, not words, and a space's letter spacing is exactly
2767
+ * the room CSS adds after a word separator (16.4 — the space and the
2768
+ * no-break space). Only text that asks for it is split.
2769
+ */
2770
+ function wordSpaced(
2771
+ out: Item[],
2772
+ box: Box,
2773
+ run: TextRun,
2774
+ spacing: number,
2775
+ ): void {
2776
+ const text = box.text;
2777
+ const push = (from: number, to: number, extra: number): void => {
2778
+ if (to <= from) return;
2779
+ out.push({
2780
+ kind: 'text',
2781
+ run: {
2782
+ ...run,
2783
+ text: text.slice(from, to),
2784
+ ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
2785
+ },
2786
+ box,
2787
+ length: to - from,
2788
+ start: box.textStart + from,
2789
+ });
2790
+ };
2791
+ let from = 0;
2792
+ for (const m of text.matchAll(/[ \u00A0]+/g)) {
2793
+ push(from, m.index, 0);
2794
+ push(m.index, m.index + m[0].length, spacing);
2795
+ from = m.index + m[0].length;
2796
+ }
2797
+ push(from, text.length, 0);
2798
+ }
2799
+
2800
+ /**
2801
+ * An inline box's margin, border and padding, resolved onto it — percentages
2802
+ * of the containing block's width, as a block's are — and the widths of its
2803
+ * two edges on the line: the start side's before its first fragment, the
2804
+ * end side's after its last. One with a background or a border to paint
2805
+ * also notes its face's ascent and descent, the height CSS paints them over
2806
+ * (10.6.1, the content area), so paint needs no font.
2807
+ */
2808
+ function inlineEdges(
2809
+ box: Box,
2810
+ width: number,
2811
+ fonts: FontsLike | null,
2812
+ ): [number, number] {
2813
+ const s = box.style;
2814
+ box.borderTop = usedBorder(s.borderTopWidth, s.borderTopStyle);
2815
+ box.borderRight = usedBorder(s.borderRightWidth, s.borderRightStyle);
2816
+ box.borderBottom = usedBorder(s.borderBottomWidth, s.borderBottomStyle);
2817
+ box.borderLeft = usedBorder(s.borderLeftWidth, s.borderLeftStyle);
2818
+ box.padTop = resolve(s.paddingTop, width);
2819
+ box.padRight = resolve(s.paddingRight, width);
2820
+ box.padBottom = resolve(s.paddingBottom, width);
2821
+ box.padLeft = resolve(s.paddingLeft, width);
2822
+ box.marginLeft = resolve(s.marginLeft, width);
2823
+ box.marginRight = resolve(s.marginRight, width);
2824
+ if (box.cut) {
2825
+ // a piece of a box broken around a block has no edge where it was cut
2826
+ const rtl = s.direction === 'rtl';
2827
+ if (box.cut & (rtl ? 2 : 1)) {
2828
+ box.marginLeft = box.borderLeft = box.padLeft = 0;
2829
+ }
2830
+ if (box.cut & (rtl ? 1 : 2)) {
2831
+ box.marginRight = box.borderRight = box.padRight = 0;
2832
+ }
2833
+ }
2834
+ box.decoration = inlineDecoration(fonts, box, s);
2835
+ const left = box.marginLeft + box.borderLeft + box.padLeft;
2836
+ const right = box.padRight + box.borderRight + box.marginRight;
2837
+ return s.direction === 'rtl' ? [right, left] : [left, right];
2838
+ }
2839
+
2840
+ /**
2841
+ * What an inline box draws around its text, in `s`: the extent of its face
2842
+ * where it has a background, a border or an outline, and null where it
2843
+ * draws none. Its borders are the ones `inlineEdges` just used. Shared with
2844
+ * a pointer move that gives a link a background (`HtmlViewNode`).
2845
+ */
2846
+ export function inlineDecoration(
2847
+ fonts: FontsLike | null,
2848
+ box: Box,
2849
+ s: ComputedStyle,
2850
+ ): InlineDecoration | null {
2851
+ const decorated =
2852
+ !isTransparent(s.backgroundColor) ||
2853
+ box.borderTop + box.borderRight + box.borderBottom + box.borderLeft > 0 ||
2854
+ s.outlineStyle !== 'none';
2855
+ return !decorated ? null : fonts ? faceExtent(fonts, s) : NO_EXTENT;
656
2856
  }
657
2857
 
658
- /** The `TextRun` one styled piece of text becomes. */
659
- function runFor(
660
- text: string,
2858
+ /** A border's width, or none where its style draws none. */
2859
+ function usedBorder(
2860
+ width: number,
2861
+ style: ComputedStyle['borderTopStyle'],
2862
+ ): number {
2863
+ return style === 'none' || style === 'hidden' ? 0 : width;
2864
+ }
2865
+
2866
+ /** A face's ascent and descent at a style's size, kept per font manager
2867
+ * and shared by the boxes set in it: by the style, which the cascade
2868
+ * shares between every element of a kind, and by the face, which styles
2869
+ * that differ in anything else share. */
2870
+ const FACE_EXTENTS = new WeakMap<
2871
+ FontsLike,
2872
+ {
2873
+ byStyle: WeakMap<ComputedStyle, InlineDecoration>;
2874
+ byFace: Map<string, InlineDecoration>;
2875
+ }
2876
+ >();
2877
+
2878
+ /** The extent of a decorated box where there are no fonts to ask. */
2879
+ const NO_EXTENT: InlineDecoration = { ascent: 0, descent: 0 };
2880
+
2881
+ /** A style's face's ascent and descent, which an inline box's background
2882
+ * is painted over. */
2883
+ export function faceExtentOf(
2884
+ fonts: FontsLike,
2885
+ style: ComputedStyle,
2886
+ ): InlineDecoration {
2887
+ return faceExtent(fonts, style);
2888
+ }
2889
+
2890
+ function faceExtent(fonts: FontsLike, style: ComputedStyle): InlineDecoration {
2891
+ let cache = FACE_EXTENTS.get(fonts);
2892
+ if (!cache) {
2893
+ cache = { byStyle: new WeakMap(), byFace: new Map() };
2894
+ FACE_EXTENTS.set(fonts, cache);
2895
+ }
2896
+ let extent = cache.byStyle.get(style);
2897
+ if (extent) return extent;
2898
+ const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight}|${style.fontStyle}`;
2899
+ extent = cache.byFace.get(key);
2900
+ if (!extent) {
2901
+ try {
2902
+ const m = fonts
2903
+ .match(style.fontFamily, {
2904
+ size: style.fontSize,
2905
+ weight: style.fontWeight,
2906
+ style: style.fontStyle,
2907
+ })
2908
+ .metrics(style.fontSize);
2909
+ extent = { ascent: m.ascent, descent: m.descent };
2910
+ } catch {
2911
+ extent = { ascent: style.fontSize * 0.8, descent: style.fontSize * 0.2 };
2912
+ }
2913
+ if (cache.byFace.size > 64) cache.byFace.clear();
2914
+ cache.byFace.set(key, extent);
2915
+ }
2916
+ cache.byStyle.set(style, extent);
2917
+ return extent;
2918
+ }
2919
+
2920
+ /** A first line in its `::first-line` fonts (`firstLineSetting`). */
2921
+ interface FirstLineSetting {
2922
+ /** The content, its text in the styles its boxes have on the line. */
2923
+ items: Item[];
2924
+ base: Record<string, unknown>;
2925
+ lineHeight: number;
2926
+ strut: InlineDecoration | null;
2927
+ lifts: Lifts;
2928
+ justify: LineJustify | null;
2929
+ }
2930
+
2931
+ /**
2932
+ * What a first line is made with where its `::first-line` sets its fonts
2933
+ * (CSS 2.1 5.12.1). Its text is in the styles its boxes have on it — each
2934
+ * element's own rules over what it inherits there, which takes the fonts
2935
+ * from `::first-line` (CSS Pseudo 4, 2.1.2), so a `<small>` is smaller
2936
+ * than the line's font and not than the block's — and it is laid out at
2937
+ * the pseudo-element's line height. The pseudo-element is an inline box
2938
+ * around the line's content, so its face at its line height is on the line
2939
+ * with the block's strut, which stays: a `::first-line` of a smaller line
2940
+ * height leaves the line as tall as the block's.
2941
+ */
2942
+ function firstLineSetting(
2943
+ block: Box,
2944
+ items: Item[],
2945
+ how: NonNullable<InlineOptions['firstLineStyle']>,
2946
+ fonts: FontsLike,
2947
+ strut: InlineDecoration | null,
2948
+ justify: LineJustify | null,
2949
+ ): FirstLineSetting {
2950
+ const style = how.style;
2951
+ const of = (box: Box): ComputedStyle =>
2952
+ box === block || !box.parent
2953
+ ? style
2954
+ : styleOnFirstLine(box, of(box.parent), how.styler);
2955
+ const restyled = items.map((item): Item => {
2956
+ if (item.kind !== 'text') return item;
2957
+ const run = restyledRun(item.run, item.box.style, of(item.box));
2958
+ return run === item.run ? item : { ...item, run };
2959
+ });
2960
+ const lineHeight = lineHeightMultiplier(fonts, style);
2961
+ const base = {
2962
+ family: style.fontFamily,
2963
+ size: style.fontSize,
2964
+ weight: style.fontWeight,
2965
+ style: style.fontStyle,
2966
+ color: style.color,
2967
+ };
2968
+ const own = strutOf(fonts, style);
2969
+ return {
2970
+ items: restyled,
2971
+ base,
2972
+ lineHeight,
2973
+ strut: strut && {
2974
+ ascent: Math.max(strut.ascent, own.ascent),
2975
+ descent: Math.max(strut.descent, own.descent),
2976
+ },
2977
+ lifts: new Lifts(fonts, style, of),
2978
+ justify: justify && {
2979
+ ...justify,
2980
+ base,
2981
+ options: { ...justify.options, lineHeight },
2982
+ },
2983
+ };
2984
+ }
2985
+
2986
+ /**
2987
+ * Each box's style on a first line (`FirstLineStyler`), kept for the style
2988
+ * its parent has there: every pass asks again, and a style that is a new
2989
+ * object each time is a new face to every cache that keys on one.
2990
+ */
2991
+ const ON_FIRST_LINE = new WeakMap<
2992
+ Box,
2993
+ { parent: ComputedStyle; style: ComputedStyle }
2994
+ >();
2995
+
2996
+ /** A box's style on a first line, where its parent's there is `parent`. */
2997
+ function styleOnFirstLine(
2998
+ box: Box,
2999
+ parent: ComputedStyle,
3000
+ styler: FirstLineStyler,
3001
+ ): ComputedStyle {
3002
+ const known = ON_FIRST_LINE.get(box);
3003
+ if (known && known.parent === parent) return known.style;
3004
+ const style = styler(box, parent);
3005
+ ON_FIRST_LINE.set(box, { parent, style });
3006
+ return style;
3007
+ }
3008
+
3009
+ /** What a run takes from its style (`runFor`). */
3010
+ const RUN_STYLE = [
3011
+ 'family',
3012
+ 'size',
3013
+ 'weight',
3014
+ 'style',
3015
+ 'color',
3016
+ 'letterSpacing',
3017
+ 'features',
3018
+ 'underline',
3019
+ 'underlineStyle',
3020
+ 'underlineOffset',
3021
+ 'underlineThickness',
3022
+ 'strike',
3023
+ ] as const;
3024
+
3025
+ /**
3026
+ * A run of text in `style` rather than `own`, the style it was made in:
3027
+ * what it has of neither — the room a tab or `word-spacing` adds to its
3028
+ * letter spacing, a `nowrap` group, where its shaping is broken — kept.
3029
+ */
3030
+ function restyledRun(
3031
+ run: TextRun,
3032
+ own: ComputedStyle,
661
3033
  style: ComputedStyle,
662
- owner: Element | null,
663
3034
  ): TextRun {
664
- // ntk hands unknown span fields back on the laid-out runs untouched, which
665
- // is how the element reaches the paint and hit-test passes without a
666
- // parallel structure to keep in step.
667
- const run: TextRun & { element?: Element } = {
3035
+ if (style === own) return run;
3036
+ const fresh = runFor(run.text, style) as unknown as Record<string, unknown>;
3037
+ const out = { ...run } as unknown as Record<string, unknown>;
3038
+ for (const name of RUN_STYLE) {
3039
+ if (fresh[name] === undefined) delete out[name];
3040
+ else out[name] = fresh[name];
3041
+ }
3042
+ const added = (run.letterSpacing ?? 0) - (own.letterSpacing || 0);
3043
+ if (added) out.letterSpacing = ((out.letterSpacing as number) ?? 0) + added;
3044
+ return out as unknown as TextRun;
3045
+ }
3046
+
3047
+ /**
3048
+ * The first line's text in its `::first-line` colour: the runs before the
3049
+ * line's end whose colour is the block's own, the one that ends past it cut
3050
+ * there. A run in a colour of its own — a link's — keeps it, as an element
3051
+ * inside the pseudo-element does. Colour moves no glyph, so the lines break
3052
+ * where they did.
3053
+ */
3054
+ function firstLineColour(
3055
+ items: Item[],
3056
+ firstLine: { color: string; from: string; end: number },
3057
+ ): void {
3058
+ for (let i = 0; i < items.length; i += 1) {
3059
+ const item = items[i];
3060
+ if (item.kind !== 'text') continue;
3061
+ // past the line: a run in a colour of its own may have been the one
3062
+ // across its end, so this is where the walk stops, not at the cut
3063
+ if (item.start >= firstLine.end) break;
3064
+ if (item.run.color !== firstLine.from) continue;
3065
+ const cut = firstLine.end - item.start;
3066
+ if (cut < item.length) {
3067
+ items.splice(i + 1, 0, {
3068
+ ...item,
3069
+ run: { ...item.run, text: item.run.text.slice(cut) },
3070
+ start: item.start + cut,
3071
+ length: item.length - cut,
3072
+ });
3073
+ items[i] = {
3074
+ ...item,
3075
+ run: {
3076
+ ...item.run,
3077
+ text: item.run.text.slice(0, cut),
3078
+ color: firstLine.color,
3079
+ },
3080
+ length: cut,
3081
+ };
3082
+ break;
3083
+ }
3084
+ items[i] = { ...item, run: { ...item.run, color: firstLine.color } };
3085
+ }
3086
+ }
3087
+
3088
+ /** The `TextRun` one styled piece of text becomes: its text, and what paint
3089
+ * needs of its style — nothing that names a box or an element, so that a
3090
+ * layout made from it can be kept for the next parse (`TextLayoutCache`). */
3091
+ /**
3092
+ * A text box's text as it is laid out. `pre` keeps its spaces whole: none
3093
+ * is broken after, and none hangs at a line's end, where a trailing space
3094
+ * counts in the line's width. Laid out, they are no-break spaces, as wide
3095
+ * and as many, so every offset holds (CSS Text 3, 4.1.3). Kept per box: a
3096
+ * pass makes no new string, and the kept layouts find their text by
3097
+ * identity before they compare it.
3098
+ */
3099
+ /**
3100
+ * Tabs a `white-space` keeps, set at their stops (CSS Text 3, 4.2): a stop
3101
+ * every `tab-size` spaces of the block's font from the line's start, and a
3102
+ * tab that would reach one less than half a `ch` on goes to the next.
3103
+ * Neither engine has them — ntk draws a tab a space wide, and CoreText
3104
+ * sets it at stops of its own every 28 points — so a tab is laid out as a
3105
+ * space spaced out to its stop: text as the engine sees it and not as the
3106
+ * document holds it, the way `heldText` holds a `pre`'s spaces. Where each
3107
+ * tab starts is read off one layout of the paragraph with its tabs as
3108
+ * spaces, and its inline boxes' edges and its atomics as the room they
3109
+ * take, unwrapped: a line `pre-wrap` wraps sets the tabs after the wrap as
3110
+ * though it had not, and a right-to-left one sets them a space wide.
3111
+ *
3112
+ * Read off each tab's run, which the tab is given of its own, and not off
3113
+ * a caret: CoreText puts a caret after a spaced glyph part of the way
3114
+ * into its spacing, so a tab after `word-spacing` was set from a place
3115
+ * half the spacing short of where it is drawn.
3116
+ */
3117
+ function setTabs(
3118
+ items: Item[],
3119
+ fonts: FontsLike,
3120
+ block: ComputedStyle,
3121
+ indent: number,
3122
+ ): void {
3123
+ // the paragraph as it will be laid out, an inline box's edges and an
3124
+ // atomic as the room they take, each tab a run of its own, and where
3125
+ // each text item starts in it
3126
+ const runs: TextRun[] = [];
3127
+ const starts = new Map<Item, number>();
3128
+ const tabs = new Set<number>();
3129
+ let units = 0;
3130
+ for (const item of items) {
3131
+ // a float is beside the line, and takes none of it
3132
+ if (item.kind === 'float') continue;
3133
+ if (item.kind !== 'text') {
3134
+ const box = item.box;
3135
+ runs.push(
3136
+ spacerRun(
3137
+ fonts,
3138
+ block,
3139
+ item.kind === 'edge'
3140
+ ? item.width
3141
+ : box.width + box.marginLeft + box.marginRight,
3142
+ ),
3143
+ );
3144
+ units += 1;
3145
+ continue;
3146
+ }
3147
+ starts.set(item, units);
3148
+ const text = item.run.text;
3149
+ if (item.control || !text.includes('\t')) {
3150
+ runs.push(item.run);
3151
+ units += text.length;
3152
+ continue;
3153
+ }
3154
+ let done = 0;
3155
+ for (let p = text.indexOf('\t'); p >= 0; p = text.indexOf('\t', p + 1)) {
3156
+ if (p > done) runs.push({ ...item.run, text: text.slice(done, p) });
3157
+ // spaced a hair apart from its neighbours, which an engine that
3158
+ // merges runs alike (CoreText) would otherwise take it into
3159
+ tabs.add(units + p);
3160
+ runs.push({
3161
+ ...item.run,
3162
+ text: spaceIn(item.box),
3163
+ letterSpacing: (item.run.letterSpacing ?? 0) + tabs.size * HAIR,
3164
+ });
3165
+ done = p + 1;
3166
+ }
3167
+ if (done < text.length) runs.push({ ...item.run, text: text.slice(done) });
3168
+ units += text.length;
3169
+ }
3170
+ const natural = fonts.layout(runs, fontOf(block), {
3171
+ direction: block.direction,
3172
+ });
3173
+ /** Where each tab starts, by its place in the layout's text. */
3174
+ const found = new Map<number, { x: number; line: number }>();
3175
+ natural.lines.forEach((line, i) => {
3176
+ for (const run of line.runs ?? []) {
3177
+ if (tabs.has(run.start))
3178
+ found.set(run.start, { x: line.x + run.x, line: i });
3179
+ }
3180
+ });
3181
+ let offsets: number[] | null | undefined;
3182
+ const space =
3183
+ advanceOf(fonts, fontOf(block), ' ') +
3184
+ block.letterSpacing +
3185
+ block.wordSpacing;
3186
+ const every = block.tabSizeIsLength ? block.tabSize : block.tabSize * space;
3187
+ const half = advanceOf(fonts, fontOf(block), '0') / 2;
3188
+ const rtl = block.direction === 'rtl';
3189
+ /** The room tabs have added to each line so far. */
3190
+ const added = new Map<number, number>();
3191
+ const next: Item[] = [];
3192
+ for (const item of items) {
3193
+ if (item.kind !== 'text' || item.control || !item.run.text.includes('\t')) {
3194
+ next.push(item);
3195
+ continue;
3196
+ }
3197
+ const text = item.run.text;
3198
+ const at = starts.get(item)!;
3199
+ // what the space a tab is laid out as takes before it is spaced out
3200
+ const own =
3201
+ advanceOf(fonts, item.run, spaceIn(item.box)) +
3202
+ (item.run.letterSpacing ?? 0);
3203
+ const piece = (start: number, end: number, run: TextRun): void => {
3204
+ next.push({
3205
+ ...item,
3206
+ run,
3207
+ length: end - start,
3208
+ start: item.start + start,
3209
+ });
3210
+ };
3211
+ let done = 0;
3212
+ for (let p = text.indexOf('\t'); p >= 0; p = text.indexOf('\t', p + 1)) {
3213
+ if (p > done) piece(done, p, { ...item.run, text: text.slice(done, p) });
3214
+ let advance = own;
3215
+ if (!rtl) {
3216
+ const unit = at + p;
3217
+ let caret = found.get(unit);
3218
+ if (!caret) {
3219
+ // an engine that says nothing of its runs has a caret to go by
3220
+ if (offsets === undefined) {
3221
+ const joined = runs.map((run) => run.text).join('');
3222
+ offsets = /[\uD800-\uDFFF]/.test(joined)
3223
+ ? codeUnitOffsets(joined)
3224
+ : null;
3225
+ }
3226
+ caret = natural.caretPosition(
3227
+ offsets ? codePointAt(offsets, unit) : unit,
3228
+ );
3229
+ }
3230
+ const before = added.get(caret.line) ?? 0;
3231
+ const x = caret.x + before + (caret.line === 0 ? indent : 0);
3232
+ if (every > 0) {
3233
+ let stop = (Math.floor(x / every) + 1) * every;
3234
+ if (stop - x < half) stop += every;
3235
+ advance = stop - x;
3236
+ } else advance = 0;
3237
+ added.set(caret.line, before + advance - own);
3238
+ }
3239
+ piece(p, p + 1, {
3240
+ ...item.run,
3241
+ text: spaceIn(item.box),
3242
+ letterSpacing: (item.run.letterSpacing ?? 0) + advance - own,
3243
+ });
3244
+ done = p + 1;
3245
+ }
3246
+ if (done < text.length) {
3247
+ piece(done, text.length, { ...item.run, text: text.slice(done) });
3248
+ }
3249
+ }
3250
+ items.splice(0, items.length, ...next);
3251
+ }
3252
+
3253
+ /** The space a tab in a box is laid out as: a `pre`'s spaces are held
3254
+ * together (`heldText`), and so are its tabs. */
3255
+ function spaceIn(box: Box): string {
3256
+ return box.style.whiteSpace === 'pre' ? '\u00a0' : ' ';
3257
+ }
3258
+
3259
+ /** A text's font, the part of a run a character's advance depends on. */
3260
+ type Face = Pick<TextRun, 'family' | 'size' | 'weight' | 'style'>;
3261
+
3262
+ /** A block's font, as its runs name it (`runFor`). */
3263
+ function fontOf(style: ComputedStyle): Face {
3264
+ return {
3265
+ family: style.fontFamily,
3266
+ size: style.fontSize,
3267
+ weight: style.fontWeight,
3268
+ style: style.fontStyle === 'normal' ? 'normal' : 'italic',
3269
+ };
3270
+ }
3271
+
3272
+ /** One character's advance in a font, kept per font manager. */
3273
+ function advanceOf(fonts: FontsLike, font: Face, char: string): number {
3274
+ let kept = ADVANCES.get(fonts);
3275
+ if (!kept) ADVANCES.set(fonts, (kept = new Map()));
3276
+ const key = `${font.family}|${font.size}|${font.weight}|${font.style}|${char}`;
3277
+ let advance = kept.get(key);
3278
+ if (advance === undefined) {
3279
+ const face: Face = {
3280
+ family: font.family,
3281
+ size: font.size,
3282
+ weight: font.weight,
3283
+ style: font.style,
3284
+ };
3285
+ // between two letters, so that no engine drops it as a line's end
3286
+ advance =
3287
+ fonts.layout([{ ...face, text: `x${char}x` }], face, {}).width -
3288
+ fonts.layout([{ ...face, text: 'xx' }], face, {}).width;
3289
+ kept.set(key, advance);
3290
+ }
3291
+ return advance;
3292
+ }
3293
+
3294
+ const ADVANCES = new WeakMap<FontsLike, Map<string, number>>();
3295
+
3296
+ function heldText(box: Box): string {
3297
+ if (box.style.whiteSpace !== 'pre' || !box.text.includes(' ')) {
3298
+ return box.text;
3299
+ }
3300
+ let held = HELD.get(box);
3301
+ if (held === undefined) {
3302
+ held = box.text.replace(/ /g, '\u00a0');
3303
+ HELD.set(box, held);
3304
+ }
3305
+ return held;
3306
+ }
3307
+
3308
+ const HELD = new WeakMap<Box, string>();
3309
+
3310
+ /**
3311
+ * The OpenType features a style's text is shaped with: the `font-variant`
3312
+ * longhands' and `font-kerning`'s, then `font-feature-settings`, which has
3313
+ * the last word (CSS Fonts 3, 7.2). One object for each set of them, kept,
3314
+ * because a run's fields are compared by identity — a new object a pass
3315
+ * would find no layout the last pass made.
3316
+ */
3317
+ export function featuresOf(
3318
+ style: ComputedStyle,
3319
+ ): Readonly<Record<string, number>> | null {
3320
+ const {
3321
+ fontKerning: kerning,
3322
+ fontVariantLigatures: ligatures,
3323
+ fontVariantNumeric: numeric,
3324
+ fontVariantCaps: caps,
3325
+ fontVariantPosition: position,
3326
+ fontFeatureSettings: settings,
3327
+ } = style;
3328
+ if (!kerning && !ligatures && !numeric && !caps && !position && !settings) {
3329
+ return null;
3330
+ }
3331
+ const key = `${kerning}|${ligatures}|${numeric}|${caps}|${position}|${settings}`;
3332
+ let features = FEATURES.get(key);
3333
+ if (!features) {
3334
+ const out: Record<string, number> = {};
3335
+ for (const part of [
3336
+ kerning,
3337
+ ligatures,
3338
+ numeric,
3339
+ caps,
3340
+ position,
3341
+ settings,
3342
+ ]) {
3343
+ if (!part) continue;
3344
+ for (const pair of part.split(',')) {
3345
+ const at = pair.indexOf('=');
3346
+ out[pair.slice(0, at)] = Number(pair.slice(at + 1));
3347
+ }
3348
+ }
3349
+ features = Object.freeze(out);
3350
+ FEATURES.set(key, features);
3351
+ }
3352
+ return features;
3353
+ }
3354
+
3355
+ const FEATURES = new Map<string, Readonly<Record<string, number>>>();
3356
+
3357
+ /** A run of `text` in `style`: its face, its ink and its rules. Every
3358
+ * paint field of a run of the document's text is this function's, which
3359
+ * is what lets a pointer move re-ink a paragraph without laying it out
3360
+ * again (`HtmlViewNode._hoverInPlace`). */
3361
+ export function runFor(text: string, style: ComputedStyle): TextRun {
3362
+ const run: TextRun = {
668
3363
  text,
669
3364
  family: style.fontFamily,
670
3365
  size: style.fontSize,
671
3366
  weight: style.fontWeight,
672
3367
  style: style.fontStyle === 'normal' ? 'normal' : 'italic',
673
- color: style.color,
3368
+ // the glyphs' fill, which `-webkit-text-fill-color` sets apart from
3369
+ // `color` — the decorations keep `color`
3370
+ color:
3371
+ style.textFillColor === null
3372
+ ? style.color
3373
+ : inkColor(style.textFillColor, style.color),
3374
+ // set here, and made true later for an inline box's text (`collect`):
3375
+ // added to a run after it was made, it gave the runs of a document two
3376
+ // shapes where the layout cache compares them, and a pass over 600 KB
3377
+ // took 3 ms longer finding every paragraph's layout again
3378
+ shapeApart: false,
674
3379
  };
675
- // An inline background fills the line box rather than hugging the ink:
676
- // `<span style="background: yellow">` is a highlighter, and two adjacent
677
- // highlighted spans must not leave a seam between them. That is exactly
678
- // what richtext's `bgFill: 'line'` was added for.
679
- if (!isTransparent(style.backgroundColor)) {
680
- run.bg = inkColor(style.backgroundColor as string, style.color);
681
- run.bgFill = 'line';
682
- }
683
- if (owner) run.element = owner;
684
- if (style.textDecorationLine === 'underline') {
685
- run.underline = inkColor(
686
- style.textDecorationColor ?? 'currentColor',
687
- style.color,
688
- );
3380
+ if (style.letterSpacing) run.letterSpacing = style.letterSpacing;
3381
+ const features = featuresOf(style);
3382
+ if (features) run.features = features;
3383
+ // Hidden text keeps its place on the line and draws nothing, rules
3384
+ // included, and the text of a visible element inside it is its own run
3385
+ // (CSS 2.1 11.2): drawn in no ink, it is laid out as it was
3386
+ if (style.visibility !== 'visible') {
3387
+ run.color = 'transparent';
3388
+ return run;
3389
+ }
3390
+ // A background is not the run's: the inline box it belongs to paints it,
3391
+ // behind every fragment of the box — nested elements' text included — and
3392
+ // out to its padding (`paintInlineBoxes`).
3393
+ // the element's own decorations and the ones propagated to it, in the
3394
+ // colours of the elements that set them (`decorate`)
3395
+ if (style.underline) {
3396
+ run.underline = style.underline;
689
3397
  // CSS's five rule styles and SGR 4's five are the same set under two
690
3398
  // names; richtext speaks SGR's, so `solid` is `single` and `wavy` is
691
3399
  // `curly`. The other three are spelled identically.
692
3400
  run.underlineStyle =
693
- style.textDecorationStyle === 'wavy'
3401
+ style.underlineStyle === 'wavy'
694
3402
  ? 'curly'
695
- : style.textDecorationStyle === 'solid'
3403
+ : style.underlineStyle === 'solid'
696
3404
  ? 'single'
697
- : style.textDecorationStyle;
698
- } else if (style.textDecorationLine === 'line-through') {
699
- run.strike = inkColor(
700
- style.textDecorationColor ?? 'currentColor',
701
- style.color,
702
- );
3405
+ : style.underlineStyle;
3406
+ if (style.underlineOffset !== null) {
3407
+ run.underlineOffset = style.underlineOffset;
3408
+ }
3409
+ if (style.underlineThickness !== null) {
3410
+ run.underlineThickness = style.underlineThickness;
3411
+ }
703
3412
  }
3413
+ if (style.lineThrough) run.strike = style.lineThrough;
704
3414
  return run;
705
3415
  }
706
3416
 
@@ -712,17 +3422,47 @@ function runFor(
712
3422
  class SpanMap {
713
3423
  private _laid: number[] = [];
714
3424
  private _doc: number[] = [];
3425
+ private _boxes: (Box | null)[] = [];
3426
+ /** Where the laid-out text has a unit that is no text of the document's:
3427
+ * a spacer, a bidi control. */
3428
+ private _gaps: number[] | null = null;
715
3429
  laidOut = 0;
716
3430
 
717
- add(documentStart: number, length: number): void {
3431
+ add(documentStart: number, length: number, box: Box | null = null): void {
718
3432
  this._laid.push(this.laidOut);
719
3433
  this._doc.push(documentStart);
3434
+ this._boxes.push(box);
3435
+ if (!box) {
3436
+ this._gaps ??= [];
3437
+ for (let i = 0; i < length; i += 1) this._gaps.push(this.laidOut + i);
3438
+ }
720
3439
  this.laidOut += length;
721
3440
  }
722
3441
 
3442
+ /** Give a line's text the gaps in its part of the laid-out text, where
3443
+ * it has any. Set after the text is made rather than spread into it: a
3444
+ * spread in an object literal costs every line that has none. */
3445
+ giveGaps(text: LineText, start: number, end: number): void {
3446
+ if (!this._gaps) return;
3447
+ const gaps = this._gaps.filter((at) => at >= start && at < end);
3448
+ if (gaps.length) text.gaps = gaps;
3449
+ }
3450
+
3451
+ /** The text box whose text an offset in the laid-out text is. */
3452
+ boxAt(offset: number): Box | null {
3453
+ const i = this._entryAt(offset);
3454
+ return i < 0 ? null : this._boxes[i];
3455
+ }
3456
+
723
3457
  documentAt(offset: number): number {
3458
+ const i = this._entryAt(offset);
3459
+ return i < 0 ? 0 : this._doc[i] + (offset - this._laid[i]);
3460
+ }
3461
+
3462
+ /** The entry an offset falls in, or -1 when there are none. */
3463
+ private _entryAt(offset: number): number {
724
3464
  const laid = this._laid;
725
- if (!laid.length) return 0;
3465
+ if (!laid.length) return -1;
726
3466
  let lo = 0;
727
3467
  let hi = laid.length - 1;
728
3468
  while (lo < hi) {
@@ -730,7 +3470,7 @@ class SpanMap {
730
3470
  if (laid[mid] <= offset) lo = mid;
731
3471
  else hi = mid - 1;
732
3472
  }
733
- return this._doc[lo] + (offset - laid[lo]);
3473
+ return lo;
734
3474
  }
735
3475
  }
736
3476
 
@@ -751,7 +3491,7 @@ function segmentFrom(items: Item[], index: number, offset: number): Segment {
751
3491
  if (item.kind !== 'text') break;
752
3492
  const text = skip > 0 ? item.run.text.slice(skip) : item.run.text;
753
3493
  if (text) {
754
- spans.add(item.box.textStart + skip, text.length);
3494
+ spans.add(item.start + skip, text.length, item.control ? null : item.box);
755
3495
  runs.push(skip > 0 ? { ...item.run, text } : item.run);
756
3496
  }
757
3497
  skip = 0;
@@ -759,6 +3499,10 @@ function segmentFrom(items: Item[], index: number, offset: number): Segment {
759
3499
  return { runs, spans, nextIndex: i };
760
3500
  }
761
3501
 
3502
+ /** A text a line may break after where its block wraps: one that ends in
3503
+ * white space, which a no-break space a `nowrap` element holds is not. */
3504
+ const BREAKS_AFTER = /[ \t\n]$/;
3505
+
762
3506
  /** Move `(index, offset)` forward by `consumed` code units of text. */
763
3507
  function advance(
764
3508
  items: Item[],
@@ -784,8 +3528,9 @@ function advance(
784
3528
  /**
785
3529
  * One space's advance in a run's style, measured as the difference between
786
3530
  * `x x` and `xx` — ntk strips a lone trailing space from a line, so it
787
- * cannot be measured directly. Cached per font manager per style; the
788
- * measurement itself hits ntk's shaping memo.
3531
+ * cannot be measured directly — plus the run's letter spacing, which a
3532
+ * space takes as every other character does. Cached per font manager per
3533
+ * face; the measurement itself hits ntk's shaping memo.
789
3534
  */
790
3535
  const SPACE_ADVANCE = new WeakMap<FontsLike, Map<string, number>>();
791
3536
 
@@ -795,9 +3540,10 @@ function spaceAdvance(fonts: FontsLike, run: TextRun): number {
795
3540
  cache = new Map();
796
3541
  SPACE_ADVANCE.set(fonts, cache);
797
3542
  }
3543
+ const spacing = run.letterSpacing ?? 0;
798
3544
  const key = `${run.family}|${run.size}|${run.weight}|${run.style}`;
799
3545
  const hit = cache.get(key);
800
- if (hit !== undefined) return hit;
3546
+ if (hit !== undefined) return hit + spacing;
801
3547
  const style = {
802
3548
  family: run.family,
803
3549
  size: run.size,
@@ -809,7 +3555,124 @@ function spaceAdvance(fonts: FontsLike, run: TextRun): number {
809
3555
  const advance = Math.max(0, measure('x x') - measure('xx'));
810
3556
  if (cache.size > 64) cache.clear();
811
3557
  cache.set(key, advance);
812
- return advance;
3558
+ return advance + spacing;
3559
+ }
3560
+
3561
+ /** Whether the runs' joined text has a forced break just before a
3562
+ * code-unit offset — a line's end, which an engine counts the break in. */
3563
+ function breakBefore(runs: TextRun[], offset: number): boolean {
3564
+ let at = 0;
3565
+ for (const run of runs) {
3566
+ const next = at + run.text.length;
3567
+ if (offset - 1 < next) return run.text.charCodeAt(offset - 1 - at) === 10;
3568
+ at = next;
3569
+ }
3570
+ return false;
3571
+ }
3572
+
3573
+ /**
3574
+ * The white space a text engine strips from a line's end, as a pattern for
3575
+ * the end of a run: spaces and tabs, and the no-break space too where the
3576
+ * engine strips that — ntk to 8.12.9 and CoreText through @windowkit/appkit
3577
+ * 0.15.0 do, where CSS measures it (sidorares/ntk#395, windowkit/appkit#83).
3578
+ * Asked of the engine once per font manager, because what the line adds back
3579
+ * has to be what the engine took: added back after an engine that kept it, a
3580
+ * no-break space before an image counted twice.
3581
+ */
3582
+ const HUNG = new WeakMap<FontsLike, RegExp>();
3583
+
3584
+ export function hungSpaces(fonts: FontsLike, run: TextRun): RegExp {
3585
+ let hung = HUNG.get(fonts);
3586
+ if (!hung) {
3587
+ const style = { family: run.family, size: run.size };
3588
+ const width = (text: string): number =>
3589
+ fonts.layout([{ ...style, text }], style, {}).lines[0]?.width ?? 0;
3590
+ hung = width('x\u00A0') > width('x') ? /[ \t]+$/ : /[ \t\u00A0]+$/;
3591
+ HUNG.set(fonts, hung);
3592
+ }
3593
+ return hung;
3594
+ }
3595
+
3596
+ /**
3597
+ * A clamped paragraph with an ellipsis at the end of its last line (CSS
3598
+ * Overflow 4, 4.2): its text up to where that line breaks in the room the
3599
+ * ellipsis leaves it — at a soft wrap opportunity, so the words that do
3600
+ * not fit beside the ellipsis go to the lines the clamp hides, and inside
3601
+ * a word only where the line has no other — and the ellipsis after it, in
3602
+ * the block's own style. The engine's own ellipsis cuts inside the last
3603
+ * word, as `text-overflow` does.
3604
+ */
3605
+ function ellipsized(
3606
+ fonts: FontsLike,
3607
+ items: readonly Extract<Item, { kind: 'text' }>[],
3608
+ runs: TextRun[],
3609
+ base: Record<string, unknown>,
3610
+ options: Parameters<FontsLike['layout']>[2],
3611
+ layout: TextLayoutLike,
3612
+ width: number,
3613
+ style: ComputedStyle,
3614
+ ): { layout: TextLayoutLike; runs: TextRun[]; spans: SpanMap } {
3615
+ const mark = runFor('\u2026', style);
3616
+ const room = width - fonts.layout([mark], base, {}).width;
3617
+ const last = layout.lines[layout.lines.length - 1];
3618
+ let end = last.end;
3619
+ if (last.width > room) {
3620
+ const probe = fonts.layout(sliceRuns(runs, last.start, last.end), base, {
3621
+ ...options,
3622
+ maxWidth: Math.max(0, room),
3623
+ maxLines: 1,
3624
+ overflow: 'clip',
3625
+ overflowWrap: 'break-word',
3626
+ });
3627
+ end = last.start + (probe.lines[0]?.end ?? 0);
3628
+ }
3629
+ // the text to the break, less the white space the line ends in — an
3630
+ // empty line keeps the break before it, and is the ellipsis alone
3631
+ const line = sliceRuns(runs, last.start, end);
3632
+ let trailing = 0;
3633
+ for (let i = line.length - 1; i >= 0; i -= 1) {
3634
+ const text = line[i].text;
3635
+ const trimmed = text.replace(/[ \t\n\r\f]+$/, '').length;
3636
+ trailing += text.length - trimmed;
3637
+ if (trimmed) break;
3638
+ }
3639
+ const kept = sliceRuns(runs, 0, end - trailing);
3640
+ // and the spans to match, the ellipsis no text of the document's
3641
+ const spans = new SpanMap();
3642
+ let at = items.length ? items[0].start : 0;
3643
+ let left = 0;
3644
+ for (const run of kept) left += run.text.length;
3645
+ for (const item of items) {
3646
+ if (left <= 0) break;
3647
+ const length = Math.min(item.run.text.length, left);
3648
+ spans.add(item.start, length, item.control ? null : item.box);
3649
+ left -= length;
3650
+ at = item.start + length;
3651
+ }
3652
+ spans.add(at, 1, null);
3653
+ kept.push(mark);
3654
+ return { layout: fonts.layout(kept, base, options), runs: kept, spans };
3655
+ }
3656
+
3657
+ /** The runs' text from one code-unit offset into their joined text to
3658
+ * another, each piece in its own run's style. */
3659
+ function sliceRuns(
3660
+ runs: readonly TextRun[],
3661
+ from: number,
3662
+ to: number,
3663
+ ): TextRun[] {
3664
+ const out: TextRun[] = [];
3665
+ let at = 0;
3666
+ for (const run of runs) {
3667
+ const next = at + run.text.length;
3668
+ if (next > from && at < to) {
3669
+ const text = run.text.slice(Math.max(0, from - at), to - at);
3670
+ out.push(text === run.text ? run : { ...run, text });
3671
+ }
3672
+ at = next;
3673
+ if (at >= to) break;
3674
+ }
3675
+ return out;
813
3676
  }
814
3677
 
815
3678
  /** Whether anything but whitespace lies past a code-unit offset into the
@@ -819,7 +3682,10 @@ function inkBeyond(runs: TextRun[], offset: number): boolean {
819
3682
  let at = 0;
820
3683
  for (const run of runs) {
821
3684
  const next = at + run.text.length;
822
- if (next > offset && /\S/.test(run.text.slice(Math.max(0, offset - at)))) {
3685
+ if (
3686
+ next > offset &&
3687
+ /[^ \t\n\r\f]/.test(run.text.slice(Math.max(0, offset - at)))
3688
+ ) {
823
3689
  return true;
824
3690
  }
825
3691
  at = next;
@@ -833,17 +3699,481 @@ function wraps(style: ComputedStyle): boolean {
833
3699
  return style.whiteSpace !== 'nowrap' && style.whiteSpace !== 'pre';
834
3700
  }
835
3701
 
836
- function alignFor(style: ComputedStyle): string {
837
- // ntk has no justification; `start` is closer than a silent left on an RTL
838
- // paragraph, and closer than a ragged-right lie about what was drawn.
3702
+ /** How much of a line's unused room goes before it: 0 for a line set flush
3703
+ * left, 1 flush right, ½ centred. `start` and `end` follow the direction;
3704
+ * `justify` is set as `start`, as `alignFor` sets it. */
3705
+ /** How far into the room a line an alignment sets goes: 0 at the left, 1
3706
+ * at the right; `justify` at the start, since a line not justified is
3707
+ * set there. */
3708
+ function alignShift(
3709
+ align: ComputedStyle['textAlign'] | ComputedStyle['textAlignLast'],
3710
+ rtl: boolean,
3711
+ ): number {
3712
+ switch (align) {
3713
+ case 'center':
3714
+ return 0.5;
3715
+ case 'right':
3716
+ return 1;
3717
+ case 'left':
3718
+ return 0;
3719
+ case 'end':
3720
+ return rtl ? 0 : 1;
3721
+ default:
3722
+ return rtl ? 1 : 0;
3723
+ }
3724
+ }
3725
+
3726
+ /** How far a line of a layout moves to where it belongs in its box. */
3727
+ type LinePlacer = (line: { x: number; width: number }) => number;
3728
+
3729
+ /** The blocks whose lines hold text a box's background shows through. */
3730
+ export const CLIPPED_TEXT = new WeakSet<Box>();
3731
+
3732
+ /** How each of those blocks' layouts was made, to make it again. */
3733
+ const RECORDED = new WeakMap<
3734
+ object,
3735
+ {
3736
+ fonts: FontsLike;
3737
+ content: TextRun[];
3738
+ style: Record<string, unknown>;
3739
+ options: Parameters<FontsLike['layout']>[2];
3740
+ }
3741
+ >();
3742
+ const RECORDERS = new WeakMap<FontsLike, FontsLike>();
3743
+ const INKLESS = new WeakMap<object, TextLayoutLike | null>();
3744
+
3745
+ /** The fonts, noting how each layout they make was made. One recorder a
3746
+ * fonts object, so what is kept per fonts object is kept for it too. */
3747
+ function recording(fonts: FontsLike): FontsLike {
3748
+ let recorder = RECORDERS.get(fonts);
3749
+ if (!recorder) {
3750
+ recorder = {
3751
+ layout(content, style, options) {
3752
+ const layout = fonts.layout(content, style, options);
3753
+ RECORDED.set(layout, { fonts, content, style, options });
3754
+ return layout;
3755
+ },
3756
+ match: (family, style) => fonts.match(family, style),
3757
+ };
3758
+ RECORDERS.set(fonts, recorder);
3759
+ }
3760
+ return recorder;
3761
+ }
3762
+
3763
+ /**
3764
+ * A layout of the same text with no ink of its own, which an engine draws
3765
+ * in the context's fill — a gradient's, for text a background shows
3766
+ * through; the same runs, so the same glyphs where the first put them.
3767
+ * Null for a layout that was not recorded.
3768
+ */
3769
+ export function inklessLayout(layout: object): TextLayoutLike | null {
3770
+ let inkless = INKLESS.get(layout);
3771
+ if (inkless !== undefined) return inkless;
3772
+ const made = RECORDED.get(layout);
3773
+ inkless = made
3774
+ ? made.fonts.layout(
3775
+ made.content.map((run) => ({ ...run, color: undefined })),
3776
+ { ...made.style, color: undefined },
3777
+ made.options,
3778
+ )
3779
+ : null;
3780
+ INKLESS.set(layout, inkless);
3781
+ return inkless;
3782
+ }
3783
+
3784
+ /** Whether any of a block's text is text a background shows through. */
3785
+ function clipsText(block: Box, items: Item[]): boolean {
3786
+ for (const item of items) {
3787
+ if (item.kind === 'text' && !item.control && clipBoxOf(item.box, block)) {
3788
+ return true;
3789
+ }
3790
+ }
3791
+ return false;
3792
+ }
3793
+
3794
+ /** The box whose background a text box's glyphs show: the nearest element
3795
+ * box from it up to the block its lines are in with `background-clip:
3796
+ * text`, or null. */
3797
+ export function clipBoxOf(box: Box | null, block: Box): Box | null {
3798
+ for (let at = box; at; at = at.parent) {
3799
+ if (at.kind !== 'text' && at.style.backgroundClipText) return at;
3800
+ if (at === block) break;
3801
+ }
3802
+ return null;
3803
+ }
3804
+
3805
+ /** The most lines `text-wrap: balance` evens out, as in Chrome: a heading,
3806
+ * a caption, a pull quote — not a paragraph, whose last line is its own. */
3807
+ const BALANCE_LINES = 6;
3808
+
3809
+ /**
3810
+ * The narrowest width a paragraph's text breaks into no more lines than
3811
+ * `layout` has (CSS Text 4, 6.2), found by halving the width between the
3812
+ * least that could hold them — their widths over their number — and the
3813
+ * width it was laid out at. The engine breaks the lines, so each step
3814
+ * asks it; eight steps come to within a pixel of the answer for a heading
3815
+ * across a page.
3816
+ */
3817
+ function balancedWidth(
3818
+ fonts: FontsLike,
3819
+ runs: TextRun[],
3820
+ base: Record<string, unknown>,
3821
+ options: Parameters<FontsLike['layout']>[2],
3822
+ width: number,
3823
+ layout: TextLayoutLike,
3824
+ ): number {
3825
+ const count = layout.lines.length;
3826
+ let total = 0;
3827
+ for (const line of layout.lines) total += line.width;
3828
+ let lo = total / count;
3829
+ let hi = width;
3830
+ for (let step = 0; step < 8 && hi - lo > 1; step += 1) {
3831
+ const mid = (lo + hi) / 2;
3832
+ const probe = fonts.layout(runs, base, { ...options, maxWidth: mid });
3833
+ if (probe.lines.length <= count) hi = mid;
3834
+ else lo = mid;
3835
+ }
3836
+ return Math.ceil(hi);
3837
+ }
3838
+
3839
+ /**
3840
+ * Where `text-align` puts the lines of a layout that was given no width to
3841
+ * align them in — text that does not wrap, which has no `maxWidth` — as how
3842
+ * far each moves: the engine aligns those lines within the widest of them,
3843
+ * which for a single line is no alignment at all, so a centred `<td
3844
+ * nowrap>` or a `white-space: nowrap` button label was set flush left. A
3845
+ * line too long for its box is set at its start, overflowing its end (CSS
3846
+ * Text 3, 7.1), which is its left in a right-to-left paragraph, as in
3847
+ * Blink. Null where every line is already where it belongs: flush left in
3848
+ * a left-to-right paragraph, or measured for a width it has not been given.
3849
+ */
3850
+ /** How the lines of a layout made with no width to fill are placed in
3851
+ * the room: each is its text's last or a forced break's, and takes
3852
+ * `text-align-last`, but where `last` is false — a balanced paragraph's,
3853
+ * laid out narrower than its room. */
3854
+ function unwrappedPlacer(
3855
+ style: ComputedStyle,
3856
+ width: number,
3857
+ last = true,
3858
+ ): LinePlacer | null {
3859
+ const rtl = style.direction === 'rtl';
3860
+ const shift = alignShift(last ? lastAlignOf(style) : style.textAlign, rtl);
3861
+ if ((shift === 0 && !rtl) || !Number.isFinite(width)) return null;
3862
+ return (line) => {
3863
+ const free = width - line.width;
3864
+ return (free >= 0 ? free * shift : rtl ? free : 0) - line.x;
3865
+ };
3866
+ }
3867
+
3868
+ /**
3869
+ * The spacing a space is given to be laid out as one that may be spaced
3870
+ * apart, too little to move a glyph. It is not nothing, because neither
3871
+ * engine measures a space that is a spaced run of its own as it measures
3872
+ * the same space inside its run: ntk shapes each run apart, so the kerning
3873
+ * pair a space made with the letter beside it is gone (Arial's `A` and
3874
+ * `T` have them), and CoreText spaces a glyph with its kerning attribute,
3875
+ * which takes the place of the font's pairs. A line justified from a
3876
+ * measure of its spaces as they were came out wider than its box and
3877
+ * broke a word early: on macOS, in Helvetica, half of a paragraph did.
3878
+ */
3879
+ const HAIR = 1e-6;
3880
+
3881
+ /** The runs with each space a run of its own, spaced a hair apart. */
3882
+ function spacedApart(runs: TextRun[]): TextRun[] {
3883
+ const out: TextRun[] = [];
3884
+ for (const run of runs) {
3885
+ const text = run.text;
3886
+ let piece = 0;
3887
+ for (let at = 0; at < text.length; at += 1) {
3888
+ const c = text.charCodeAt(at);
3889
+ if (c !== 0x20 && c !== 0xa0) continue;
3890
+ if (at > piece) out.push({ ...run, text: text.slice(piece, at) });
3891
+ out.push({
3892
+ ...run,
3893
+ text: text[at],
3894
+ letterSpacing: (run.letterSpacing ?? 0) + HAIR,
3895
+ });
3896
+ piece = at + 1;
3897
+ }
3898
+ if (piece === 0) out.push(run);
3899
+ else if (piece < text.length) out.push({ ...run, text: text.slice(piece) });
3900
+ }
3901
+ return out;
3902
+ }
3903
+
3904
+ /**
3905
+ * `text-align: justify` (CSS Text 3, 7.4), as neither engine has it: the
3906
+ * paragraph's runs again, with each space inside a line that is to be
3907
+ * justified widened by its share of what the line leaves of `width` — the
3908
+ * `letter-spacing` a `word-spacing` is drawn with — so the same breaks fill
3909
+ * their lines. A line that is the paragraph's last, or that a forced break
3910
+ * ends, is not justified, nor one with no space inside it; the spaces a
3911
+ * line ends on hang, and take no share. `skip` is the offsets of spaces
3912
+ * that are no word separators — an inline box's edge laid out as a spacer
3913
+ * (`layoutSpaced`) — which take no share either. Null where no line is
3914
+ * justified.
3915
+ */
3916
+ function justifiedRuns(
3917
+ runs: TextRun[],
3918
+ layout: TextLayoutLike,
3919
+ width: number,
3920
+ rest = true,
3921
+ last = false,
3922
+ skip?: ReadonlySet<number>,
3923
+ ): TextRun[] | null {
3924
+ const text = runs.map((run) => run.text).join('');
3925
+ // each space's extra, by its offset in the paragraph
3926
+ const extra = new Map<number, number>();
3927
+ const lines = layout.lines;
3928
+ for (let i = 0; i < lines.length; i += 1) {
3929
+ const line = lines[i];
3930
+ let end = line.end;
3931
+ // the paragraph's last line, or one a forced break ends, is
3932
+ // `text-align-last`'s to justify
3933
+ const ends =
3934
+ i === lines.length - 1 || text[end - 1] === '\n' || text[end] === '\n';
3935
+ if (!(ends ? last : rest)) continue;
3936
+ while (
3937
+ end > line.start &&
3938
+ (text[end - 1] === ' ' ||
3939
+ text[end - 1] === '\u00a0' ||
3940
+ !!skip?.has(end - 1))
3941
+ )
3942
+ end -= 1;
3943
+ const spaces: number[] = [];
3944
+ for (let at = line.start; at < end; at += 1) {
3945
+ if ((text[at] === ' ' || text[at] === '\u00a0') && !skip?.has(at)) {
3946
+ spaces.push(at);
3947
+ }
3948
+ }
3949
+ // a hair short, so that the engine breaks where it did
3950
+ const slack = width - line.width - 0.01;
3951
+ if (!spaces.length || !(slack > 0)) continue;
3952
+ for (const at of spaces) extra.set(at, slack / spaces.length);
3953
+ }
3954
+ return extra.size ? widenedAt(runs, extra) : null;
3955
+ }
3956
+
3957
+ /** The runs with the character at each offset of `extra` a run of its
3958
+ * own, spaced that much more. */
3959
+ function widenedAt(runs: TextRun[], extra: Map<number, number>): TextRun[] {
3960
+ const text = runs.map((run) => run.text).join('');
3961
+ const out: TextRun[] = [];
3962
+ let from = 0;
3963
+ for (const run of runs) {
3964
+ const end = from + run.text.length;
3965
+ let piece = from;
3966
+ for (let at = from; at < end; at += 1) {
3967
+ const add = extra.get(at);
3968
+ if (add === undefined) continue;
3969
+ if (at > piece) out.push({ ...run, text: text.slice(piece, at) });
3970
+ out.push({
3971
+ ...run,
3972
+ text: text[at],
3973
+ letterSpacing: (run.letterSpacing ?? 0) + add,
3974
+ });
3975
+ piece = at + 1;
3976
+ }
3977
+ if (end > piece) out.push({ ...run, text: text.slice(piece, end) });
3978
+ from = end;
3979
+ }
3980
+ return out;
3981
+ }
3982
+
3983
+ /** A word separator, as justification spaces them out (CSS Text 3, 7.4):
3984
+ * a space, or a no-break one. */
3985
+ function isSeparator(code: number): boolean {
3986
+ return code === 0x20 || code === 0xa0;
3987
+ }
3988
+
3989
+ /** What `finishLine` lays a justified line's text out again with. */
3990
+ interface LineJustify {
3991
+ fonts: FontsLike;
3992
+ base: Record<string, unknown>;
3993
+ options: Parameters<FontsLike['layout']>[2];
3994
+ }
3995
+
3996
+ /**
3997
+ * A line made a piece at a time — beside a float, around an inline-block,
3998
+ * between an inline box's edges — justified where it is (CSS Text 3, 7.4):
3999
+ * the room it leaves, `free`, shared out equally among its word
4000
+ * separators, as `justifiedRuns` shares out a paragraph's. A space inside a
4001
+ * piece of text takes its share as letter spacing, the piece laid out again
4002
+ * with it; a space a piece ends on, with something after it on the line,
4003
+ * takes it as room before what follows. The spaces the line ends on hang
4004
+ * (7.3), whatever inline box closes after them, and take none. Whatever
4005
+ * follows a share on the line moves over by it. How much wider the line
4006
+ * is.
4007
+ */
4008
+ function justifyLine(open: OpenLine, free: number, how: LineJustify): number {
4009
+ const order = open.order;
4010
+ let lastContent = -1;
4011
+ for (let i = order.length - 1; i >= 0; i -= 1) {
4012
+ if (order[i].kind !== 'edge') {
4013
+ lastContent = i;
4014
+ break;
4015
+ }
4016
+ }
4017
+ const plans = new Map<
4018
+ LineText,
4019
+ { runs: TextRun[]; inside: number[]; after: number }
4020
+ >();
4021
+ let count = 0;
4022
+ for (let i = 0; i < order.length; i += 1) {
4023
+ const placed = order[i];
4024
+ if (placed.kind !== 'text') continue;
4025
+ const text = placed.item;
4026
+ // the runs it was laid out from, where they are still what is kept
4027
+ // (a selection keeps its offsets in their place)
4028
+ const kept = LAYOUT_RUNS.get(text.layout);
4029
+ const runs =
4030
+ kept?.length && typeof kept[0] !== 'number' ? (kept as TextRun[]) : null;
4031
+ const line = text.layout.lines[text.layoutLine];
4032
+ if (!runs || !line) continue;
4033
+ const all = runs.map((run) => run.text).join('');
4034
+ let end = line.end;
4035
+ while (end > line.start && isSeparator(all.charCodeAt(end - 1))) end -= 1;
4036
+ const inside: number[] = [];
4037
+ for (let at = line.start; at < end; at += 1) {
4038
+ if (isSeparator(all.charCodeAt(at))) inside.push(at);
4039
+ }
4040
+ // the spaces it ends on, before what follows it on the line
4041
+ let after = 0;
4042
+ if (i < lastContent) {
4043
+ for (let at = end; at < all.length; at += 1) {
4044
+ if (!isSeparator(all.charCodeAt(at))) break;
4045
+ after += 1;
4046
+ }
4047
+ }
4048
+ count += inside.length + after;
4049
+ plans.set(text, { runs, inside, after });
4050
+ }
4051
+ if (!count) return 0;
4052
+ const share = free / count;
4053
+ let moved = 0;
4054
+ for (const placed of order) {
4055
+ if (placed.kind !== 'text') {
4056
+ placed.item.x += moved;
4057
+ continue;
4058
+ }
4059
+ const text = placed.item;
4060
+ text.drawX += moved;
4061
+ const plan = plans.get(text);
4062
+ if (!plan) continue;
4063
+ if (plan.inside.length) {
4064
+ const line = text.layout.lines[text.layoutLine];
4065
+ const widened = widenedAt(
4066
+ plan.runs,
4067
+ new Map(plan.inside.map((at) => [at, share])),
4068
+ );
4069
+ // as wide as the piece is to be, and the piece alone: the text after
4070
+ // it did not fit beside it before it was any wider
4071
+ const layout = how.fonts.layout(widened, how.base, {
4072
+ ...how.options,
4073
+ maxWidth: line.width + plan.inside.length * share + 0.5,
4074
+ maxLines: 1,
4075
+ });
4076
+ const first = layout.lines[0];
4077
+ if (first && first.start === line.start && first.end === line.end) {
4078
+ LAYOUT_RUNS.set(layout, widened);
4079
+ text.layout = layout;
4080
+ text.layoutLine = 0;
4081
+ moved += first.width - line.width;
4082
+ }
4083
+ }
4084
+ moved += plan.after * share;
4085
+ }
4086
+ return moved;
4087
+ }
4088
+
4089
+ /** Which of a paragraph's lines are justified (CSS Text 3, 7.4): those
4090
+ * its end or a forced break does not end, where `text-align` is
4091
+ * `justify`, and those it does, where `text-align-last` is — neither
4092
+ * where the text does not wrap, nor under `text-justify: none`. */
4093
+ function justification(style: ComputedStyle): { rest: boolean; last: boolean } {
4094
+ const on = wraps(style) && style.textJustify !== 'none';
4095
+ return {
4096
+ rest: on && style.textAlign === 'justify',
4097
+ last: on && lastAlignOf(style) === 'justify',
4098
+ };
4099
+ }
4100
+
4101
+ /**
4102
+ * A layout of `runs` whose justified lines fill `width`: measured as it
4103
+ * will be drawn, its spaces spaced apart (`HAIR`), then laid out again with
4104
+ * each space inside a justified line widened by its share
4105
+ * (`justifiedRuns`), which keeps the lines where they broke. The layout
4106
+ * that is given where no line is to be justified. Every path that lays a
4107
+ * paragraph out as one layout justifies through this, so that a paragraph
4108
+ * is justified whichever it took.
4109
+ */
4110
+ function justifiedLayout(
4111
+ fonts: FontsLike,
4112
+ runs: TextRun[],
4113
+ base: Record<string, unknown>,
4114
+ options: Parameters<FontsLike['layout']>[2],
4115
+ layout: TextLayoutLike,
4116
+ width: number,
4117
+ { rest, last }: { rest: boolean; last: boolean },
4118
+ skip?: ReadonlySet<number>,
4119
+ ): { layout: TextLayoutLike; runs: TextRun[] } {
4120
+ if (
4121
+ !Number.isFinite(width) ||
4122
+ !((rest && layout.lines.length > 1) || (last && layout.lines.length > 0))
4123
+ ) {
4124
+ return { layout, runs };
4125
+ }
4126
+ const spaced = spacedApart(runs);
4127
+ const measured = fonts.layout(spaced, base, options);
4128
+ const justified = justifiedRuns(spaced, measured, width, rest, last, skip);
4129
+ const out = justified ? fonts.layout(justified, base, options) : measured;
4130
+ const outRuns = justified ?? spaced;
4131
+ LAYOUT_RUNS.set(out, outRuns);
4132
+ return { layout: out, runs: outRuns };
4133
+ }
4134
+
4135
+ /** An alignment as the text engine takes it. It has no justification:
4136
+ * `start` is closer than a silent left on an RTL paragraph, and closer
4137
+ * than a ragged-right lie about what was drawn — justified lines are
4138
+ * spaced to fill their width before they get here (`justifiedRuns`). */
4139
+ function engineAlign(
4140
+ align: ComputedStyle['textAlign'] | ComputedStyle['textAlignLast'],
4141
+ ): string {
4142
+ return align === 'justify' || align === 'auto' ? 'start' : align;
4143
+ }
4144
+
4145
+ /** How the lines the text or a forced break ends are aligned (CSS Text 3,
4146
+ * 7.2): `text-align-last`, or where that is `auto`, `text-align`, but
4147
+ * `justify`'s, which leaves them at the start. */
4148
+ function lastAlignOf(
4149
+ style: ComputedStyle,
4150
+ ): Exclude<ComputedStyle['textAlignLast'], 'auto'> {
4151
+ const last = style.textAlignLast;
4152
+ if (last !== 'auto') return last;
839
4153
  return style.textAlign === 'justify' ? 'start' : style.textAlign;
840
4154
  }
841
4155
 
4156
+ /**
4157
+ * The `text-indent` a block's first line takes: none where that line is
4158
+ * not its element's first formatted line (CSS 2.1 16.1). An anonymous
4159
+ * block's is only where the block is its parent's first child: the text
4160
+ * after a `<div>` in a `<span>`, or after a paragraph in a `<div>`, starts
4161
+ * a line of no indent.
4162
+ */
4163
+ function blockIndent(block: Box, width: number): number {
4164
+ if (!block.el && block.parent && block.parent.children[0] !== block) {
4165
+ return 0;
4166
+ }
4167
+ return indentOf(block.style, width);
4168
+ }
4169
+
842
4170
  function indentOf(style: ComputedStyle, width: number): number {
843
4171
  const indent = style.textIndent;
844
4172
  if (typeof indent === 'number') return indent;
845
4173
  if (indent === 'auto') return 0;
846
- return Number.isFinite(width) ? (indent.pct / 100) * width : 0;
4174
+ // of zero where the width is not known yet, as when measuring the
4175
+ // content's width: a `calc()` keeps its pixels (CSS Sizing 3 5.2.1)
4176
+ return resolve(indent, Number.isFinite(width) ? width : 0);
847
4177
  }
848
4178
 
849
4179
  /**
@@ -854,37 +4184,458 @@ function indentOf(style: ComputedStyle, width: number): number {
854
4184
  * rather than passing the CSS number through is the difference between
855
4185
  * `line-height: 1.5` meaning 1.5 × 16px and it meaning 1.5 × 19px, which is
856
4186
  * a visibly looser document than the author asked for.
4187
+ *
4188
+ * `line-height: 0` is legal and means what it says — lines of no height,
4189
+ * the glyphs overflowing evenly — and ntk's half-leading does exactly that
4190
+ * with a multiplier of 0. The floor is a hair above it rather than the 0.1
4191
+ * it was, which set such lines a tenth of a line apart: the Cocoa engine
4192
+ * takes a zero multiple for none at all.
857
4193
  */
858
- function lineHeightMultiplier(fonts: FontsLike, style: ComputedStyle): number {
4194
+ export function lineHeightMultiplier(
4195
+ fonts: FontsLike,
4196
+ style: ComputedStyle,
4197
+ ): number {
859
4198
  if (style.lineHeight === 'normal') return 1;
860
4199
  const target = style.lineHeightIsLength
861
4200
  ? (style.lineHeight as number)
862
4201
  : (style.lineHeight as number) * style.fontSize;
863
4202
  const natural = naturalLineHeight(fonts, style);
864
4203
  if (!natural) return 1;
865
- return Math.max(0.1, target / natural);
4204
+ return Math.max(1e-4, target / natural);
866
4205
  }
867
4206
 
4207
+ /**
4208
+ * The font's natural line height in a style, which CSS's number form is
4209
+ * converted against. Kept per font manager per style, like a space's
4210
+ * advance: a pass asks once a paragraph — 8,800 times at 600 KB — and on
4211
+ * CoreText every answer was a call to the native side. Found by the style
4212
+ * object first, as `faceExtent` is: the paragraphs of a pass share their
4213
+ * styles, and spelling a face into a string key for each was a millisecond
4214
+ * of every pass over a long document.
4215
+ */
4216
+ const NATURAL_LINE_HEIGHT = new WeakMap<
4217
+ FontsLike,
4218
+ { byStyle: WeakMap<ComputedStyle, number>; byFace: Map<string, number> }
4219
+ >();
4220
+
868
4221
  function naturalLineHeight(fonts: FontsLike, style: ComputedStyle): number {
4222
+ let cache = NATURAL_LINE_HEIGHT.get(fonts);
4223
+ if (!cache) {
4224
+ cache = { byStyle: new WeakMap(), byFace: new Map() };
4225
+ NATURAL_LINE_HEIGHT.set(fonts, cache);
4226
+ }
4227
+ const known = cache.byStyle.get(style);
4228
+ if (known !== undefined) return known;
4229
+ const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight}|${style.fontStyle}`;
4230
+ let height = cache.byFace.get(key);
4231
+ if (height === undefined) {
4232
+ try {
4233
+ const font = fonts.match(style.fontFamily, {
4234
+ size: style.fontSize,
4235
+ weight: style.fontWeight,
4236
+ style: style.fontStyle,
4237
+ });
4238
+ height = font.metrics(style.fontSize).lineHeight;
4239
+ } catch {
4240
+ height = style.fontSize * 1.2;
4241
+ }
4242
+ if (cache.byFace.size > 64) cache.byFace.clear();
4243
+ cache.byFace.set(key, height);
4244
+ }
4245
+ cache.byStyle.set(style, height);
4246
+ return height;
4247
+ }
4248
+
4249
+ /**
4250
+ * A block's strut: the ascent and descent of a line of nothing in its face,
4251
+ * the half-leading its `line-height` adds shared above and below, as the
4252
+ * text engines set a line (CSS 2.1 10.8.1).
4253
+ */
4254
+ export function strutOf(
4255
+ fonts: FontsLike,
4256
+ style: ComputedStyle,
4257
+ ): InlineDecoration {
4258
+ // text set at no size takes no room, and CoreText reads a size of 0 as
4259
+ // its default twelve points
4260
+ if (!(style.fontSize > 0)) return NO_EXTENT;
4261
+ const face = faceExtent(fonts, style);
4262
+ const target =
4263
+ style.lineHeight === 'normal'
4264
+ ? naturalLineHeight(fonts, style)
4265
+ : style.lineHeightIsLength
4266
+ ? (style.lineHeight as number)
4267
+ : (style.lineHeight as number) * style.fontSize;
4268
+ const half = (target - face.ascent - face.descent) / 2;
4269
+ return { ascent: face.ascent + half, descent: face.descent + half };
4270
+ }
4271
+
4272
+ /** Where a text `vertical-align` raised goes on its line. */
4273
+ interface Lift {
4274
+ /** How far above the line's baseline its baseline is — above the top
4275
+ * or bottom box's, under one of those. */
4276
+ raise: number;
4277
+ /** The room its own inline box takes about its baseline. */
4278
+ ascent: number;
4279
+ descent: number;
4280
+ /** A `top` or `bottom` box it is in, aligned with the line box's edge
4281
+ * rather than with anything's baseline, and the room that box's own
4282
+ * font takes: its content on the line adds to it (`finishLine`). */
4283
+ edge: {
4284
+ box: Box;
4285
+ to: 'top' | 'bottom';
4286
+ ascent: number;
4287
+ descent: number;
4288
+ } | null;
4289
+ }
4290
+
4291
+ /**
4292
+ * The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
4293
+ * 10.8.1): each from its parent's baseline, so a box inside a raised box is
4294
+ * raised with it. `sub` and `super` by a fifth and a third of the parent's
4295
+ * font size and a pixel, as browsers set them; `text-top` and `text-bottom`
4296
+ * to the edges of the parent's font; `middle` by its middle to half the
4297
+ * parent's x-height above its baseline; a length by itself and a
4298
+ * percentage of the box's own line height. Worked out once a box a pass.
4299
+ */
4300
+ class Lifts {
4301
+ private _boxes = new Map<
4302
+ Box,
4303
+ { raise: number; edge: Box | null; lead: boolean }
4304
+ >();
4305
+
4306
+ constructor(
4307
+ private readonly fonts: FontsLike,
4308
+ private readonly block: ComputedStyle,
4309
+ /** A box's style on the lines these are for: its own, or the one it
4310
+ * has on a first line (`firstLineSetting`). */
4311
+ private readonly styleOf: (box: Box) => ComputedStyle = ownStyle,
4312
+ ) {}
4313
+
4314
+ /** Where a text goes, or null for one that is on the line's baseline and
4315
+ * in no box with a line height of its own. */
4316
+ of(text: LineText): Lift | null {
4317
+ const owner = text.spans.boxAt?.(text.layoutStart);
4318
+ const parent = owner?.parent;
4319
+ if (!owner || !parent || parent.kind !== 'inline') return null;
4320
+ const { raise, edge, lead } = this._box(parent);
4321
+ if (!raise && !edge && !lead) return null;
4322
+ // the room of every box whose text is in it: no raised box starts or
4323
+ // ends inside a fragment, but one it holds may be set larger
4324
+ const own = { ...strutOf(this.fonts, this.styleOf(owner)) };
4325
+ // and of the inline boxes around it, each about its own baseline: each
4326
+ // is on the line with its line height, which the text's may not reach.
4327
+ // Not only where one sets a line height of its own: a raised `<sup>`
4328
+ // holding a smaller `<a>` — every footnote mark — is as tall on the
4329
+ // line as its own font and line height make it (CSS 2.1 10.8)
4330
+ if (lead || raise) {
4331
+ for (let at: Box | null = parent; at?.kind === 'inline'; at = at.parent) {
4332
+ const box = this._box(at);
4333
+ const room = strutOf(this.fonts, this.styleOf(at));
4334
+ own.ascent = Math.max(own.ascent, room.ascent + box.raise - raise);
4335
+ own.descent = Math.max(own.descent, room.descent - box.raise + raise);
4336
+ if (box.edge === at) break;
4337
+ }
4338
+ }
4339
+ const natural = text.layout.lines[text.layoutLine];
4340
+ let seen = this.styleOf(owner);
4341
+ for (const run of natural?.runs ?? []) {
4342
+ const at = text.spans.boxAt?.(run.start);
4343
+ const style = at && this.styleOf(at);
4344
+ if (!style || style === seen) continue;
4345
+ seen = style;
4346
+ const room = strutOf(this.fonts, style);
4347
+ own.ascent = Math.max(own.ascent, room.ascent);
4348
+ own.descent = Math.max(own.descent, room.descent);
4349
+ }
4350
+ let to: Lift['edge'] = null;
4351
+ if (edge) {
4352
+ const room = strutOf(this.fonts, this.styleOf(edge));
4353
+ to = {
4354
+ box: edge,
4355
+ to: this.styleOf(edge).verticalAlign === 'top' ? 'top' : 'bottom',
4356
+ ascent: room.ascent,
4357
+ descent: room.descent,
4358
+ };
4359
+ }
4360
+ return { raise, ascent: own.ascent, descent: own.descent, edge: to };
4361
+ }
4362
+
4363
+ /** Where an inline box's own room is about the line's baseline — its
4364
+ * face at its line height, raised as it is — or null for one on the
4365
+ * baseline, or one the line's edge sets. */
4366
+ ofBox(box: Box): { raise: number; ascent: number; descent: number } | null {
4367
+ const { raise, edge, lead } = this._box(box);
4368
+ if (edge) return null;
4369
+ if (!raise && !lead && !tallerStrut(this.fonts, this.block, box)) {
4370
+ return null;
4371
+ }
4372
+ const room = strutOf(this.fonts, this.styleOf(box));
4373
+ return { raise, ascent: room.ascent, descent: room.descent };
4374
+ }
4375
+
4376
+ /** The `top` or `bottom` box a box is in, itself included, and how far
4377
+ * its baseline is raised from that box's; null outside one. */
4378
+ edgeOf(box: Box): { edge: Box; raise: number } | null {
4379
+ const lift = this._box(box);
4380
+ return lift.edge ? { edge: lift.edge, raise: lift.raise } : null;
4381
+ }
4382
+
4383
+ private _box(box: Box): { raise: number; edge: Box | null; lead: boolean } {
4384
+ if (box.kind !== 'inline' || !box.parent) return NO_LIFT;
4385
+ const known = this._boxes.get(box);
4386
+ if (known) return known;
4387
+ const va = this.styleOf(box).verticalAlign;
4388
+ const own = ownsLeading(this.fonts, this.block, this.styleOf(box));
4389
+ let lift: { raise: number; edge: Box | null; lead: boolean };
4390
+ if (va === 'top' || va === 'bottom') {
4391
+ // what is in it is raised from its baseline, which the line's edge
4392
+ // sets, whatever is around it
4393
+ lift = { raise: 0, edge: box, lead: own };
4394
+ } else {
4395
+ const up = this._box(box.parent);
4396
+ lift = {
4397
+ raise: up.raise + this._own(box, box.parent),
4398
+ edge: up.edge,
4399
+ lead: own || up.lead,
4400
+ };
4401
+ if (lift.raise && !lift.edge) BOX_RAISES.set(box, lift.raise);
4402
+ }
4403
+ this._boxes.set(box, lift);
4404
+ return lift;
4405
+ }
4406
+
4407
+ /** How far a box's own `vertical-align` raises it from its parent's
4408
+ * baseline. */
4409
+ private _own(box: Box, parent: Box): number {
4410
+ const va = this.styleOf(box).verticalAlign;
4411
+ const size = this.styleOf(parent).fontSize;
4412
+ switch (va) {
4413
+ case 'baseline':
4414
+ case 'top':
4415
+ case 'bottom':
4416
+ return 0;
4417
+ case 'sub':
4418
+ return -(size / 5 + size / 16);
4419
+ case 'super':
4420
+ return size / 3 + size / 16;
4421
+ case 'text-top':
4422
+ return (
4423
+ faceExtent(this.fonts, this.styleOf(parent)).ascent -
4424
+ strutOf(this.fonts, this.styleOf(box)).ascent
4425
+ );
4426
+ case 'text-bottom':
4427
+ return (
4428
+ strutOf(this.fonts, this.styleOf(box)).descent -
4429
+ faceExtent(this.fonts, this.styleOf(parent)).descent
4430
+ );
4431
+ case 'middle': {
4432
+ const own = strutOf(this.fonts, this.styleOf(box));
4433
+ return (
4434
+ xHeightOf(this.fonts, this.styleOf(parent)) / 2 -
4435
+ (own.ascent - own.descent) / 2
4436
+ );
4437
+ }
4438
+ default:
4439
+ return typeof va === 'number'
4440
+ ? va
4441
+ : resolve(va, lineHeightOf(this.fonts, this.styleOf(box)));
4442
+ }
4443
+ }
4444
+ }
4445
+
4446
+ const ownStyle = (box: Box): ComputedStyle => box.style;
4447
+
4448
+ const NO_LIFT = { raise: 0, edge: null, lead: false };
4449
+
4450
+ /**
4451
+ * Whether an inline box's own line height sets its line otherwise than the
4452
+ * one a paragraph's layout gives its text. That layout sets every run at
4453
+ * the block's line height, as a multiple of the run's font's natural one;
4454
+ * CSS gives each inline box its own, and the line box holds them all (CSS
4455
+ * 2.1 10.8.1). So a box whose own is more makes its line taller, and a box
4456
+ * whose own is less makes it shorter than the multiple does, where the
4457
+ * multiple is more than the block's line height too: a larger face under
4458
+ * a `line-height` length, which every inline box inherits as that length —
4459
+ * `font: 11px/15px` on the body and a 14px heading run inline, which the
4460
+ * multiple set 19px tall on a line CSS makes 16.5. One whose own is less
4461
+ * but which the block's line height holds anyway, a `<code>` in a font
4462
+ * with taller natural lines, is left to the one layout: its lines come out
4463
+ * the same, and the line at a time costs a long document dear.
4464
+ *
4465
+ * Asked of every inline box in every paragraph, so it has to cost nothing
4466
+ * for the ones that are nothing: a box with its block's line height, family
4467
+ * and size is past at once, and any other answer is kept on its style.
4468
+ */
4469
+ function ownsLeading(
4470
+ fonts: FontsLike,
4471
+ block: ComputedStyle,
4472
+ style: ComputedStyle,
4473
+ ): boolean {
4474
+ // a bold or an italic face keeps its family's line metrics, and a
4475
+ // paragraph is thick with <strong>, <em> and <a>
4476
+ if (
4477
+ style.lineHeight === block.lineHeight &&
4478
+ style.lineHeightIsLength === block.lineHeightIsLength &&
4479
+ style.fontSize === block.fontSize &&
4480
+ style.fontFamily === block.fontFamily
4481
+ ) {
4482
+ return false;
4483
+ }
4484
+ const known = LEADS.get(style);
4485
+ if (known && known.block === block && known.fonts === fonts) {
4486
+ return known.own;
4487
+ }
4488
+ const multiple =
4489
+ lineHeightMultiplier(fonts, block) * naturalLineHeight(fonts, style);
4490
+ const height = lineHeightOf(fonts, style);
4491
+ const own =
4492
+ height > multiple + 0.5 ||
4493
+ multiple > Math.max(height, lineHeightOf(fonts, block)) + 0.5;
4494
+ LEADS.set(style, { fonts, block, own });
4495
+ return own;
4496
+ }
4497
+
4498
+ /** Whether a text is in an inline box that owns its leading
4499
+ * (`ownsLeading`), below the block it is laid out in. */
4500
+ function inBoxOwningLeading(fonts: FontsLike, block: Box, text: Box): boolean {
4501
+ for (let at = text.parent; at && at !== block; at = at.parent) {
4502
+ if (at.kind !== 'inline') break;
4503
+ if (ownsLeading(fonts, block.style, at.style)) return true;
4504
+ }
4505
+ return false;
4506
+ }
4507
+
4508
+ const LEADS = new WeakMap<
4509
+ ComputedStyle,
4510
+ { fonts: FontsLike; block: ComputedStyle; own: boolean }
4511
+ >();
4512
+
4513
+ /**
4514
+ * Whether an inline box with no text is taller on its line than the
4515
+ * paragraph's own line height: what it holds there, which the engine,
4516
+ * which lays out text, gives no room (CSS 2.1 10.8). A box with text is
4517
+ * given its face's room with the text, at the paragraph's leading, which
4518
+ * `ownsLeading` compares its own line height with; asked of one with none,
4519
+ * the border of an empty `<span>` in a large face was drawn over the text
4520
+ * above its line, which was as short as the paragraph's. Not asked of a
4521
+ * box with text, which the one-layout paths lay out whole: a padded
4522
+ * `<code>` in a paragraph would have been laid out a line at a time.
4523
+ */
4524
+ function tallerStrut(
4525
+ fonts: FontsLike,
4526
+ block: ComputedStyle,
4527
+ box: Box,
4528
+ ): boolean {
4529
+ if (box.subtreeTextEnd > box.subtreeTextStart) return false;
4530
+ const style = box.style;
4531
+ if (
4532
+ style.fontSize === block.fontSize &&
4533
+ style.lineHeight === block.lineHeight
4534
+ ) {
4535
+ return false;
4536
+ }
4537
+ const own = strutOf(fonts, style);
4538
+ const theirs = strutOf(fonts, block);
4539
+ return own.ascent + own.descent > theirs.ascent + theirs.descent + 0.5;
4540
+ }
4541
+
4542
+ /** A style's line height in pixels. */
4543
+ function lineHeightOf(fonts: FontsLike, style: ComputedStyle): number {
4544
+ if (style.lineHeight === 'normal') return naturalLineHeight(fonts, style);
4545
+ return style.lineHeightIsLength
4546
+ ? (style.lineHeight as number)
4547
+ : (style.lineHeight as number) * style.fontSize;
4548
+ }
4549
+
4550
+ /**
4551
+ * A style's x-height, where its font says, or half its size, as `ex` takes
4552
+ * it (CSS 2.1 4.3.2). A face whose OS/2 table is older than version 2 —
4553
+ * DejaVu's — states none, and the engine's answer is then NaN, which is a
4554
+ * number: taken for one, it put an image set `middle` nowhere at all.
4555
+ */
4556
+ function xHeightOf(fonts: FontsLike, style: ComputedStyle): number {
869
4557
  try {
870
- const font = fonts.match(style.fontFamily, {
871
- size: style.fontSize,
872
- weight: style.fontWeight,
873
- style: style.fontStyle,
874
- });
875
- return font.metrics(style.fontSize).lineHeight;
4558
+ const metrics = fonts
4559
+ .match(style.fontFamily, {
4560
+ size: style.fontSize,
4561
+ weight: style.fontWeight,
4562
+ style: style.fontStyle,
4563
+ })
4564
+ .metrics(style.fontSize) as { xHeight?: number | null };
4565
+ const x = metrics.xHeight;
4566
+ if (typeof x === 'number' && x > 0) return x;
876
4567
  } catch {
877
- return style.fontSize * 1.2;
4568
+ // no font to ask
4569
+ }
4570
+ return style.fontSize / 2;
4571
+ }
4572
+
4573
+ /** Whether a line has nothing on it yet. */
4574
+ function isEmpty(open: {
4575
+ x: number;
4576
+ indent: number;
4577
+ texts: unknown[];
4578
+ atomics: unknown[];
4579
+ }) {
4580
+ return open.x === open.indent && !open.texts.length && !open.atomics.length;
4581
+ }
4582
+
4583
+ /** Where a line at `y` next has more room, in the block's own space: the
4584
+ * nearest bottom edge of a float beside it, or null when none is. */
4585
+ function belowFloats(
4586
+ options: InlineOptions,
4587
+ y: number,
4588
+ height: number,
4589
+ ): number | null {
4590
+ const edge = options.floats?.nextEdgeBelow(options.startY + y, height);
4591
+ return edge == null ? null : Math.max(y + 1, edge - options.startY);
4592
+ }
4593
+
4594
+ /**
4595
+ * Whether a line's first word did not fit its room: the line runs past the
4596
+ * room — a line's width leaves out the white space it ends on, which hangs,
4597
+ * so this is the word, kept whole where `overflow-wrap` says so — or it
4598
+ * ends inside a word — between two letters or digits, where no break is
4599
+ * allowed, so only a line too narrow for the word put one there. Ideographs
4600
+ * and kana break between any two, and are not counted as a word's letters.
4601
+ */
4602
+ function tooNarrow(
4603
+ runs: TextRun[],
4604
+ end: number,
4605
+ width: number,
4606
+ room: number,
4607
+ ): boolean {
4608
+ let at = 0;
4609
+ let before = '';
4610
+ let after = '';
4611
+ for (const run of runs) {
4612
+ const text = run.text;
4613
+ if (end > at && end <= at + text.length) before = text[end - at - 1];
4614
+ if (end >= at && end < at + text.length) after = text[end - at];
4615
+ at += text.length;
878
4616
  }
4617
+ if (WORD_CHAR.test(before) && WORD_CHAR.test(after)) return true;
4618
+ return width > room + 0.5 && before !== '';
879
4619
  }
880
4620
 
4621
+ const WORD_CHAR =
4622
+ /^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
4623
+
881
4624
  function bandAt(
882
4625
  options: InlineOptions,
883
4626
  y: number,
884
4627
  height: number,
885
4628
  ): { left: number; right: number } {
886
4629
  if (!options.floats) return { left: 0, right: options.width };
887
- const band = options.floats.bandAt(options.startY + y, height);
4630
+ // Floats narrow the block's own line, where they reach into it; the
4631
+ // formatting context's edges are not this block's, and a block wider than
4632
+ // its context — a fixed width in a narrow body — keeps its lines whole.
4633
+ const band = options.floats.bandAt(
4634
+ options.startY + y,
4635
+ height,
4636
+ options.originX,
4637
+ options.originX + options.width,
4638
+ );
888
4639
  return {
889
4640
  left: Math.max(0, band.left - options.originX),
890
4641
  right: Math.min(options.width, band.right - options.originX),