postext 1.29.2 → 1.30.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 (275) hide show
  1. package/dist/__tests__/arabicLocale.test.js +5 -5
  2. package/dist/__tests__/arabicLocale.test.js.map +1 -1
  3. package/dist/__tests__/bundle/balloonPlacementPin.test.js +5 -4
  4. package/dist/__tests__/bundle/balloonPlacementPin.test.js.map +1 -1
  5. package/dist/__tests__/bundle/cjkRulesPin.test.js +8 -6
  6. package/dist/__tests__/bundle/cjkRulesPin.test.js.map +1 -1
  7. package/dist/__tests__/bundle/designOverflowPin.test.js +1 -1
  8. package/dist/__tests__/bundle/flowColumnsPin.test.js +4 -3
  9. package/dist/__tests__/bundle/flowColumnsPin.test.js.map +1 -1
  10. package/dist/__tests__/bundle/gridBalancingPin.test.js +5 -4
  11. package/dist/__tests__/bundle/gridBalancingPin.test.js.map +1 -1
  12. package/dist/__tests__/bundle/gridBreakRulesPin.test.js +14 -10
  13. package/dist/__tests__/bundle/gridBreakRulesPin.test.js.map +1 -1
  14. package/dist/__tests__/bundle/indexGroupSplitPin.test.js +4 -3
  15. package/dist/__tests__/bundle/indexGroupSplitPin.test.js.map +1 -1
  16. package/dist/__tests__/bundle/looseOrderPin.test.js +5 -4
  17. package/dist/__tests__/bundle/looseOrderPin.test.js.map +1 -1
  18. package/dist/__tests__/bundle/refLabelBreaksPin.test.js +5 -4
  19. package/dist/__tests__/bundle/refLabelBreaksPin.test.js.map +1 -1
  20. package/dist/__tests__/bundle/scriptContextPin.test.js +6 -5
  21. package/dist/__tests__/bundle/scriptContextPin.test.js.map +1 -1
  22. package/dist/__tests__/bundle/shapingLanguagePin.test.js +142 -46
  23. package/dist/__tests__/bundle/shapingLanguagePin.test.js.map +1 -1
  24. package/dist/__tests__/bundle/taskBoxPin.test.js +1 -1
  25. package/dist/__tests__/bundle/trailingStepPin.test.js +4 -3
  26. package/dist/__tests__/bundle/trailingStepPin.test.js.map +1 -1
  27. package/dist/__tests__/bundle/wideFloatStretchPin.test.js +4 -3
  28. package/dist/__tests__/bundle/wideFloatStretchPin.test.js.map +1 -1
  29. package/dist/__tests__/bundle.test.js +1 -1
  30. package/dist/__tests__/cellText.test.d.ts +2 -0
  31. package/dist/__tests__/cellText.test.d.ts.map +1 -0
  32. package/dist/__tests__/cellText.test.js +281 -0
  33. package/dist/__tests__/cellText.test.js.map +1 -0
  34. package/dist/__tests__/chineseLocale.test.js +13 -11
  35. package/dist/__tests__/chineseLocale.test.js.map +1 -1
  36. package/dist/__tests__/cjk/japaneseHtml.test.js +3 -3
  37. package/dist/__tests__/cjk/japaneseHtml.test.js.map +1 -1
  38. package/dist/__tests__/cjk/japaneseRuby.test.js +1 -1
  39. package/dist/__tests__/cjk/japaneseRuby.test.js.map +1 -1
  40. package/dist/__tests__/cjk/japaneseSegmentLanguage.test.js +3 -3
  41. package/dist/__tests__/cjk/japaneseSegmentLanguage.test.js.map +1 -1
  42. package/dist/__tests__/codeBlocks.test.js +1 -1
  43. package/dist/__tests__/contextText.test.d.ts +2 -0
  44. package/dist/__tests__/contextText.test.d.ts.map +1 -0
  45. package/dist/__tests__/contextText.test.js +371 -0
  46. package/dist/__tests__/contextText.test.js.map +1 -0
  47. package/dist/__tests__/design/designTextRender.test.js +8 -1
  48. package/dist/__tests__/design/designTextRender.test.js.map +1 -1
  49. package/dist/__tests__/exports.test.js +6 -0
  50. package/dist/__tests__/exports.test.js.map +1 -1
  51. package/dist/__tests__/fontUsage.test.js +40 -0
  52. package/dist/__tests__/fontUsage.test.js.map +1 -1
  53. package/dist/__tests__/hardBreaks.test.js +1 -1
  54. package/dist/__tests__/headingInlineMarks.test.js +1 -1
  55. package/dist/__tests__/html-backend.test.js +6 -2
  56. package/dist/__tests__/html-backend.test.js.map +1 -1
  57. package/dist/__tests__/japaneseNotes.test.js +4 -2
  58. package/dist/__tests__/japaneseNotes.test.js.map +1 -1
  59. package/dist/__tests__/lineEndPieces.test.d.ts +2 -0
  60. package/dist/__tests__/lineEndPieces.test.d.ts.map +1 -0
  61. package/dist/__tests__/lineEndPieces.test.js +374 -0
  62. package/dist/__tests__/lineEndPieces.test.js.map +1 -0
  63. package/dist/__tests__/mathSizeMigration.test.js +2 -2
  64. package/dist/__tests__/pipeline/calloutInlineResourceGap.test.js +1 -1
  65. package/dist/__tests__/pipeline/calloutSplitMinLinesChild.test.js +1 -1
  66. package/dist/__tests__/pipeline/colonListRoom.test.js +1 -1
  67. package/dist/__tests__/pipeline/compoundBreaks.test.js +1 -1
  68. package/dist/__tests__/pipeline/dashBreaks.test.js +1 -1
  69. package/dist/__tests__/pipeline/headingSplitOrphans.test.js +1 -1
  70. package/dist/__tests__/pipeline/inlineFigureSpace.test.js +1 -1
  71. package/dist/__tests__/pipeline/paragraphContainerTail.test.js +1 -1
  72. package/dist/__tests__/pipeline/raggedOptimal.test.js +1 -1
  73. package/dist/__tests__/plainGeminateHead.test.d.ts +2 -0
  74. package/dist/__tests__/plainGeminateHead.test.d.ts.map +1 -0
  75. package/dist/__tests__/plainGeminateHead.test.js +92 -0
  76. package/dist/__tests__/plainGeminateHead.test.js.map +1 -0
  77. package/dist/__tests__/presets/showcaseShapingLanguage.test.d.ts +2 -0
  78. package/dist/__tests__/presets/showcaseShapingLanguage.test.d.ts.map +1 -0
  79. package/dist/__tests__/presets/showcaseShapingLanguage.test.js +52 -0
  80. package/dist/__tests__/presets/showcaseShapingLanguage.test.js.map +1 -0
  81. package/dist/__tests__/reshapedTracking.test.d.ts +2 -0
  82. package/dist/__tests__/reshapedTracking.test.d.ts.map +1 -0
  83. package/dist/__tests__/reshapedTracking.test.js +358 -0
  84. package/dist/__tests__/reshapedTracking.test.js.map +1 -0
  85. package/dist/__tests__/rtl/designRunsPainted.test.d.ts +2 -0
  86. package/dist/__tests__/rtl/designRunsPainted.test.d.ts.map +1 -0
  87. package/dist/__tests__/rtl/designRunsPainted.test.js +184 -0
  88. package/dist/__tests__/rtl/designRunsPainted.test.js.map +1 -0
  89. package/dist/__tests__/rtl/designTextPaint.test.js +5 -1
  90. package/dist/__tests__/rtl/designTextPaint.test.js.map +1 -1
  91. package/dist/__tests__/rtl/htmlRtl.test.js +3 -3
  92. package/dist/__tests__/rtl/htmlRtl.test.js.map +1 -1
  93. package/dist/__tests__/rtl/listMarkerDirection.test.js +28 -28
  94. package/dist/__tests__/rtl/listMarkerDirection.test.js.map +1 -1
  95. package/dist/__tests__/rtl/tocNumberPainted.test.d.ts +2 -0
  96. package/dist/__tests__/rtl/tocNumberPainted.test.d.ts.map +1 -0
  97. package/dist/__tests__/rtl/tocNumberPainted.test.js +113 -0
  98. package/dist/__tests__/rtl/tocNumberPainted.test.js.map +1 -0
  99. package/dist/__tests__/scriptContext.test.js +141 -1
  100. package/dist/__tests__/scriptContext.test.js.map +1 -1
  101. package/dist/__tests__/shapingLanguage.test.js +54 -41
  102. package/dist/__tests__/shapingLanguage.test.js.map +1 -1
  103. package/dist/__tests__/trackedLetters.test.d.ts +2 -0
  104. package/dist/__tests__/trackedLetters.test.d.ts.map +1 -0
  105. package/dist/__tests__/trackedLetters.test.js +380 -0
  106. package/dist/__tests__/trackedLetters.test.js.map +1 -0
  107. package/dist/__tests__/trackedLigatures.test.js +17 -5
  108. package/dist/__tests__/trackedLigatures.test.js.map +1 -1
  109. package/dist/__tests__/verseLines.test.js +1 -1
  110. package/dist/__tests__/wordCacheOrder.test.d.ts +2 -0
  111. package/dist/__tests__/wordCacheOrder.test.d.ts.map +1 -0
  112. package/dist/__tests__/wordCacheOrder.test.js +352 -0
  113. package/dist/__tests__/wordCacheOrder.test.js.map +1 -0
  114. package/dist/__tests__/wordPieces.test.d.ts +2 -0
  115. package/dist/__tests__/wordPieces.test.d.ts.map +1 -0
  116. package/dist/__tests__/wordPieces.test.js +762 -0
  117. package/dist/__tests__/wordPieces.test.js.map +1 -0
  118. package/dist/bidi.d.ts +12 -0
  119. package/dist/bidi.d.ts.map +1 -1
  120. package/dist/bidi.js +23 -0
  121. package/dist/bidi.js.map +1 -1
  122. package/dist/bundle/configVersion.d.ts +49 -23
  123. package/dist/bundle/configVersion.d.ts.map +1 -1
  124. package/dist/bundle/configVersion.js +54 -25
  125. package/dist/bundle/configVersion.js.map +1 -1
  126. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  127. package/dist/canvas-backend/blockRender.js +58 -27
  128. package/dist/canvas-backend/blockRender.js.map +1 -1
  129. package/dist/canvas-backend/cellText.d.ts +12 -0
  130. package/dist/canvas-backend/cellText.d.ts.map +1 -0
  131. package/dist/canvas-backend/cellText.js +114 -0
  132. package/dist/canvas-backend/cellText.js.map +1 -0
  133. package/dist/canvas-backend/chip.d.ts.map +1 -1
  134. package/dist/canvas-backend/chip.js +2 -1
  135. package/dist/canvas-backend/chip.js.map +1 -1
  136. package/dist/canvas-backend/contextState.d.ts +7 -0
  137. package/dist/canvas-backend/contextState.d.ts.map +1 -1
  138. package/dist/canvas-backend/contextState.js +7 -0
  139. package/dist/canvas-backend/contextState.js.map +1 -1
  140. package/dist/canvas-backend/contextText.d.ts +76 -0
  141. package/dist/canvas-backend/contextText.d.ts.map +1 -0
  142. package/dist/canvas-backend/contextText.js +102 -0
  143. package/dist/canvas-backend/contextText.js.map +1 -0
  144. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  145. package/dist/canvas-backend/headerFooter.js +8 -1
  146. package/dist/canvas-backend/headerFooter.js.map +1 -1
  147. package/dist/canvas-backend/index.d.ts.map +1 -1
  148. package/dist/canvas-backend/index.js +19 -13
  149. package/dist/canvas-backend/index.js.map +1 -1
  150. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  151. package/dist/canvas-backend/renderResourceBlock.js +12 -6
  152. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  153. package/dist/canvas-backend/scriptText.d.ts.map +1 -1
  154. package/dist/canvas-backend/scriptText.js +3 -2
  155. package/dist/canvas-backend/scriptText.js.map +1 -1
  156. package/dist/canvas-backend/segmentText.d.ts.map +1 -1
  157. package/dist/canvas-backend/segmentText.js +8 -2
  158. package/dist/canvas-backend/segmentText.js.map +1 -1
  159. package/dist/canvas-backend/trackedText.d.ts +75 -1
  160. package/dist/canvas-backend/trackedText.d.ts.map +1 -1
  161. package/dist/canvas-backend/trackedText.js +185 -22
  162. package/dist/canvas-backend/trackedText.js.map +1 -1
  163. package/dist/canvas-backend/verticalText.d.ts +9 -0
  164. package/dist/canvas-backend/verticalText.d.ts.map +1 -1
  165. package/dist/canvas-backend/verticalText.js +29 -7
  166. package/dist/canvas-backend/verticalText.js.map +1 -1
  167. package/dist/canvas-backend/wordPieces.d.ts +31 -0
  168. package/dist/canvas-backend/wordPieces.d.ts.map +1 -0
  169. package/dist/canvas-backend/wordPieces.js +132 -0
  170. package/dist/canvas-backend/wordPieces.js.map +1 -0
  171. package/dist/canvas-backend/wordRuns.d.ts +3 -1
  172. package/dist/canvas-backend/wordRuns.d.ts.map +1 -1
  173. package/dist/canvas-backend/wordRuns.js +10 -6
  174. package/dist/canvas-backend/wordRuns.js.map +1 -1
  175. package/dist/comics/lettering/__tests__/fitAsPainted.test.d.ts +2 -0
  176. package/dist/comics/lettering/__tests__/fitAsPainted.test.d.ts.map +1 -0
  177. package/dist/comics/lettering/__tests__/fitAsPainted.test.js +97 -0
  178. package/dist/comics/lettering/__tests__/fitAsPainted.test.js.map +1 -0
  179. package/dist/comics/lettering/shape-text.d.ts.map +1 -1
  180. package/dist/comics/lettering/shape-text.js +39 -11
  181. package/dist/comics/lettering/shape-text.js.map +1 -1
  182. package/dist/defaults/bodyText.d.ts.map +1 -1
  183. package/dist/defaults/bodyText.js +6 -5
  184. package/dist/defaults/bodyText.js.map +1 -1
  185. package/dist/design/bidiText.d.ts +45 -6
  186. package/dist/design/bidiText.d.ts.map +1 -1
  187. package/dist/design/bidiText.js +78 -10
  188. package/dist/design/bidiText.js.map +1 -1
  189. package/dist/design/cjkText.d.ts +3 -0
  190. package/dist/design/cjkText.d.ts.map +1 -1
  191. package/dist/design/cjkText.js +1 -1
  192. package/dist/design/cjkText.js.map +1 -1
  193. package/dist/design/layout.d.ts.map +1 -1
  194. package/dist/design/layout.js +36 -5
  195. package/dist/design/layout.js.map +1 -1
  196. package/dist/design/richText.d.ts +9 -1
  197. package/dist/design/richText.d.ts.map +1 -1
  198. package/dist/design/richText.js +8 -2
  199. package/dist/design/richText.js.map +1 -1
  200. package/dist/fonts/usage.d.ts +2 -0
  201. package/dist/fonts/usage.d.ts.map +1 -1
  202. package/dist/fonts/usage.js +18 -0
  203. package/dist/fonts/usage.js.map +1 -1
  204. package/dist/html-backend.d.ts.map +1 -1
  205. package/dist/html-backend.js +664 -84
  206. package/dist/html-backend.js.map +1 -1
  207. package/dist/index.d.ts +3 -1
  208. package/dist/index.d.ts.map +1 -1
  209. package/dist/index.js +4 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  212. package/dist/knuthPlass/breakpoints.js +4 -2
  213. package/dist/knuthPlass/breakpoints.js.map +1 -1
  214. package/dist/knuthPlass/pretextAdapter.d.ts +6 -3
  215. package/dist/knuthPlass/pretextAdapter.d.ts.map +1 -1
  216. package/dist/knuthPlass/pretextAdapter.js +11 -3
  217. package/dist/knuthPlass/pretextAdapter.js.map +1 -1
  218. package/dist/knuthPlass/richAdapter.d.ts +13 -1
  219. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  220. package/dist/knuthPlass/richAdapter.js +53 -5
  221. package/dist/knuthPlass/richAdapter.js.map +1 -1
  222. package/dist/knuthPlass/tracking.d.ts.map +1 -1
  223. package/dist/knuthPlass/tracking.js +3 -2
  224. package/dist/knuthPlass/tracking.js.map +1 -1
  225. package/dist/knuthPlass/types.d.ts +6 -0
  226. package/dist/knuthPlass/types.d.ts.map +1 -1
  227. package/dist/locale.d.ts +21 -17
  228. package/dist/locale.d.ts.map +1 -1
  229. package/dist/locale.js +23 -19
  230. package/dist/locale.js.map +1 -1
  231. package/dist/measure/canvas.d.ts +21 -0
  232. package/dist/measure/canvas.d.ts.map +1 -1
  233. package/dist/measure/canvas.js +165 -85
  234. package/dist/measure/canvas.js.map +1 -1
  235. package/dist/measure/cjkCompose.d.ts.map +1 -1
  236. package/dist/measure/cjkCompose.js +31 -5
  237. package/dist/measure/cjkCompose.js.map +1 -1
  238. package/dist/measure/plain.d.ts.map +1 -1
  239. package/dist/measure/plain.js +31 -6
  240. package/dist/measure/plain.js.map +1 -1
  241. package/dist/measure/pretextLanguage.d.ts.map +1 -1
  242. package/dist/measure/pretextLanguage.js +49 -7
  243. package/dist/measure/pretextLanguage.js.map +1 -1
  244. package/dist/measure/rich.d.ts +18 -1
  245. package/dist/measure/rich.d.ts.map +1 -1
  246. package/dist/measure/rich.js +200 -29
  247. package/dist/measure/rich.js.map +1 -1
  248. package/dist/measure/script.d.ts +17 -1
  249. package/dist/measure/script.d.ts.map +1 -1
  250. package/dist/measure/script.js +17 -1
  251. package/dist/measure/script.js.map +1 -1
  252. package/dist/measure/trackedLigatures.d.ts +59 -0
  253. package/dist/measure/trackedLigatures.d.ts.map +1 -0
  254. package/dist/measure/trackedLigatures.js +144 -0
  255. package/dist/measure/trackedLigatures.js.map +1 -0
  256. package/dist/pipeline/build.d.ts.map +1 -1
  257. package/dist/pipeline/build.js +4 -3
  258. package/dist/pipeline/build.js.map +1 -1
  259. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  260. package/dist/pipeline/calloutLayout.js +26 -7
  261. package/dist/pipeline/calloutLayout.js.map +1 -1
  262. package/dist/pipeline/lists.d.ts +13 -4
  263. package/dist/pipeline/lists.d.ts.map +1 -1
  264. package/dist/pipeline/lists.js +49 -12
  265. package/dist/pipeline/lists.js.map +1 -1
  266. package/dist/pipeline/toc.d.ts.map +1 -1
  267. package/dist/pipeline/toc.js +33 -9
  268. package/dist/pipeline/toc.js.map +1 -1
  269. package/dist/types.d.ts +35 -26
  270. package/dist/types.d.ts.map +1 -1
  271. package/dist/wordPieces.d.ts +120 -0
  272. package/dist/wordPieces.d.ts.map +1 -0
  273. package/dist/wordPieces.js +319 -0
  274. package/dist/wordPieces.js.map +1 -0
  275. package/package.json +1 -1
@@ -7,14 +7,17 @@ import { registeredFontSyncProvider } from './svg/fontRegistry';
7
7
  import { svgPictureFontWarning } from './svg/image';
8
8
  import { lineInkExtent, lineTrailingTracking } from './lineInk';
9
9
  import { CHARACTER_GRID_COLOR, cjkGridCells } from './pipeline/cjkGrid';
10
- import { isJapaneseLanguage, renderLangOf, shapingLanguageOf } from './locale';
10
+ import { isJapaneseLanguage, renderLangOf, shapingLanguageOf, shapingTagOf } from './locale';
11
11
  import { hasCJK } from './measure/cjk';
12
- import { markerRuns } from './bidi';
12
+ import { arabicMarkNeeded, needsBidi } from './bidi';
13
13
  import { joiningScriptIn } from './measure/joining';
14
- import { scriptContextUntracked, scriptMark } from './measure/script';
14
+ import { faceReadsScript, isLetterless, rtlTextWidth, scriptContextUntracked, scriptMark, scriptTextWidth } from './measure/script';
15
15
  import { DEFAULT_CENTRAL_BASELINE, verticalFlowOf } from './vdt';
16
16
  import { holdsTurnedMark, segmentOrientation, verticalRuns } from './writingMode';
17
- import { graphemesOf } from './measure/graphemes';
17
+ import { graphemeCount, graphemesOf } from './measure/graphemes';
18
+ import { measureSpacedWidth, measureTextWidth, withMeasureLanguage } from './measure/canvas';
19
+ import { trackedLigaturePieces } from './measure/trackedLigatures';
20
+ import { cellsOf, lineWordSegmentsInOrder, sameSetWidth } from './wordPieces';
18
21
  import { fontFamilyOf } from './measure/vertical';
19
22
  import { lineMarksHtml, rubyHtml, verticalLineMarksHtml, warichuHtml, sideMarkerHtml, RT_OPEN, RUBY_OPEN, rubyGroupOf, kuntenHtml, verticalKuntenTateHtml } from './htmlAnnotations';
20
23
  import { playMarkTriangle, qrModuleRuns } from './pipeline/videoOverlay';
@@ -67,8 +70,53 @@ const SCRIPT_MARK_DECL = 'font-size:0;-webkit-user-select:none;user-select:none;
67
70
  const CJK_TRACKED_TEXT_DECL = "text-spacing-trim:space-all;text-autospace:no-autospace;font-feature-settings:'chws' 0,'halt' 0,'vchw' 0,'liga' 1,'clig' 1;";
68
71
  /** {@link CJK_FEATURES_DECL} for a box of a tracked text. */
69
72
  const CJK_TRACKED_FEATURES_DECL = "font-feature-settings:'chws' 0,'halt' 0,'vchw' 0,'liga' 1,'clig' 1;";
73
+ /**
74
+ * Whether the document being written is shaped in its own language
75
+ * (`bodyText.shapingLanguage: 'document'`, #770) and something measures:
76
+ * the rules below that follow its measure apply, and a document that
77
+ * keeps its host's language is written as it was. Set by
78
+ * {@link renderToHtmlIndexed} while it writes its pages.
79
+ *
80
+ * The first of them: a ligature of a tracked text takes the tracking of
81
+ * each of its letters. The layout counts the tracking once a letter. With
82
+ * the ligatures asked for as features ({@link TRACKED_LIGATURES_DECL}) the
83
+ * browser gives a ligature one tracking, as one glyph, and the word ends
84
+ * a tracking short of its box for each letter joined; in a face whose
85
+ * ligature is as wide as its letters the canvas sets the letters apart,
86
+ * each tracked, and the box asked for the ligature. Chrome 154 at 40 px
87
+ * with 0.4 px a letter, against the width measured: `office` in Source
88
+ * Serif 4 −0.8 px, `first` in Geist −0.4 px (on the canvas 0). So such a
89
+ * document asks for no feature: its tracked text is shaped as the canvas
90
+ * shapes it under the letter-spacing, and a word that would lose a
91
+ * ligature to it is written in the canvas's pieces
92
+ * ({@link trackedWordHtml}): −0.003 and +0.007 px for the same two words,
93
+ * 0.063 px at most over 154 words of seven faces.
94
+ */
95
+ let ownShaping = false;
96
+ /** {@link TRACKED_LIGATURES_DECL} where a tracked box asks for its
97
+ * ligatures, nothing where its words are written in pieces
98
+ * ({@link ownShaping}). */
99
+ const ligaturesDecl = () => (ownShaping ? '' : TRACKED_LIGATURES_DECL);
70
100
  /** {@link CJK_TEXT_DECL} of a box, with its ligatures when it is `tracked`. */
71
- const cjkTextDecl = (tracked) => (tracked ? CJK_TRACKED_TEXT_DECL : CJK_TEXT_DECL);
101
+ const cjkTextDecl = (tracked) => (tracked && !ownShaping ? CJK_TRACKED_TEXT_DECL : CJK_TEXT_DECL);
102
+ /** {@link CJK_FEATURES_DECL} of a box in another face, with its ligatures
103
+ * when it is `tracked`. */
104
+ const cjkFeaturesDecl = (tracked) => (tracked && !ownShaping ? CJK_TRACKED_FEATURES_DECL : CJK_FEATURES_DECL);
105
+ /**
106
+ * The box of the cells of a line of the CJK composer (#770). The composer
107
+ * measures each Chinese or Japanese character alone and a line carries
108
+ * the cells of one style as one segment (`cellsOf`); set in one box, the
109
+ * browser kerns inside it: a kana against the mark after it, a kana
110
+ * against a kana. Chrome 154, thousandths of the em under `lang="ja"`:
111
+ * Noto Sans JP `す。` −100, `れ、` +11; Noto Serif JP `す。` −130; and
112
+ * it kerns more pairs than the canvas (21 of 41 texts against 13 in Noto
113
+ * Sans JP). With the kerning off the box is as wide as its cells (41 texts,
114
+ * the two faces, two languages, at 7.5 and 10 px: none differs), and its
115
+ * text stays one run for a selection, a ruby or a link. After the `font`
116
+ * shorthand, which resets it. A Western run was measured with its kerning
117
+ * and keeps it.
118
+ */
119
+ const CELLS_DECL = 'font-kerning:none;';
72
120
  /** Whether a line box carries {@link CJK_TEXT_DECL}: a line of the CJK
73
121
  * composer, which it measured character by character, or a line with no
74
122
  * segments (one span) holding CJK text. A word-by-word line holding CJK
@@ -104,10 +152,13 @@ function segmentBox(seg, inherited, rootLang, tracking, font, color, paint) {
104
152
  const decl = tracking !== 0 && (joiningScriptIn(seg.text) || scriptContextUntracked(seg.scriptContext)) ? 'letter-spacing:0;' : '';
105
153
  const html = seg.runs && seg.runs.length > 0 ? wordRunsHtml(seg, font, color, paint)
106
154
  : seg.kashida && seg.kashida.length > 0 ? kashidaTextHtml(seg.text, seg.kashida)
107
- : seg.scriptContext !== undefined ? scriptTextHtml(seg.text, seg.scriptContext, seg.rtl === true) : undefined;
108
- if (!attrs && !decl && html === undefined)
155
+ : seg.scriptContext !== undefined && !setsAlone(seg, font, tracking) ? scriptTextHtml(seg.text, seg.scriptContext, seg.rtl === true) : undefined;
156
+ // A tracked word is written in the canvas's pieces where it holds a
157
+ // ligature (#770); `font` is the CSS value, which the canvas takes too.
158
+ const tracked = ownShaping && tracking !== 0 && !decl && html === undefined ? { font, tracking, ...(seg.lang !== undefined ? { lang: seg.lang } : {}) } : undefined;
159
+ if (!attrs && !decl && html === undefined && !tracked)
109
160
  return PLAIN_BOX;
110
- return html === undefined ? { attrs, decl } : { attrs, decl, html };
161
+ return { attrs, decl, ...(html !== undefined ? { html } : {}), ...(tracked ? { tracked } : {}) };
111
162
  }
112
163
  /**
113
164
  * A word with no letter set in the script the layout measured it in
@@ -126,6 +177,166 @@ function scriptTextHtml(text, script, rtl) {
126
177
  const hidden = (mark) => (mark === '' ? '' : `<span aria-hidden="true" style="${SCRIPT_MARK_DECL}">${esc(mark)}</span>`);
127
178
  return hidden(lead) + esc(text) + hidden(trail);
128
179
  }
180
+ /**
181
+ * Whether a doubled dash or ellipsis given the forms of Han text
182
+ * (`scriptContext: 'Hani'`) is set with no mark beside it (#770): where
183
+ * the canvas that measured it set it alone as wide as its place.
184
+ *
185
+ * The mark of the Han script is an ideograph, and Chrome's canvas cuts a
186
+ * string before a Han character that follows another one: the word
187
+ * beside the mark is shaped alone there, in no script, and the mark
188
+ * changes nothing. The browser's layout shapes the two as one run of Han
189
+ * text, where the face may answer with the form of the document's
190
+ * language. Chrome 154, Noto Serif TC at 14.58 px under `zh-Hant`: `——`
191
+ * is 24.67 px on the canvas with the mark or without, 24.67 px alone in a
192
+ * box, and 29.17 px in a box with the mark (the full-width dash of the
193
+ * face's `locl`). So the box takes the word alone whenever the measurer
194
+ * says that is what the layout holds; asked in the language the text is
195
+ * shaped in ({@link shapedText}), and the mark stays where that is not
196
+ * known.
197
+ */
198
+ function setsAlone(seg, font, tracking) {
199
+ const shaped = shapedText;
200
+ if (!shaped || seg.scriptContext !== 'Hani')
201
+ return false;
202
+ const alone = withMeasureLanguage(seg.lang !== undefined ? shapingTagOf(seg.lang) : shaped.lang, () => measureTextWidth(seg.text, font));
203
+ return sameSetWidth(alone + (tracking === 0 ? 0 : tracking * graphemeCount(seg.text)), seg.width);
204
+ }
205
+ /**
206
+ * A run of design text with no letter, set in the forms the canvas
207
+ * painted it in (#770), or undefined when it is written as it is.
208
+ *
209
+ * A word of body text with no letter carries the script it was measured
210
+ * in (`scriptContext`, #725). A design text carries none: its runs are
211
+ * measured as the canvas paints them, each alone, and a current Chrome
212
+ * gives such a run the face's Latin forms there, while the browser's
213
+ * layout gives a run with no letter the default ones. Chrome 154, Amiri
214
+ * at 25 px: the `(` that opens a date set right to left is 6.38 px on the
215
+ * canvas and 11.45 px in a box, alone or beside the Arabic text; with a
216
+ * Latin mark of no size after it the box is 6.38 px wide. So where the
217
+ * face draws such characters by script and the run is as wide as the
218
+ * canvas sets it in Latin forms (`width` px, `tracking` after each
219
+ * character), it is written beside that mark, in a box of its own
220
+ * direction so the mark's letter orders nothing around it. One word
221
+ * only: in a text of several the mark would reach the others. Asked in
222
+ * the language the text is shaped in ({@link shapedText}); nothing
223
+ * changes where that is not known.
224
+ */
225
+ function letterlessRunHtml(text, font, rtl, width, tracking) {
226
+ const shaped = shapedText;
227
+ if (!shaped || text.indexOf(' ') >= 0 || !isLetterless(text))
228
+ return undefined;
229
+ return withMeasureLanguage(shaped.lang, () => {
230
+ if (!faceReadsScript(font))
231
+ return undefined;
232
+ const set = scriptTextWidth(text, font, 'Latn', rtl) + (tracking === 0 ? 0 : tracking * graphemeCount(text));
233
+ return sameSetWidth(set, width) ? scriptTextHtml(text, 'Latn', rtl) : undefined;
234
+ });
235
+ }
236
+ /**
237
+ * {@link CELLS_DECL} for a line or a run of design text set at the
238
+ * glyphs' own advances (#770): when its characters are cells and spaces
239
+ * and the layout holds it `width` px wide, the sum of their advances
240
+ * (`tracking` after each). The canvas, which measured it, kerns fewer
241
+ * pairs of kana than the browser's layout does: the running head
242
+ * `日本語テキスト処理` in Noto Sans JP at 15.6 px is 140.58 px on the
243
+ * canvas, its nine cells, and 140.13 px in a box. Asked in the language
244
+ * the text is shaped in ({@link shapedText}); nothing where that is not
245
+ * known, or where the canvas kerned the text itself.
246
+ */
247
+ function designCellsDecl(text, font, width, tracking) {
248
+ const shaped = shapedText;
249
+ if (!shaped || !hasCJK(text))
250
+ return '';
251
+ const cells = cellsOf(text.replace(/[ \u3000]+/g, ''));
252
+ if (!cells)
253
+ return '';
254
+ const graphemes = graphemesOf(text);
255
+ const sum = withMeasureLanguage(shaped.lang, () => graphemes.reduce((w, g) => w + measureTextWidth(g, font), 0));
256
+ return sameSetWidth(sum + tracking * graphemes.length, width) ? CELLS_DECL : '';
257
+ }
258
+ /**
259
+ * A line of design text that carries directions (`VDTDesignTextLine.order`),
260
+ * as the markup inside its line box (#770): each run in a box of its own,
261
+ * at the place the layout gave it along the line and in its own direction.
262
+ *
263
+ * The canvas paints such a line run by run, each run a text of its own
264
+ * shaped right to left or left to right, and since #770 the layout
265
+ * measures each run so. Set inline in one line box, the browser shapes
266
+ * across the runs: a bracket takes the script of the run beside it and of
267
+ * the bracket it closes (the date `(٧٣٢–٨٠٨هـ)` in Amiri at 25 px is 122.5
268
+ * px run by run and 127.6 px as one line), and the browser orders the
269
+ * runs itself. A box per run is shaped alone, as the canvas shapes it;
270
+ * the markup stays in logical order, so the text copies as written. The
271
+ * boxes carry `white-space:pre` and their line box does not: a mirrored
272
+ * flow turns each of them back about itself (`MIRRORED_FLOW_STYLE`).
273
+ */
274
+ function directedDesignRunsHtml(line, block, font, tracked) {
275
+ const runs = line.runs;
276
+ const at = new Array(runs.length).fill(0);
277
+ let x = 0;
278
+ // The order the canvas advances in: the line's when it lists every run,
279
+ // else the runs' own (a mirrored flow may leave it out).
280
+ const order = line.order && line.order.length === runs.length ? line.order : runs.map((_, i) => i);
281
+ for (const i of order) {
282
+ at[i] = x;
283
+ x += runs[i].width;
284
+ }
285
+ return runs.map((run, i) => {
286
+ const runFont = quoteFontString(run.fontString);
287
+ const cjkDecl = hasCJK(run.text) ? cjkTextDecl(tracked) : '';
288
+ // A run of several words in the line's font: a box per word, in a run
289
+ // box as wide as the run (see `runWordBoxesHtml`).
290
+ const words = runFont === font && !cjkDecl && !tracked ? runWordBoxesHtml(run.text, run.fontString, run.rtl === true, run.width) : undefined;
291
+ const text = words ?? letterlessRunHtml(run.text, run.fontString, run.rtl === true, run.width, block.letterSpacingPx ?? 0) ?? wordsHtml(run.text, trackedIn(run.fontString, block.letterSpacingPx));
292
+ const top = run.baselineShift ? `${run.baselineShift.toFixed(3)}px` : '0';
293
+ const pos = `position:absolute;left:${at[i].toFixed(3)}px;top:${top};white-space:pre;${words !== undefined ? `width:${run.width.toFixed(3)}px;` : ''}${cjkDecl}`;
294
+ // A run in another face sits on the baseline of the line's own.
295
+ const inner = runFont !== font
296
+ ? `<span style="font:${runFont};line-height:0;${cjkDecl ? cjkFeaturesDecl(tracked) : tracked ? ligaturesDecl() : ''}">${text}</span>`
297
+ : text;
298
+ return `<span dir="${run.rtl ? 'rtl' : 'ltr'}" style="${pos}">${inner}</span>`;
299
+ }).join('');
300
+ }
301
+ /**
302
+ * A run of a directed design line as a box per word and per run of
303
+ * spaces, each at the place the canvas gives it (#770), or undefined
304
+ * where {@link wordsHtml} sets it.
305
+ *
306
+ * {@link wordsHtml} puts each run of spaces in an inline box, and a box
307
+ * in a line is as wide as a whole number of layout units: the opener
308
+ * `الإخراج مجموعة صفحات، …` of the Arabic guide, 17 words in Amiri at
309
+ * 47.92 px, has 16 spaces of 13.98 px, each 14 px in its box, and the
310
+ * run ended 0.30 px past its 1699.42 px. With every word at its own
311
+ * place nothing adds up ({@link wordBoxesHtml}, which sets a line with no
312
+ * runs so). The places are the canvas's, which shapes a run word by
313
+ * word: each word alone in the run's direction, each run of spaces
314
+ * alone, from the run's start side (its right for a right-to-left run);
315
+ * the caller gives the run's box its width. Only where the words set so
316
+ * add up to the run as the layout measured it (0.01 px), so a run
317
+ * measured otherwise (a document that keeps its host's language, a
318
+ * tracked text) is written as it was; not for a run with a word with no
319
+ * letter, which alone takes other forms than in its text, nor where the
320
+ * language is not known.
321
+ */
322
+ function runWordBoxesHtml(text, font, rtl, width) {
323
+ const shaped = shapedText;
324
+ if (!shaped || text.indexOf(' ') < 0 || text.trim() === '')
325
+ return undefined;
326
+ const parts = text.split(/( +)/).filter((part) => part !== '');
327
+ if (parts.some((part) => part[0] !== ' ' && isLetterless(part)))
328
+ return undefined;
329
+ return withMeasureLanguage(shaped.lang, () => {
330
+ const side = rtl ? 'right' : 'left';
331
+ let x = 0;
332
+ let out = '';
333
+ for (const part of parts) {
334
+ out += `<span style="position:absolute;${side}:${x.toFixed(3)}px;top:0;">${esc(part)}</span>`;
335
+ x += part[0] !== ' ' && rtl ? rtlTextWidth(part, font) : measureTextWidth(part, font);
336
+ }
337
+ return Math.abs(x - width) <= 0.01 ? out : undefined;
338
+ });
339
+ }
129
340
  /**
130
341
  * A word whose letters change style inside it (`VDTLineSegment.runs`: a
131
342
  * bold letter, a coloured haraka) as one run of text with inline spans for
@@ -331,6 +542,216 @@ const HTML_ESCAPE = {
331
542
  function esc(s) {
332
543
  return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c] ?? c);
333
544
  }
545
+ /**
546
+ * `text` escaped, each run of spaces inside it in a box of its own, and
547
+ * each word with no letter of a text that holds a character past U+00FF
548
+ * (#770).
549
+ *
550
+ * The canvas, which measured the text, shapes a string word by word: a
551
+ * space is a word of its own and nothing kerns across it. The browser
552
+ * shapes the text of a box as one run and kerns a letter against the
553
+ * space beside it. Chrome 154, STIX Two Text at 20.83 px: `Section V` is
554
+ * 83.72 px on the canvas and 83.31 px in one box, `To AV Ty` 80.22 px and
555
+ * 79.39 px; in Hedvig Letters Serif at 40 px a space beside a capital is
556
+ * 1.3 to 3.1 px narrower in a box. In Arabic text the browser also takes
557
+ * another space: Aref Ruqaa's is 0.146 em between two words of a run and
558
+ * 0.333 em alone, 34 px over a title of three words at 92 px. An inline
559
+ * block is shaped apart from the text around it, so with its spaces in
560
+ * such boxes the text is as wide as the canvas made it (83.75 and
561
+ * 80.27 px: the boxes' edges fall on a 64th of a pixel), and it reads and
562
+ * copies as written.
563
+ *
564
+ * A word with no letter (`(1332–1406),`) has no script of its own. In a
565
+ * string with a character past U+00FF the canvas draws it by the face's
566
+ * default rules (`canvas-backend/contextText.ts`); in a box the browser
567
+ * gives it the script of the words beside it, across those inline blocks
568
+ * too: Amiri's brackets are 0.255 em in Latin text and 0.458 em by
569
+ * default, 7.5 px over that word at 18.75 px. In a box of its own it is
570
+ * drawn by the default rules.
571
+ *
572
+ * For the text of one box that holds several words: a reference's label,
573
+ * a line with no segments, a line or a run of a design text.
574
+ */
575
+ function wordsHtml(text, tracked) {
576
+ if (tracked && ownShaping && tracked.tracking !== 0)
577
+ return trackedWordsHtml(text, tracked);
578
+ const escaped = esc(text);
579
+ if (text.indexOf(' ') < 0 || text.trim() === '')
580
+ return escaped;
581
+ if (!WIDE_TEXT_RE.test(text))
582
+ return escaped.replace(/ +/g, (spaces) => `${APART_OPEN}${spaces}</span>`);
583
+ return text.split(/( +)/).map((part) => (part === '' ? ''
584
+ : part[0] === ' ' || isLetterless(part) ? `${APART_OPEN}${esc(part)}</span>` : esc(part))).join('');
585
+ }
586
+ /** {@link TrackedText} of a text in `font` on a box tracked `tracking` px
587
+ * a letter, undefined for an untracked one. */
588
+ function trackedIn(font, tracking) {
589
+ return tracking ? { font, tracking } : undefined;
590
+ }
591
+ /**
592
+ * A word of a tracked text, escaped, in the pieces the canvas paints it
593
+ * in (#663, #770): each ligature in a box of its own under no
594
+ * letter-spacing, so the browser sets it, with the tracking of each of
595
+ * its letters after it as a margin; the letters between them as they
596
+ * are, tracked by the box they stand in. The kerning the natural shaping
597
+ * has across a cut, which the browser does not apply between two boxes
598
+ * of other spacing, is a margin before the piece (`pipefitters` in Source
599
+ * Serif 4 at 40 px: 0.39 px between `pipe` and `fi`). A word with no
600
+ * ligature the letter-spacing dissolves, a word of a joining or a
601
+ * right-to-left script, and any word where nothing measures, is its text.
602
+ *
603
+ * A letter-spacing takes the contextual alternates of a face away too
604
+ * (see `canvas-backend/trackedText.ts`): a word whose pieces do not end
605
+ * where the word was measured to end, or that the letter-spacing reshapes
606
+ * with no ligature to keep, is set whole in a box at the letter-spacing
607
+ * that ends it there, as the canvas paints it. `TEA-PARTY` in Cormorant
608
+ * SC at 17.71 px, tracked 2.5 px: 2.66 px a letter, the 1.47 px its
609
+ * hyphen is narrower by under a letter-spacing shared among its nine
610
+ * letters.
611
+ */
612
+ function trackedWordHtml(word, tracked) {
613
+ const shaped = shapedText;
614
+ if (!shaped || word.length < 2)
615
+ return esc(word);
616
+ const set = withMeasureLanguage(tracked.lang !== undefined ? shapingTagOf(tracked.lang) : shaped.lang, () => {
617
+ const cut = trackedLigaturePieces(word, tracked.font);
618
+ if (cut && piecesFit(cut, word, tracked.font))
619
+ return { pieces: cut };
620
+ return { pieces: cut, spacing: fittedLetterSpacing(word, tracked) };
621
+ });
622
+ if (set.spacing !== undefined)
623
+ return `<span style="letter-spacing:${set.spacing.toFixed(4)}px;">${esc(word)}</span>`;
624
+ const pieces = set.pieces;
625
+ if (!pieces)
626
+ return esc(word);
627
+ let out = '';
628
+ // Where the piece before ends in the natural shaping.
629
+ let end = 0;
630
+ for (const piece of pieces) {
631
+ const kern = piece.offset - end;
632
+ end = piece.offset + piece.width;
633
+ const lead = Math.abs(kern) >= 0.005 ? `margin-inline-start:${kern.toFixed(3)}px;` : '';
634
+ if (piece.ligature)
635
+ out += `<span style="letter-spacing:0;${lead}margin-inline-end:${(piece.letters * tracked.tracking).toFixed(3)}px;">${esc(piece.text)}</span>`;
636
+ else
637
+ out += lead ? `<span style="${lead}">${esc(piece.text)}</span>` : esc(piece.text);
638
+ }
639
+ return out;
640
+ }
641
+ /** The width of `text` in `font` under a letter-spacing, that spacing
642
+ * taken off, and what one px of letter-spacing adds to it; undefined
643
+ * where the measurer takes no letter-spacing. */
644
+ function spacedTextWidth(text, font) {
645
+ const one = measureSpacedWidth(text, font, 1);
646
+ const two = measureSpacedWidth(text, font, 2);
647
+ if (one === undefined || two === undefined || !(two - one > 0.5))
648
+ return undefined;
649
+ return { width: one - (two - one), step: two - one };
650
+ }
651
+ /** Least difference, px, that counts as another shaping
652
+ * (`measure/trackedLigatures.ts`). */
653
+ const RESHAPED_PX = 0.05;
654
+ /** Whether the pieces of `word`, each set as it is alone, end where the
655
+ * natural shaping ends the word (`TrackedWord.fits` of the canvas). */
656
+ function piecesFit(pieces, word, font) {
657
+ const last = pieces[pieces.length - 1];
658
+ const width = last.ligature ? last.width : spacedTextWidth(last.text, font)?.width;
659
+ return width !== undefined && Math.abs(last.offset + width - measureTextWidth(word, font)) <= RESHAPED_PX;
660
+ }
661
+ /** The letter-spacing, px, at which `word`, as the browser shapes it
662
+ * under one, is as wide as it was measured: its natural width and the
663
+ * tracking after each letter. Undefined for a word the letter-spacing
664
+ * does not reshape, a word of a joining or a right-to-left script, and
665
+ * where nothing measures a letter-spacing. Nor for a text with a CJK
666
+ * character, which is not measured so: the composer and a vertical line
667
+ * count cells, and two marks that meet are as wide set whole as the
668
+ * browser trims them, under no letter-spacing only (the lines of the
669
+ * Chinese guide set down the page stood 0.4 to 1.3 px off their cells). */
670
+ function fittedLetterSpacing(word, tracked) {
671
+ if (joiningScriptIn(word) || needsBidi(word) || hasCJK(word))
672
+ return undefined;
673
+ const letters = graphemeCount(word);
674
+ if (letters < 2)
675
+ return undefined;
676
+ const under = spacedTextWidth(word, tracked.font);
677
+ if (!under)
678
+ return undefined;
679
+ const natural = measureTextWidth(word, tracked.font);
680
+ if (Math.abs(under.width - natural) <= RESHAPED_PX)
681
+ return undefined;
682
+ const spacing = (natural + tracked.tracking * letters - under.width) / under.step;
683
+ // At no letter-spacing the browser would shape it naturally again.
684
+ return Math.abs(spacing) < 1e-6 ? undefined : spacing;
685
+ }
686
+ /** {@link wordsHtml} of a tracked text whose ligatures take the tracking
687
+ * of each letter: its spaces and its letterless words in boxes of their
688
+ * own as there, every other word through {@link trackedWordHtml}. */
689
+ function trackedWordsHtml(text, tracked) {
690
+ if (text.indexOf(' ') < 0 || text.trim() === '')
691
+ return trackedWordHtml(text, tracked);
692
+ const wide = WIDE_TEXT_RE.test(text);
693
+ return text.split(/( +)/).map((part) => (part === '' ? ''
694
+ : part[0] === ' ' || (wide && isLetterless(part)) ? `${APART_OPEN}${esc(part)}</span>` : trackedWordHtml(part, tracked))).join('');
695
+ }
696
+ /** A text set down a vertical line or with no box per space, escaped,
697
+ * each word of a tracked one through {@link trackedWordHtml}. */
698
+ function trackedTextHtml(text, tracked) {
699
+ if (!tracked || !ownShaping || tracked.tracking === 0)
700
+ return esc(text);
701
+ if (text.indexOf(' ') < 0)
702
+ return trackedWordHtml(text, tracked);
703
+ return text.split(/( +)/).map((part) => (part === '' ? '' : part[0] === ' ' ? part : trackedWordHtml(part, tracked))).join('');
704
+ }
705
+ /** A box shaped apart from the text around it. */
706
+ const APART_OPEN = '<span style="display:inline-block;">';
707
+ /**
708
+ * A line of several words as a box per word and per run of spaces, each
709
+ * at the place the canvas gives it (#770), or undefined where
710
+ * {@link wordsHtml} sets it.
711
+ *
712
+ * Boxes inside a line each round their width to a 64th of a pixel, and
713
+ * over a long line the rounding adds up: a colophon of 26 spaces set with
714
+ * {@link wordsHtml} ends 0.14 to 0.64 px past the canvas's width (four
715
+ * faces, three sizes), where in one box it stood 1.9 to 16 px short of
716
+ * it. With every word at its own place, as the words of a body line
717
+ * are, nothing adds up. The places are the canvas's: the advance of each
718
+ * word alone, in the language the text is shaped in ({@link shapedText}),
719
+ * `tracking` after each character and `wordSpacing` on each space. For a
720
+ * text that reads left to right, written straight into a box that keeps
721
+ * the line's font; the caller gives that box the line's width, since its
722
+ * words no longer make it. Not where the language is not known. In a
723
+ * face that draws by script, a word with no letter may take other forms
724
+ * measured alone than in a line that holds a character past U+00FF: such
725
+ * a line is set so only where its words alone are as wide together as
726
+ * the line measured whole, 0.01 px apart at most (the colophon `Higuchi
727
+ * Ichiyō, たけくらべ (Child’s Play, 1895–96), …` in Noto Sans JP at
728
+ * 11.46 px: 578.2357 px both ways; with {@link wordsHtml} its 16 spaces
729
+ * and its number, each in a box, end it at 578.53 px).
730
+ */
731
+ function wordBoxesHtml(text, font, tracking, wordSpacing) {
732
+ const shaped = shapedText;
733
+ if (!shaped || text.indexOf(' ') < 0 || text.trim() === '' || needsBidi(text))
734
+ return undefined;
735
+ const parts = text.split(/( +)/).filter((part) => part !== '');
736
+ return withMeasureLanguage(shaped.lang, () => {
737
+ const byScript = WIDE_TEXT_RE.test(text) && parts.some((part) => part[0] !== ' ' && isLetterless(part)) && faceReadsScript(font);
738
+ let x = 0;
739
+ // The words alone, with no tracking and no wider space.
740
+ let alone = 0;
741
+ let out = '';
742
+ for (const part of parts) {
743
+ out += `<span style="position:absolute;left:${x.toFixed(3)}px;top:0;">${part[0] === ' ' ? esc(part) : trackedTextHtml(part, trackedIn(font, tracking))}</span>`;
744
+ const width = measureTextWidth(part, font);
745
+ alone += width;
746
+ x += width + (tracking === 0 ? 0 : tracking * graphemeCount(part)) + (part[0] === ' ' ? wordSpacing * part.length : 0);
747
+ }
748
+ if (byScript && Math.abs(alone - measureTextWidth(text, font)) > 0.01)
749
+ return undefined;
750
+ return out;
751
+ });
752
+ }
753
+ /** A character past Latin-1 (see `canvas-backend/contextText.ts`). */
754
+ const WIDE_TEXT_RE = /[^\u0000-\u00FF]/;
334
755
  /** Element id for a resource embed's in-document anchor. `:ref` segments link
335
756
  * to it with `#<id>` so references navigate in standalone HTML output. */
336
757
  function resourceAnchorId(resourceId) {
@@ -405,6 +826,50 @@ function pickSegmentFont(seg, block) {
405
826
  return block.italicFontString;
406
827
  return block.fontString;
407
828
  }
829
+ /**
830
+ * The language the browser shapes the text of the document being written
831
+ * in, as far as the writer knows it (#770): `lang` is the one its root
832
+ * declares (the document's own, or none: the empty tag), or undefined for
833
+ * the language of the page that writes it, which shows it too. Null where
834
+ * nobody can tell: a document in the host's language written in a worker,
835
+ * or where nothing measures. Set by {@link renderToHtmlIndexed} while it
836
+ * writes its pages, null again after them.
837
+ */
838
+ let shapedText = null;
839
+ /** {@link shapedText} of a document whose root declares `declared`
840
+ * (undefined: it declares none). */
841
+ function shapedTextOf(declared) {
842
+ const page = typeof document !== 'undefined';
843
+ if (!page && typeof OffscreenCanvas === 'undefined')
844
+ return null;
845
+ if (declared !== undefined)
846
+ return { lang: declared };
847
+ return page ? { lang: undefined } : null;
848
+ }
849
+ /**
850
+ * The segments a line set word by word is written from (#770): the line's
851
+ * own, the pieces of a word joined into one box where the word was
852
+ * measured whole and the hyphen of a line end in a box of its own where
853
+ * it was measured apart (`lineWordSegments`, the rule the canvas and the
854
+ * PDF apply). The browser sets each box's text as one run, so a joined
855
+ * word has the glyphs the layout measured: the `ffi` of `of|fice`, the
856
+ * dot of a Catalan `l·l`. Asked of the engine's measurer, in the language
857
+ * the text is shaped in ({@link shapedText}, a segment's own where it
858
+ * names one), at the line's `tracking`; `fontOf` gives a segment's font
859
+ * string. With them the order they advance in on a line that carries one
860
+ * (`VDTLine.order`): the pieces of a left-to-right word are joined there
861
+ * too, and the order is the one of the segments that result. The line's
862
+ * own segments and order where that language is not known.
863
+ */
864
+ function wordSegmentsOf(line, tracking, fontOf) {
865
+ const shaped = shapedText;
866
+ if (!shaped)
867
+ return { segments: line.segments, order: line.order };
868
+ const set = lineWordSegmentsInOrder(line, false, (seg, text) => withMeasureLanguage(seg.lang !== undefined ? shapingTagOf(seg.lang) : shaped.lang, () => measureTextWidth(text, fontOf(seg)))
869
+ + (tracking === 0 ? 0 : tracking * graphemeCount(text)));
870
+ // Nothing joined: the line's order as it is, whatever it counts.
871
+ return set.segments === line.segments ? { segments: line.segments, order: line.order } : set;
872
+ }
408
873
  function pickSegmentColor(seg, block) {
409
874
  if (seg.color)
410
875
  return seg.color;
@@ -458,15 +923,17 @@ box = PLAIN_BOX) {
458
923
  // A compressed CJK mark is painted before its box (`inkOffset`).
459
924
  const left = seg.inkOffset !== undefined ? x + seg.inkOffset : x;
460
925
  const scaleDecl = seg.inkScale !== undefined ? inkScaleDecl(seg.inkScale) : '';
461
- const pos = `position:absolute;left:${left.toFixed(3)}px;top:${top};white-space:pre;${spacingDecl}${cjk === 'own' ? cjkTextDecl(tracking !== 0) : ''}${scaleDecl}${box.decl}`;
926
+ // The cells of a line of the composer, each where it was measured.
927
+ const cellsDecl = cjk === 'line' && cellsOf(seg.text) ? CELLS_DECL : '';
928
+ const pos = `position:absolute;left:${left.toFixed(3)}px;top:${top};white-space:pre;${spacingDecl}${cjk === 'own' ? cjkTextDecl(tracking !== 0) : ''}${scaleDecl}${fontDecl ? '' : cellsDecl}${box.decl}`;
462
929
  // Text set with emphasis dots is emphasis (#193); the dots are the
463
930
  // line's marks.
464
- const text = seg.cjkMarks?.dots ? `<em style="font-style:inherit;">${esc(seg.text)}</em>` : box.html ?? esc(seg.text);
931
+ const text = seg.cjkMarks?.dots ? `<em style="font-style:inherit;">${esc(seg.text)}</em>` : box.html ?? wordsHtml(seg.text, box.tracked);
465
932
  // A run in another face of a tracked line asks for its ligatures again
466
933
  // after its `font` (#703), with the CJK features when it takes those.
467
- const featuresDecl = !fontDecl ? '' : scaleDecl ? DASH_FEATURES_DECL
468
- : cjk ? (tracking !== 0 ? CJK_TRACKED_FEATURES_DECL : CJK_FEATURES_DECL)
469
- : tracking !== 0 && !fontDecl.includes(TRACKED_LIGATURES_DECL) ? TRACKED_LIGATURES_DECL : '';
934
+ const featuresDecl = !fontDecl ? '' : (scaleDecl ? DASH_FEATURES_DECL
935
+ : cjk ? cjkFeaturesDecl(tracking !== 0)
936
+ : tracking !== 0 && !fontDecl.includes(TRACKED_LIGATURES_DECL) ? ligaturesDecl() : '') + cellsDecl;
470
937
  if (seg.refResourceId !== undefined) {
471
938
  // Anchors carry an explicit color so the UA link blue never leaks in.
472
939
  const inner = `<a href="${refAnchorHref(refKey(seg))}" style="text-decoration:none;${fontDecl}${featuresDecl}${fontDecl ? 'line-height:0;' : ''}color:${color};">${text}</a>`;
@@ -492,7 +959,7 @@ box = PLAIN_BOX) {
492
959
  if (cjk || seg.cjkMarks)
493
960
  return renderMarkedWordSegment(seg, x, top, fontDecl, colorDecl, color, cjk, box);
494
961
  const pos = `position:absolute;left:${x.toFixed(3)}px;top:${top};white-space:pre;${box.decl}`;
495
- const text = box.html ?? esc(seg.text);
962
+ const text = box.html ?? wordsHtml(seg.text, box.tracked);
496
963
  if (seg.refResourceId !== undefined) {
497
964
  // Anchors carry an explicit color so the UA link blue never leaks in.
498
965
  const inner = `<a href="${refAnchorHref(refKey(seg))}" style="text-decoration:none;${fontDecl}${fontDecl ? 'line-height:0;' : ''}color:${color};">${text}</a>`;
@@ -509,7 +976,7 @@ function renderMarkedWordSegment(seg, x, top, fontDecl, colorDecl, color, cjk, b
509
976
  const pos = `position:absolute;left:${x.toFixed(3)}px;top:${top};white-space:pre;${cjk ? CJK_TEXT_DECL : ''}${box.decl}`;
510
977
  // Text set with emphasis dots is emphasis (#193); the dots are the
511
978
  // line's marks.
512
- const text = seg.cjkMarks?.dots ? `<em style="font-style:inherit;">${esc(seg.text)}</em>` : box.html ?? esc(seg.text);
979
+ const text = seg.cjkMarks?.dots ? `<em style="font-style:inherit;">${esc(seg.text)}</em>` : box.html ?? wordsHtml(seg.text, box.tracked);
513
980
  const featuresDecl = fontDecl && cjk ? CJK_FEATURES_DECL : '';
514
981
  if (seg.refResourceId !== undefined) {
515
982
  // Anchors carry an explicit color so the UA link blue never leaks in.
@@ -729,7 +1196,7 @@ end = '') {
729
1196
  ? Math.max(0, (room - plainWidth) / 2)
730
1197
  : 0);
731
1198
  const plainDir = dirAttr(block.direction ?? rootDir, rootDir);
732
- return `<span${plainDir} style="position:absolute;left:${plainLeft.toFixed(3)}px;top:0;white-space:pre;">${esc(line.text)}</span>` + lineEndHtml(end, plainLeft + plainWidth);
1199
+ return `<span${plainDir} style="position:absolute;left:${plainLeft.toFixed(3)}px;top:0;white-space:pre;">${wordsHtml(line.text)}</span>` + lineEndHtml(end, plainLeft + plainWidth);
733
1200
  }
734
1201
  // Match canvas justification: stretch inter-word spaces to fill effective width.
735
1202
  const lineIndent = span ? span.x - block.bbox.x : line.bbox.x - block.bbox.x;
@@ -759,17 +1226,19 @@ end = '') {
759
1226
  const parts = [];
760
1227
  const links = linkRuns();
761
1228
  let x = leadingGap;
762
- const segs = line.segments;
763
- // Segments advance along the line in `line.order` when it has one (a
1229
+ // The pieces of a word the layout measured whole are one box (#770).
1230
+ const set = wordSegmentsOf(line, tracking, (seg) => pickSegmentFont(seg, block));
1231
+ const segs = set.segments;
1232
+ // Segments advance along the line in its order when it has one (a
764
1233
  // line with right-to-left runs, or any line of a mirrored page): their
765
1234
  // x are taken in that order; the markup stays in logical order, so the
766
1235
  // text copies as written.
767
- const at = line.order ? orderedOffsets(segs, line.order, leadingGap, useJustify ? justifiedSpaceWidth : undefined) : undefined;
1236
+ const at = set.order ? orderedOffsets(segs, set.order, leadingGap, useJustify ? justifiedSpaceWidth : undefined) : undefined;
768
1237
  const cjk = block.tocEntry !== undefined || hasCJK(line.text) || (line.tabbed === true && segs.some((sg) => sg.leader === 'text'));
769
1238
  const paintText = (seg, at, inLink = false) => {
770
1239
  const font = quoteFontString(pickSegmentFont(seg, block));
771
1240
  const color = pickSegmentColor(seg, block);
772
- const fontDecl = font !== quoteFontString(block.fontString) ? `font:${font};${tracking !== 0 ? TRACKED_LIGATURES_DECL : ''}` : '';
1241
+ const fontDecl = font !== quoteFontString(block.fontString) ? `font:${font};${tracking !== 0 ? ligaturesDecl() : ''}` : '';
773
1242
  const colorDecl = color !== block.color ? `color:${color};` : '';
774
1243
  const top = seg.baselineShift ? `${seg.baselineShift.toFixed(3)}px` : '0';
775
1244
  return renderWordTextSegment(inLink ? { ...seg, refResourceId: undefined } : seg, at, top, fontDecl, colorDecl, color, cjk && hasCJK(seg.text), segmentBox(seg, rootDir, rootLang, tracking, font, color, (probe) => ({ font: quoteFontString(pickSegmentFont(probe, block)), color: pickSegmentColor(probe, block) })));
@@ -868,7 +1337,7 @@ function renderComposedSegments(line, block, targets, rootDir = 'ltr', end = '')
868
1337
  : block.textAlign === 'center'
869
1338
  ? Math.max(0, (plainRoom - plainWidth) / 2)
870
1339
  : 0;
871
- return `<span style="position:absolute;left:${plainLeft.toFixed(3)}px;top:0;white-space:pre;">${esc(line.text)}</span>` + lineEndHtml(end, plainLeft + plainWidth);
1340
+ return `<span style="position:absolute;left:${plainLeft.toFixed(3)}px;top:0;white-space:pre;">${wordsHtml(line.text)}</span>` + lineEndHtml(end, plainLeft + plainWidth);
872
1341
  }
873
1342
  // Match canvas justification: stretch inter-word spaces to fill effective width.
874
1343
  const lineIndent = line.bbox.x - block.bbox.x;
@@ -913,7 +1382,12 @@ function renderComposedSegments(line, block, targets, rootDir = 'ltr', end = '')
913
1382
  // The composer sets left-to-right text: in a right-to-left document
914
1383
  // its boxes say so (#379).
915
1384
  const dir = dirAttr(seg.rtl ? 'rtl' : 'ltr', rootDir);
916
- return renderTextSegment(inLink ? { ...seg, refResourceId: undefined } : seg, at, top, fontDecl, colorDecl, color, lineTracking, segmentCjk(seg, lineDecl), dir ? { attrs: dir, decl: '' } : PLAIN_BOX);
1385
+ // A Western word of a tracked line in the canvas's pieces (#770): the
1386
+ // cells of the line hold no ligature and are asked nothing.
1387
+ const segTracking = lineTracking + (seg.tracking ?? 0);
1388
+ const tracked = ownShaping && segTracking !== 0 && !cellsOf(seg.text) && !joiningScriptIn(seg.text)
1389
+ ? { font, tracking: segTracking, ...(seg.lang !== undefined ? { lang: seg.lang } : {}) } : undefined;
1390
+ return renderTextSegment(inLink ? { ...seg, refResourceId: undefined } : seg, at, top, fontDecl, colorDecl, color, lineTracking, segmentCjk(seg, lineDecl), dir || tracked ? { attrs: dir, decl: '', ...(tracked ? { tracked } : {}) } : PLAIN_BOX);
917
1391
  };
918
1392
  // The `<ruby>` open across the bases of one annotation (#428).
919
1393
  const ruby = rubyRuns();
@@ -1012,10 +1486,23 @@ function leaderHtml(seg, x, baselineOffset, block) {
1012
1486
  const font = quoteFontString(pickSegmentFont(seg, block));
1013
1487
  const fontDecl = font !== quoteFontString(block.fontString) ? `font:${font};` : '';
1014
1488
  const colorDecl = color !== block.color ? `color:${color};` : '';
1489
+ // The dots are measured and painted on the canvas left to right, as a
1490
+ // text of their own. In the box of a right-to-left block the browser
1491
+ // draws them in the forms of the script around them: a contents row of
1492
+ // 66 `. ` in Amiri at 27.08 px is 441.46 px in the layout and on the
1493
+ // canvas and 544.23 px in the box, 318.34 px against 277.98 px in Noto
1494
+ // Naskh Arabic. With its own direction on the positioned box (an inline
1495
+ // one inside it changes nothing) the leader is 441.47 px and 318.34 px.
1496
+ // In a document shaped in its own language (#770); one that keeps its
1497
+ // host's measured the stop as the page had it and is written as it was.
1498
+ // The direction is the box's `dir`, not a `direction` declaration, which
1499
+ // an EPUB's pages may not carry (EPUBCheck CSS-001); Chrome sets the
1500
+ // leader the same with either (882.92 px for 66 `. ` in Amiri).
1015
1501
  const pos = `position:absolute;left:${x.toFixed(3)}px;top:0;white-space:pre;${noSelect}`;
1502
+ const dir = ownShaping ? ' dir="ltr"' : '';
1016
1503
  if (fontDecl)
1017
- return `<span ${hidden} style="${pos}"><span style="${fontDecl}line-height:0;${colorDecl}">${esc(seg.text)}</span></span>`;
1018
- return `<span ${hidden} style="${pos}${colorDecl}">${esc(seg.text)}</span>`;
1504
+ return `<span ${hidden} style="${pos}"${dir}><span style="${fontDecl}line-height:0;${colorDecl}">${esc(seg.text)}</span></span>`;
1505
+ return `<span ${hidden} style="${pos}${colorDecl}"${dir}>${esc(seg.text)}</span>`;
1019
1506
  }
1020
1507
  /** Inline colour swatch (`:swatch{…}`): a square of `side` px on the line's
1021
1508
  * baseline (`baselineOffset` from the line top), filled with `fill` when
@@ -1065,15 +1552,6 @@ rootDir = 'ltr') {
1065
1552
  }
1066
1553
  return parts.join('');
1067
1554
  }
1068
- /** Whether a marker's order differs when its leading European digits read
1069
- * as Arabic numbers (`BidiOptions.arabicNumbers`, #665). */
1070
- function arabicMarkNeeded(text) {
1071
- if (!/[0-9]/.test(text))
1072
- return false;
1073
- const plain = markerRuns(text, 'rtl');
1074
- const arabic = markerRuns(text, 'rtl-arabic');
1075
- return plain.length !== arabic.length || plain.some((r, i) => r.text !== arabic[i].text);
1076
- }
1077
1555
  /**
1078
1556
  * The box of a task item (`VDTBlock.bulletBox`, #765): an inline SVG the
1079
1557
  * size of the box, from `bulletOffsetX` and centred on `bulletY`, in the
@@ -1173,7 +1651,7 @@ lead = '') {
1173
1651
  // line's own (justification): measured into the segment widths, so the
1174
1652
  // glyphs must spread the same way.
1175
1653
  const tracking = (block.letterSpacing ?? 0) + (line.letterSpacing ?? 0);
1176
- const trackingDecl = tracking !== 0 ? `letter-spacing:${tracking}px;${TRACKED_LIGATURES_DECL}` : '';
1654
+ const trackingDecl = tracking !== 0 ? `letter-spacing:${tracking}px;${ligaturesDecl()}` : '';
1177
1655
  return (`<div class="pt-line" data-block="${esc(block.id)}" style="` +
1178
1656
  `position:absolute;` +
1179
1657
  `left:${line.bbox.x}px;` +
@@ -1195,9 +1673,14 @@ lead = '') {
1195
1673
  * with nothing between, so a copy and a screen reader read the first word
1196
1674
  * whole.
1197
1675
  */
1198
- function dropCapHtml(cap, line, lineFont) {
1676
+ function dropCapHtml(cap, line, lineFont, tracking = 0) {
1677
+ // The cap is measured and painted on the canvas with no tracking; set
1678
+ // inside the box of a tracked line it would inherit the line's
1679
+ // (#770: `Se` at 86.25 px on a line tracked 0.25 px, 95.39 px measured,
1680
+ // 95.91 px in the box).
1681
+ const untracked = tracking !== 0 ? 'letter-spacing:normal;' : '';
1199
1682
  const run = (text, font, x, baselineY, width, cls) => `<span class="${cls}" style="position:absolute;left:${(x - line.bbox.x).toFixed(3)}px;top:${(baselineY - line.baseline).toFixed(3)}px;` +
1200
- `width:${width.toFixed(3)}px;height:${line.bbox.height}px;font:${lineFont};color:${cap.color};white-space:pre;` +
1683
+ `width:${width.toFixed(3)}px;height:${line.bbox.height}px;font:${lineFont};color:${cap.color};white-space:pre;` + untracked +
1201
1684
  (hasCJK(text) ? CJK_TEXT_DECL : '') +
1202
1685
  `"><span style="font:${quoteFontString(font)};line-height:0;">${esc(text)}</span></span>`;
1203
1686
  const hang = cap.hang ? run(cap.hang.text, cap.hang.fontString, cap.hang.x, cap.hang.baselineY, cap.hang.width, 'pt-dropcap-hang') : '';
@@ -1239,20 +1722,24 @@ function verticalTextHtml(text, v, orient, font, tracking = 0) {
1239
1722
  return `<span style="text-combine-upright:all;">${esc(text)}</span>`;
1240
1723
  if (orient === 'upright')
1241
1724
  return `<span style="text-orientation:upright;">${esc(text)}</span>`;
1725
+ // A sideways Latin word of a tracked line in the canvas's pieces, its
1726
+ // ligatures with the tracking of each letter (#770).
1727
+ const tracked = font !== undefined ? trackedIn(font, tracking) : undefined;
1242
1728
  if (orient === 'sideways')
1243
- return `<span style="text-orientation:sideways;">${esc(text)}</span>`;
1729
+ return `<span style="text-orientation:sideways;">${trackedTextHtml(text, tracked)}</span>`;
1244
1730
  const digits = v.uprightDigits > 0 && /[0-9]/.test(text);
1245
1731
  const japanese = v.region === 'japan' && JAPANESE_VERTICAL_RE.test(text);
1246
1732
  if (!digits && !japanese && !holdsTurnedMark(text))
1247
- return esc(text);
1733
+ return trackedTextHtml(text, tracked);
1248
1734
  const runs = verticalRuns(graphemesOf(text), v.region, v.uprightDigits);
1249
1735
  if (!runs.some((r) => r.glyph.orient === 'tcy' || r.glyph.orient === 'rotate' || r.glyph.paintAs !== undefined))
1250
- return esc(text);
1736
+ return trackedTextHtml(text, tracked);
1251
1737
  const advances = font !== undefined ? v.dashes?.[fontFamilyOf(font)] : undefined;
1252
1738
  // A number in one cell combined upright; a turned mark in its cell.
1253
1739
  return runs.map((r) => (r.glyph.orient === 'tcy'
1254
1740
  ? `<span style="text-combine-upright:all;">${esc(r.text)}</span>`
1255
- : r.glyph.orient === 'rotate' ? turnedCellHtml(r, advances?.[r.text], tracking) : esc(r.glyph.paintAs ?? r.text))).join('');
1741
+ : r.glyph.orient === 'rotate' ? turnedCellHtml(r, advances?.[r.text], tracking)
1742
+ : r.glyph.paintAs !== undefined ? esc(r.glyph.paintAs) : trackedTextHtml(r.text, tracked))).join('');
1256
1743
  }
1257
1744
  /**
1258
1745
  * A mark a vertical line turns in a cell of its own (`rotate`: a dash, an
@@ -1322,6 +1809,44 @@ function verticalAnnotationRuns(runs, x, color, v, axisOf, hidden = true) {
1322
1809
  return verticalSpanAt(x + run.dx, axisOf(run.fontString, run.dy), extractFontSizePx(run.fontString), verticalTextHtml(run.text, v, run.upright ? 'upright' : undefined, run.fontString), decl, hidden);
1323
1810
  }).join('');
1324
1811
  }
1812
+ /**
1813
+ * The advance of `text` down a vertical line, as the layout measures it
1814
+ * (`verticalTextWidth`): its sideways runs at the width the measurer
1815
+ * gives them, every cell at its length in ems, `tracking` px after each
1816
+ * character and each cell.
1817
+ */
1818
+ function verticalAdvance(text, font, v, tracking) {
1819
+ const em = extractFontSizePx(font);
1820
+ let width = 0;
1821
+ for (const run of verticalRuns(graphemesOf(text), v.region, v.uprightDigits)) {
1822
+ width += run.cell === undefined
1823
+ ? measureTextWidth(run.text, font) + (tracking === 0 ? 0 : tracking * graphemeCount(run.text))
1824
+ : em * run.cell + tracking;
1825
+ }
1826
+ return width;
1827
+ }
1828
+ /**
1829
+ * The segments a line down a vertical page is written from (#770): the
1830
+ * line's own, the pieces of a word set sideways joined into one box where
1831
+ * the word was measured whole (`lineWordSegments`, the rule of a
1832
+ * horizontal line, see {@link wordSegmentsOf}).
1833
+ *
1834
+ * A Latin word runs sideways down the column, shaped as on a horizontal
1835
+ * line, and a piece of it is as wide as the word grew by it. Chrome 154,
1836
+ * Noto Sans TC at 15.63 px under `zh-Hant`: the layout holds `BY-` 22.51
1837
+ * and `SA` 18.46 px, `BY-SA` being 40.97 px, where `SA` alone is 18.81 px
1838
+ * (the face draws the hyphen 0.34 px narrower before the capital). In a
1839
+ * box of its own `SA` ended 0.35 px past its place; the word in one box
1840
+ * is 40.98 px. A line of the CJK composer, which measures a Western run
1841
+ * whole, and a line that carries an order are written as they are; so is
1842
+ * every line where the language is not known.
1843
+ */
1844
+ function verticalWordSegments(line, block, v, tracking) {
1845
+ const shaped = shapedText;
1846
+ if (!shaped || !line.segments || line.cjkComposed || line.order !== undefined)
1847
+ return line.segments;
1848
+ return lineWordSegmentsInOrder(line, false, (seg, text) => withMeasureLanguage(seg.lang !== undefined ? shapingTagOf(seg.lang) : shaped.lang, () => verticalAdvance(text, pickSegmentFont(seg, block), v, tracking))).segments;
1849
+ }
1325
1850
  /** A body line of a vertical page: its text segments down an upright box,
1326
1851
  * its formulas, swatches and chips sideways where the canvas paints them
1327
1852
  * (see the section comment). */
@@ -1335,8 +1860,10 @@ function renderVerticalLine(line, block, v, targets, next) {
1335
1860
  const axisOf = (fontString, shift = 0) => baseline + shift - centralOf(v, fontString) * extractFontSizePx(fontString);
1336
1861
  const inner = [];
1337
1862
  const sideways = [];
1338
- const segs = line.segments && line.segments.length > 0
1339
- ? line.segments
1863
+ // The pieces of a sideways word in one box (see `verticalWordSegments`).
1864
+ const own = verticalWordSegments(line, block, v, lineTracking);
1865
+ const segs = own && own.length > 0
1866
+ ? own
1340
1867
  : [{ kind: 'text', text: line.text, width: line.bbox.width }];
1341
1868
  let wordWidth = 0;
1342
1869
  let spaceCount = 0;
@@ -1407,7 +1934,7 @@ function renderVerticalLine(line, block, v, targets, next) {
1407
1934
  const stretched = seg.inkScale !== undefined;
1408
1935
  // A run in another face of a tracked line asks for its ligatures
1409
1936
  // again after its `font`, which resets them (#703).
1410
- const decl = (font !== blockFont ? `font:${font};${lineTracking !== 0 ? TRACKED_LIGATURES_DECL : ''}` : '')
1937
+ const decl = (font !== blockFont ? `font:${font};${lineTracking !== 0 ? ligaturesDecl() : ''}` : '')
1411
1938
  + (color !== block.color ? `color:${color};` : '')
1412
1939
  + (seg.tracking !== undefined ? `letter-spacing:${lineTracking + seg.tracking}px;` : '')
1413
1940
  + (stretched ? `transform:scaleY(${seg.inkScale.toFixed(4)});transform-origin:0 0;${DASH_FEATURES_DECL}` : '');
@@ -1452,7 +1979,7 @@ function renderVerticalLine(line, block, v, targets, next) {
1452
1979
  + (block.strikethroughText ? 'text-decoration:line-through;' : '')
1453
1980
  // A tracked line keeps the ligatures of its sideways Latin runs, as
1454
1981
  // they were measured (#703).
1455
- + (lineTracking !== 0 ? `letter-spacing:${lineTracking}px;${TRACKED_LIGATURES_DECL}` : '')
1982
+ + (lineTracking !== 0 ? `letter-spacing:${lineTracking}px;${ligaturesDecl()}` : '')
1456
1983
  + (lineCjkDecl(line) || hasCJK(line.text) ? cjkTextDecl(lineTracking !== 0) : '');
1457
1984
  return (`<div class="pt-line" data-block="${esc(block.id)}" style="position:absolute;left:${line.bbox.x}px;top:${line.bbox.y}px;width:${width}px;height:${line.bbox.height}px;">` +
1458
1985
  uprightInner(width, line.bbox.height, decl, inner.join('')) +
@@ -1515,7 +2042,7 @@ function verticalDesignLines(block, v, originX, originY) {
1515
2042
  // A dash of a 破折号 (`inkScale`, #652): turned with the flow and
1516
2043
  // stretched down the column, as a body segment's is.
1517
2044
  const stretched = run.inkScale !== undefined;
1518
- const decl = (runFont !== font ? `font:${runFont};${tracked ? TRACKED_LIGATURES_DECL : ''}` : '')
2045
+ const decl = (runFont !== font ? `font:${runFont};${tracked ? ligaturesDecl() : ''}` : '')
1519
2046
  + (stretched ? `transform:scaleY(${run.inkScale.toFixed(4)});transform-origin:0 0;${DASH_FEATURES_DECL}` : '');
1520
2047
  // A run whose width is its box (a CJK mark that gave up blank, #637)
1521
2048
  // sets its glyphs `inkOffset` into it.
@@ -1523,7 +2050,7 @@ function verticalDesignLines(block, v, originX, originY) {
1523
2050
  x += run.width;
1524
2051
  }
1525
2052
  const width = Math.max(line.width, x);
1526
- const decl = `font:${font};color:${block.color};${hasCJK(line.text) ? cjkTextDecl(tracked) : tracked ? TRACKED_LIGATURES_DECL : ''}`
2053
+ const decl = `font:${font};color:${block.color};${hasCJK(line.text) ? cjkTextDecl(tracked) : tracked ? ligaturesDecl() : ''}`
1527
2054
  + (line.wordSpacingPx ? `word-spacing:${line.wordSpacingPx.toFixed(3)}px;` : '');
1528
2055
  parts.push(uprightBox(originX + line.xOffset, originY + top, width, h, decl, inner.join('')));
1529
2056
  }
@@ -1556,13 +2083,15 @@ end = '') {
1556
2083
  const parts = [];
1557
2084
  const lineDecl = lineCjkDecl(line);
1558
2085
  if (line.segments && line.segments.length > 0) {
1559
- const segs = line.segments;
1560
2086
  const composed = line.cjkComposed === true;
2087
+ const tracking = line.letterSpacing ?? 0;
2088
+ // The pieces of a word the layout measured whole are one box (#770).
2089
+ const set = composed ? { segments: line.segments, order: line.order } : wordSegmentsOf(line, tracking, (seg) => pickResourceFont(seg, fonts));
2090
+ const segs = set.segments;
1561
2091
  const cjk = !composed && hasCJK(line.text);
1562
2092
  // A caption or cell line with right-to-left runs, or on a mirrored
1563
2093
  // page, advances through its segments in `order` (#379).
1564
- const at = line.order && line.order.length === segs.length ? orderedOffsets(segs, line.order, 0, undefined) : undefined;
1565
- const tracking = line.letterSpacing ?? 0;
2094
+ const at = set.order && set.order.length === segs.length ? orderedOffsets(segs, set.order, 0, undefined) : undefined;
1566
2095
  const segColorOf = (seg) => (seg.refResourceId !== undefined
1567
2096
  ? linkColor
1568
2097
  : seg.captionLabel
@@ -1571,7 +2100,7 @@ end = '') {
1571
2100
  const paintText = (seg, at, inLink = false) => {
1572
2101
  const font = quoteFontString(pickResourceFont(seg, fonts));
1573
2102
  const segColor = segColorOf(seg);
1574
- const fontDecl = font !== baseFont ? `font:${font};${tracking !== 0 ? TRACKED_LIGATURES_DECL : ''}` : '';
2103
+ const fontDecl = font !== baseFont ? `font:${font};${tracking !== 0 ? ligaturesDecl() : ''}` : '';
1575
2104
  const colorDecl = segColor !== color ? `color:${segColor};` : '';
1576
2105
  const top = seg.baselineShift ? `${seg.baselineShift.toFixed(3)}px` : '0';
1577
2106
  // Its direction, untracked joining letters and styled runs (#379).
@@ -1644,7 +2173,7 @@ end = '') {
1644
2173
  parts.push(lineMarksHtml(line, color));
1645
2174
  }
1646
2175
  else {
1647
- parts.push(`<span style="position:absolute;left:0;top:0;white-space:pre;">${esc(line.text)}</span>`);
2176
+ parts.push(`<span style="position:absolute;left:0;top:0;white-space:pre;">${wordsHtml(line.text)}</span>`);
1648
2177
  parts.push(lineEndHtml(end, line.bbox.width));
1649
2178
  }
1650
2179
  return (`<div class="pt-line" style="` +
@@ -1656,7 +2185,7 @@ end = '') {
1656
2185
  `color:${color};` +
1657
2186
  // A tracked line (a table header set with `headerLetterSpacing`) was
1658
2187
  // measured with the tracking in its widths.
1659
- (line.letterSpacing ? `letter-spacing:${line.letterSpacing}px;${TRACKED_LIGATURES_DECL}` : '') +
2188
+ (line.letterSpacing ? `letter-spacing:${line.letterSpacing}px;${ligaturesDecl()}` : '') +
1660
2189
  (lineDecl ? cjkTextDecl(!!line.letterSpacing) : '') +
1661
2190
  `">${parts.join('')}</div>`);
1662
2191
  }
@@ -1974,6 +2503,15 @@ function renderBoxAt(bbox, style) {
1974
2503
  boxStyleDecls(style) +
1975
2504
  `"></div>`);
1976
2505
  }
2506
+ /** Whether a line of design text carries one run that is its whole text
2507
+ * in the block's own font, set at its advance with nothing of its own (no
2508
+ * shift, no box, no direction) and no CJK character, which the composer
2509
+ * measures alone: a line without runs by another name. */
2510
+ function isPlainRun(line, block) {
2511
+ const run = line.runs?.length === 1 ? line.runs[0] : undefined;
2512
+ return run !== undefined && line.order === undefined && run.text === line.text && run.fontString === block.fontString && !hasCJK(run.text)
2513
+ && !run.baselineShift && !run.stacked && !run.tcy && !run.orientation && !run.rtl && run.inkOffset === undefined && run.inkScale === undefined;
2514
+ }
1977
2515
  function renderDesignTextBlock(block, options) {
1978
2516
  const parts = [];
1979
2517
  if (block.box)
@@ -1998,34 +2536,67 @@ function renderDesignTextBlock(block, options) {
1998
2536
  // takes no room and the second in one as wide as the pair (EF-80).
1999
2537
  // Each run was measured whole, and so was a line without runs: the
2000
2538
  // browser's punctuation trimming is off on those that hold CJK text.
2001
- const inner = line.runs
2002
- ? line.runs.map((run, i) => {
2003
- const runFont = quoteFontString(run.fontString);
2004
- const cjkDecl = hasCJK(run.text) ? cjkTextDecl(tracked) : '';
2005
- // The `font` shorthand resets `line-height` to `normal`: a run in
2006
- // another face would grow the line box and push the line's
2007
- // baseline down. It keeps the line's own.
2008
- const fontDecl = runFont !== font ? `font:${runFont};line-height:1;${tracked && !cjkDecl ? TRACKED_LIGATURES_DECL : ''}` : '';
2009
- const stackDecl = run.stacked
2010
- ? 'display:inline-block;width:0;'
2011
- : line.runs[i - 1]?.stacked ? `display:inline-block;min-width:${run.width.toFixed(3)}px;` : '';
2012
- const shiftDecl = run.baselineShift ? `position:relative;top:${run.baselineShift.toFixed(3)}px;` : '';
2013
- // A run whose width is its box, not its glyphs' advance (a CJK
2014
- // mark that gave up blank, a Han–Latin space, #637): a box that
2015
- // wide, its glyphs set `inkOffset` into it.
2016
- const boxDecl = run.inkOffset !== undefined && !run.stacked
2017
- ? `display:inline-block;width:${run.width.toFixed(3)}px;${run.inkOffset !== 0 ? `text-indent:${run.inkOffset.toFixed(3)}px;` : ''}`
2018
- : '';
2019
- // A dash of a 破折号 (`inkScale`, #652): a box of its own inside
2020
- // the run's, stretched from where the run's indent puts its
2021
- // glyph, as a body segment's is.
2022
- const text = run.inkScale !== undefined
2023
- ? `<span style="display:inline-block;text-indent:0;${inkScaleDecl(run.inkScale)}">${esc(run.text)}</span>`
2024
- : esc(run.text);
2025
- return fontDecl || cjkDecl || stackDecl || shiftDecl || boxDecl ? `<span style="${fontDecl}${cjkDecl}${stackDecl}${shiftDecl}${boxDecl}">${text}</span>` : text;
2026
- }).join('')
2027
- : esc(line.text);
2028
- const lineDecl = !line.runs && hasCJK(line.text) ? cjkTextDecl(tracked) : '';
2539
+ // A line of several words in the block's font: a box per word (see
2540
+ // `wordBoxesHtml`), in a line box as wide as the line. So is a line
2541
+ // of one run that is its text in the block's font (the CJK composer
2542
+ // gives every line of a design text its runs): written through
2543
+ // `wordsHtml`, the colophon `Set in Noto Serif TC, … (SIL OFL).` in
2544
+ // Source Serif 4 at 13.54 px ended 0.31 px past its 520.63 px, a 64th
2545
+ // of a pixel and more at each of its 16 spaces.
2546
+ const words = line.runs && !isPlainRun(line, block) ? undefined : wordBoxesHtml(line.text, block.fontString, block.letterSpacingPx ?? 0, line.wordSpacingPx ?? 0);
2547
+ const inner = words !== undefined ? words
2548
+ : line.runs
2549
+ ? line.runs.map((run, i) => {
2550
+ const runFont = quoteFontString(run.fontString);
2551
+ const cjkDecl = hasCJK(run.text) ? cjkTextDecl(tracked) : '';
2552
+ // The `font` shorthand resets `line-height` to `normal`: a run in
2553
+ // another face would grow the line box and push the line's
2554
+ // baseline down. It keeps the line's own.
2555
+ const fontDecl = runFont !== font ? `font:${runFont};line-height:1;${tracked && !cjkDecl ? ligaturesDecl() : ''}` : '';
2556
+ // A run whose width is its box comes from the CJK composer,
2557
+ // which measured its characters one by one: its cells are set
2558
+ // with no kerning between them (see {@link CELLS_DECL}).
2559
+ const cellsDecl = run.inkOffset !== undefined ? (cellsOf(run.text) ? CELLS_DECL : '')
2560
+ : designCellsDecl(run.text, run.fontString, run.width, block.letterSpacingPx ?? 0);
2561
+ const stackDecl = run.stacked
2562
+ ? 'display:inline-block;width:0;'
2563
+ : line.runs[i - 1]?.stacked ? `display:inline-block;min-width:${run.width.toFixed(3)}px;` : '';
2564
+ const shiftDecl = run.baselineShift ? `position:relative;top:${run.baselineShift.toFixed(3)}px;` : '';
2565
+ // A run whose width is its box, not its glyphs' advance (a CJK
2566
+ // mark that gave up blank, a Han–Latin space, #637): a box that
2567
+ // wide, its glyphs set `inkOffset` into it.
2568
+ const boxDecl = run.inkOffset !== undefined && !run.stacked
2569
+ ? `display:inline-block;width:${run.width.toFixed(3)}px;${run.inkOffset !== 0 ? `text-indent:${run.inkOffset.toFixed(3)}px;` : ''}`
2570
+ : '';
2571
+ // A dash of a 破折号 (`inkScale`, #652): a box of its own inside
2572
+ // the run's, stretched from where the run's indent puts its
2573
+ // glyph, as a body segment's is.
2574
+ const text = run.inkScale !== undefined
2575
+ ? `<span style="display:inline-block;text-indent:0;${inkScaleDecl(run.inkScale)}">${esc(run.text)}</span>`
2576
+ // A run whose width is its box holds no word space: its box
2577
+ // indents its text, and so would a box inside it.
2578
+ : run.inkOffset !== undefined ? esc(run.text)
2579
+ : wordsHtml(run.text, trackedIn(run.fontString, block.letterSpacingPx));
2580
+ return fontDecl || cjkDecl || stackDecl || shiftDecl || boxDecl ? `<span style="${fontDecl}${cjkDecl}${cellsDecl}${stackDecl}${shiftDecl}${boxDecl}">${text}</span>` : text;
2581
+ }).join('')
2582
+ : letterlessRunHtml(line.text, block.fontString, false, line.width, block.letterSpacingPx ?? 0) ?? wordsHtml(line.text, trackedIn(block.fontString, block.letterSpacingPx));
2583
+ // A line that carries directions (an order, a right-to-left run): a box
2584
+ // per run (see `directedDesignRunsHtml`), unless a run is set otherwise
2585
+ // than as its text at its advance (a stacked script, a box of the CJK
2586
+ // composer).
2587
+ const apartable = line.runs !== undefined
2588
+ && line.runs.every((run) => !run.stacked && run.inkOffset === undefined && run.inkScale === undefined && !run.tcy && !run.orientation);
2589
+ // So is a line of several runs with no CJK text, in a document shaped
2590
+ // in its own language: the canvas starts each run where the widths
2591
+ // before it end, and inline every run, every space in its box and
2592
+ // every span is as wide as a whole number of 64ths of a pixel. The
2593
+ // byline `John Jumper¹,⁴, Richard Evans¹,⁴, …` in Noto Sans at 15.42
2594
+ // px, fifteen runs and fourteen spaces, had its last run 0.32 px past
2595
+ // its place, and three more lines of it 0.30 to 0.46 px; a box per
2596
+ // run, 0.02 px at most.
2597
+ const apart = ownShaping && apartable && words === undefined && line.runs.length > 1 && !line.runs.some((run) => hasCJK(run.text));
2598
+ const directed = apart || (apartable && (line.order !== undefined || line.runs.some((run) => run.rtl)));
2599
+ const lineDecl = !line.runs && hasCJK(line.text) ? cjkTextDecl(tracked) + designCellsDecl(line.text, block.fontString, line.width, block.letterSpacingPx ?? 0) : '';
2029
2600
  // A justified line: its word spaces widened as the canvas and the PDF
2030
2601
  // advance its runs (EF-109).
2031
2602
  const wordSpacingDecl = line.wordSpacingPx ? `word-spacing:${line.wordSpacingPx.toFixed(3)}px;` : '';
@@ -2037,13 +2608,14 @@ function renderDesignTextBlock(block, options) {
2037
2608
  `position:absolute;` +
2038
2609
  `left:${line.xOffset.toFixed(3)}px;` +
2039
2610
  `top:${top.toFixed(3)}px;` +
2040
- `line-height:1;white-space:pre;` +
2611
+ (directed ? 'line-height:1;' : 'line-height:1;white-space:pre;') +
2612
+ (words !== undefined ? `width:${line.width.toFixed(3)}px;` : '') +
2041
2613
  wordSpacingDecl +
2042
2614
  lineDecl +
2043
- `">${inner}</span>`);
2615
+ `">${directed ? directedDesignRunsHtml(line, block, font, tracked) : inner}</span>`);
2044
2616
  }
2045
2617
  const clipDecl = block.clip ? 'overflow:hidden;' : '';
2046
- const trackingDecl = block.letterSpacingPx ? `letter-spacing:${block.letterSpacingPx}px;${TRACKED_LIGATURES_DECL}` : '';
2618
+ const trackingDecl = block.letterSpacingPx ? `letter-spacing:${block.letterSpacingPx}px;${ligaturesDecl()}` : '';
2047
2619
  // An outline over the glyphs (centred on their edges, as on canvas and in
2048
2620
  // the PDF); hollow letters leave the fill transparent.
2049
2621
  const strokeDecl = block.stroke && block.stroke.widthPx > 0
@@ -2180,7 +2752,7 @@ function renderBlockInner(block, options) {
2180
2752
  parts.push(v ? renderVerticalBullet(block, v) : renderBullet(block, options.dir));
2181
2753
  block.lines.forEach((line, i) => {
2182
2754
  const next = block.lines[i + 1];
2183
- const lead = i === 0 && block.dropCap && !v ? dropCapHtml(block.dropCap, line, quoteFontString(block.fontString)) : '';
2755
+ const lead = i === 0 && block.dropCap && !v ? dropCapHtml(block.dropCap, line, quoteFontString(block.fontString), (block.letterSpacing ?? 0) + (line.letterSpacing ?? 0)) : '';
2184
2756
  parts.push(v ? renderVerticalLine(line, block, v, options.linkTargets, next) : renderLine(line, block, options.linkTargets, options.dir, options.rootLang, next, lead));
2185
2757
  });
2186
2758
  return parts.join('');
@@ -2467,6 +3039,12 @@ export function renderToHtmlIndexed(doc, options = {}) {
2467
3039
  ? Math.max(0, doc.trimOffset - dimensionToPx(doc.config.page.cutLines.bleed, doc.config.page.dpi))
2468
3040
  : 0;
2469
3041
  const svgUrl = svgFontInliner(doc, options);
3042
+ // The language the pages' text is shaped in, for the words set whole
3043
+ // (#770): the declared one, else the writing page's.
3044
+ shapedText = shapedTextOf(docLang);
3045
+ // A document shaped in its own language sets a tracked ligature with
3046
+ // the tracking of each of its letters, as the layout counted it (#770).
3047
+ ownShaping = shapedIn !== undefined && shapedText !== null;
2470
3048
  for (const p of doc.pages) {
2471
3049
  const pageOptions = onWarning
2472
3050
  ? {
@@ -2495,6 +3073,8 @@ export function renderToHtmlIndexed(doc, options = {}) {
2495
3073
  ...(detail.comicHtml ? { comicHtml: detail.comicHtml } : {}),
2496
3074
  });
2497
3075
  }
3076
+ shapedText = null;
3077
+ ownShaping = false;
2498
3078
  // The host's inherited text properties are reset first (EF-96). A
2499
3079
  // Chinese, Japanese or Korean document declares its language, so the
2500
3080
  // browser picks the region's glyph forms, and so does a right-to-left one,