postext 1.25.5 → 1.26.1

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 (218) hide show
  1. package/dist/__tests__/cjk/headingNumberMark.test.d.ts +2 -0
  2. package/dist/__tests__/cjk/headingNumberMark.test.d.ts.map +1 -0
  3. package/dist/__tests__/cjk/headingNumberMark.test.js +108 -0
  4. package/dist/__tests__/cjk/headingNumberMark.test.js.map +1 -0
  5. package/dist/__tests__/cjk/japaneseDash.test.d.ts +2 -0
  6. package/dist/__tests__/cjk/japaneseDash.test.d.ts.map +1 -0
  7. package/dist/__tests__/cjk/japaneseDash.test.js +250 -0
  8. package/dist/__tests__/cjk/japaneseDash.test.js.map +1 -0
  9. package/dist/__tests__/cjk/japaneseSpacing.test.js +1 -1
  10. package/dist/__tests__/cjk/punctuation.test.js +5 -3
  11. package/dist/__tests__/cjk/punctuation.test.js.map +1 -1
  12. package/dist/__tests__/columnRuleBands.test.js +47 -0
  13. package/dist/__tests__/columnRuleBands.test.js.map +1 -1
  14. package/dist/__tests__/comics/canvas.test.js +38 -1
  15. package/dist/__tests__/comics/canvas.test.js.map +1 -1
  16. package/dist/__tests__/comics/html.test.js +16 -3
  17. package/dist/__tests__/comics/html.test.js.map +1 -1
  18. package/dist/__tests__/design/cjkDesignDash.test.js +7 -4
  19. package/dist/__tests__/design/cjkDesignDash.test.js.map +1 -1
  20. package/dist/__tests__/englishApostropheHyphenation.test.d.ts +2 -0
  21. package/dist/__tests__/englishApostropheHyphenation.test.d.ts.map +1 -0
  22. package/dist/__tests__/englishApostropheHyphenation.test.js +47 -0
  23. package/dist/__tests__/englishApostropheHyphenation.test.js.map +1 -0
  24. package/dist/__tests__/exports.test.js +1 -0
  25. package/dist/__tests__/exports.test.js.map +1 -1
  26. package/dist/__tests__/leaderMinRepeats.test.d.ts +2 -0
  27. package/dist/__tests__/leaderMinRepeats.test.d.ts.map +1 -0
  28. package/dist/__tests__/leaderMinRepeats.test.js +109 -0
  29. package/dist/__tests__/leaderMinRepeats.test.js.map +1 -0
  30. package/dist/__tests__/pipeline/footnoteRuntTracking.test.d.ts +2 -0
  31. package/dist/__tests__/pipeline/footnoteRuntTracking.test.d.ts.map +1 -0
  32. package/dist/__tests__/pipeline/footnoteRuntTracking.test.js +90 -0
  33. package/dist/__tests__/pipeline/footnoteRuntTracking.test.js.map +1 -0
  34. package/dist/__tests__/pipeline/orderedNumberAlign.test.d.ts +2 -0
  35. package/dist/__tests__/pipeline/orderedNumberAlign.test.d.ts.map +1 -0
  36. package/dist/__tests__/pipeline/orderedNumberAlign.test.js +108 -0
  37. package/dist/__tests__/pipeline/orderedNumberAlign.test.js.map +1 -0
  38. package/dist/__tests__/pipeline/trailingCapColumnNote.test.d.ts +2 -0
  39. package/dist/__tests__/pipeline/trailingCapColumnNote.test.d.ts.map +1 -0
  40. package/dist/__tests__/pipeline/trailingCapColumnNote.test.js +63 -0
  41. package/dist/__tests__/pipeline/trailingCapColumnNote.test.js.map +1 -0
  42. package/dist/__tests__/pipeline/trailingCapFloatHead.test.d.ts +2 -0
  43. package/dist/__tests__/pipeline/trailingCapFloatHead.test.d.ts.map +1 -0
  44. package/dist/__tests__/pipeline/trailingCapFloatHead.test.js +91 -0
  45. package/dist/__tests__/pipeline/trailingCapFloatHead.test.js.map +1 -0
  46. package/dist/__tests__/rtl/arabicLeadingNumbers.test.d.ts +2 -0
  47. package/dist/__tests__/rtl/arabicLeadingNumbers.test.d.ts.map +1 -0
  48. package/dist/__tests__/rtl/arabicLeadingNumbers.test.js +186 -0
  49. package/dist/__tests__/rtl/arabicLeadingNumbers.test.js.map +1 -0
  50. package/dist/__tests__/rtl/listMarkerDirection.test.d.ts +2 -0
  51. package/dist/__tests__/rtl/listMarkerDirection.test.d.ts.map +1 -0
  52. package/dist/__tests__/rtl/listMarkerDirection.test.js +197 -0
  53. package/dist/__tests__/rtl/listMarkerDirection.test.js.map +1 -0
  54. package/dist/__tests__/trackedLigatures.test.d.ts +2 -0
  55. package/dist/__tests__/trackedLigatures.test.d.ts.map +1 -0
  56. package/dist/__tests__/trackedLigatures.test.js +587 -0
  57. package/dist/__tests__/trackedLigatures.test.js.map +1 -0
  58. package/dist/__tests__/vectorRasterScale.test.d.ts +2 -0
  59. package/dist/__tests__/vectorRasterScale.test.d.ts.map +1 -0
  60. package/dist/__tests__/vectorRasterScale.test.js +304 -0
  61. package/dist/__tests__/vectorRasterScale.test.js.map +1 -0
  62. package/dist/bidi.d.ts +43 -3
  63. package/dist/bidi.d.ts.map +1 -1
  64. package/dist/bidi.js +29 -7
  65. package/dist/bidi.js.map +1 -1
  66. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  67. package/dist/canvas-backend/blockRender.js +43 -5
  68. package/dist/canvas-backend/blockRender.js.map +1 -1
  69. package/dist/canvas-backend/comic.d.ts +8 -4
  70. package/dist/canvas-backend/comic.d.ts.map +1 -1
  71. package/dist/canvas-backend/comic.js +13 -6
  72. package/dist/canvas-backend/comic.js.map +1 -1
  73. package/dist/canvas-backend/deviceScale.d.ts +31 -0
  74. package/dist/canvas-backend/deviceScale.d.ts.map +1 -0
  75. package/dist/canvas-backend/deviceScale.js +45 -0
  76. package/dist/canvas-backend/deviceScale.js.map +1 -0
  77. package/dist/canvas-backend/index.d.ts +5 -1
  78. package/dist/canvas-backend/index.d.ts.map +1 -1
  79. package/dist/canvas-backend/index.js.map +1 -1
  80. package/dist/canvas-backend/mirrorFrame.d.ts +14 -1
  81. package/dist/canvas-backend/mirrorFrame.d.ts.map +1 -1
  82. package/dist/canvas-backend/mirrorFrame.js +51 -7
  83. package/dist/canvas-backend/mirrorFrame.js.map +1 -1
  84. package/dist/canvas-backend/renderResourceBlock.d.ts +5 -3
  85. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  86. package/dist/canvas-backend/renderResourceBlock.js +39 -6
  87. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  88. package/dist/canvas-backend/trackedText.d.ts +72 -0
  89. package/dist/canvas-backend/trackedText.d.ts.map +1 -0
  90. package/dist/canvas-backend/trackedText.js +166 -0
  91. package/dist/canvas-backend/trackedText.js.map +1 -0
  92. package/dist/canvas-backend/wordRuns.d.ts +6 -0
  93. package/dist/canvas-backend/wordRuns.d.ts.map +1 -1
  94. package/dist/canvas-backend/wordRuns.js +11 -1
  95. package/dist/canvas-backend/wordRuns.js.map +1 -1
  96. package/dist/columnRule.d.ts +2 -1
  97. package/dist/columnRule.d.ts.map +1 -1
  98. package/dist/columnRule.js +11 -3
  99. package/dist/columnRule.js.map +1 -1
  100. package/dist/comics/layoutPage.d.ts.map +1 -1
  101. package/dist/comics/layoutPage.js +54 -2
  102. package/dist/comics/layoutPage.js.map +1 -1
  103. package/dist/comics/lettering/__tests__/ellipsisFace.test.d.ts +2 -0
  104. package/dist/comics/lettering/__tests__/ellipsisFace.test.d.ts.map +1 -0
  105. package/dist/comics/lettering/__tests__/ellipsisFace.test.js +54 -0
  106. package/dist/comics/lettering/__tests__/ellipsisFace.test.js.map +1 -0
  107. package/dist/comics/lettering/__tests__/polish.test.js +8 -0
  108. package/dist/comics/lettering/__tests__/polish.test.js.map +1 -1
  109. package/dist/comics/lettering/__tests__/round4.test.d.ts +2 -0
  110. package/dist/comics/lettering/__tests__/round4.test.d.ts.map +1 -0
  111. package/dist/comics/lettering/__tests__/round4.test.js +212 -0
  112. package/dist/comics/lettering/__tests__/round4.test.js.map +1 -0
  113. package/dist/comics/lettering/letter.js +2 -2
  114. package/dist/comics/lettering/letter.js.map +1 -1
  115. package/dist/comics/lettering/placement.d.ts +10 -3
  116. package/dist/comics/lettering/placement.d.ts.map +1 -1
  117. package/dist/comics/lettering/placement.js +162 -22
  118. package/dist/comics/lettering/placement.js.map +1 -1
  119. package/dist/comics/lettering/text.d.ts.map +1 -1
  120. package/dist/comics/lettering/text.js +25 -3
  121. package/dist/comics/lettering/text.js.map +1 -1
  122. package/dist/comics/lettering/types.d.ts +29 -2
  123. package/dist/comics/lettering/types.d.ts.map +1 -1
  124. package/dist/defaults/orderedLists.d.ts +1 -0
  125. package/dist/defaults/orderedLists.d.ts.map +1 -1
  126. package/dist/defaults/orderedLists.js +7 -0
  127. package/dist/defaults/orderedLists.js.map +1 -1
  128. package/dist/design/bidiText.d.ts +5 -2
  129. package/dist/design/bidiText.d.ts.map +1 -1
  130. package/dist/design/bidiText.js +7 -4
  131. package/dist/design/bidiText.js.map +1 -1
  132. package/dist/design/layout.d.ts +5 -0
  133. package/dist/design/layout.d.ts.map +1 -1
  134. package/dist/design/layout.js +1 -1
  135. package/dist/design/layout.js.map +1 -1
  136. package/dist/html-backend.d.ts.map +1 -1
  137. package/dist/html-backend.js +32 -6
  138. package/dist/html-backend.js.map +1 -1
  139. package/dist/htmlComic.d.ts +2 -1
  140. package/dist/htmlComic.d.ts.map +1 -1
  141. package/dist/htmlComic.js +14 -1
  142. package/dist/htmlComic.js.map +1 -1
  143. package/dist/hyphenate.d.ts.map +1 -1
  144. package/dist/hyphenate.js +52 -0
  145. package/dist/hyphenate.js.map +1 -1
  146. package/dist/index.d.ts +3 -3
  147. package/dist/index.d.ts.map +1 -1
  148. package/dist/index.js +1 -1
  149. package/dist/index.js.map +1 -1
  150. package/dist/math/rasterCache.d.ts +7 -1
  151. package/dist/math/rasterCache.d.ts.map +1 -1
  152. package/dist/math/rasterCache.js +23 -11
  153. package/dist/math/rasterCache.js.map +1 -1
  154. package/dist/measure/bidiLines.d.ts +8 -0
  155. package/dist/measure/bidiLines.d.ts.map +1 -1
  156. package/dist/measure/bidiLines.js +13 -0
  157. package/dist/measure/bidiLines.js.map +1 -1
  158. package/dist/measure/cache.d.ts.map +1 -1
  159. package/dist/measure/cache.js +5 -3
  160. package/dist/measure/cache.js.map +1 -1
  161. package/dist/measure/cjkCompose.d.ts +10 -0
  162. package/dist/measure/cjkCompose.d.ts.map +1 -1
  163. package/dist/measure/cjkCompose.js +95 -19
  164. package/dist/measure/cjkCompose.js.map +1 -1
  165. package/dist/measure/cjkPunctuation.d.ts +10 -0
  166. package/dist/measure/cjkPunctuation.d.ts.map +1 -1
  167. package/dist/measure/cjkPunctuation.js +18 -1
  168. package/dist/measure/cjkPunctuation.js.map +1 -1
  169. package/dist/measure/hardBreaks.d.ts.map +1 -1
  170. package/dist/measure/hardBreaks.js +4 -1
  171. package/dist/measure/hardBreaks.js.map +1 -1
  172. package/dist/measure/leader.d.ts +6 -1
  173. package/dist/measure/leader.d.ts.map +1 -1
  174. package/dist/measure/leader.js +8 -3
  175. package/dist/measure/leader.js.map +1 -1
  176. package/dist/measure/plain.d.ts.map +1 -1
  177. package/dist/measure/plain.js +4 -2
  178. package/dist/measure/plain.js.map +1 -1
  179. package/dist/measure/rich.d.ts.map +1 -1
  180. package/dist/measure/rich.js +7 -4
  181. package/dist/measure/rich.js.map +1 -1
  182. package/dist/measure/types.d.ts +7 -0
  183. package/dist/measure/types.d.ts.map +1 -1
  184. package/dist/measure/types.js.map +1 -1
  185. package/dist/pipeline/build.d.ts.map +1 -1
  186. package/dist/pipeline/build.js +38 -5
  187. package/dist/pipeline/build.js.map +1 -1
  188. package/dist/pipeline/buildBlockKind.d.ts +5 -0
  189. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  190. package/dist/pipeline/buildBlockKind.js +1 -0
  191. package/dist/pipeline/buildBlockKind.js.map +1 -1
  192. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  193. package/dist/pipeline/calloutLayout.js +8 -4
  194. package/dist/pipeline/calloutLayout.js.map +1 -1
  195. package/dist/pipeline/citations.d.ts.map +1 -1
  196. package/dist/pipeline/citations.js +52 -6
  197. package/dist/pipeline/citations.js.map +1 -1
  198. package/dist/pipeline/config.d.ts +6 -0
  199. package/dist/pipeline/config.d.ts.map +1 -1
  200. package/dist/pipeline/config.js +12 -1
  201. package/dist/pipeline/config.js.map +1 -1
  202. package/dist/pipeline/headerFooter.d.ts +2 -0
  203. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  204. package/dist/pipeline/headerFooter.js +3 -1
  205. package/dist/pipeline/headerFooter.js.map +1 -1
  206. package/dist/pipeline/lists.d.ts +8 -0
  207. package/dist/pipeline/lists.d.ts.map +1 -1
  208. package/dist/pipeline/lists.js +18 -1
  209. package/dist/pipeline/lists.js.map +1 -1
  210. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  211. package/dist/pipeline/measureContentBlock.js +3 -0
  212. package/dist/pipeline/measureContentBlock.js.map +1 -1
  213. package/dist/types.d.ts +13 -0
  214. package/dist/types.d.ts.map +1 -1
  215. package/dist/vdt.d.ts +9 -0
  216. package/dist/vdt.d.ts.map +1 -1
  217. package/dist/vdt.js.map +1 -1
  218. package/package.json +1 -1
@@ -0,0 +1,587 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderPageToCanvas } from '../canvas-backend';
3
+ import { renderBlock } from '../canvas-backend/blockRender';
4
+ import { beginMirroredFlow } from '../canvas-backend/mirrorFrame';
5
+ import { fillTrackedWord, trackedPieces, shapedOtherwise } from '../canvas-backend/trackedText';
6
+ import { renderToHtml } from '../html-backend';
7
+ import { buildDocument } from '../pipeline';
8
+ // #663: a tracked word is measured at its natural width, ligatures counted,
9
+ // plus the tracking times its letters. A browser shapes it without its
10
+ // common ligatures once `letterSpacing` is not zero, so the canvas painted
11
+ // "first" wider than its place and the word space after it closed up
12
+ // ("firstis"). The word is painted in pieces instead, its ligature whole;
13
+ // the HTML asks for the ligatures as features.
14
+ // A face with ff, fi, fl and ffi ligatures narrower than their letters, as a
15
+ // browser measures it: under a letter-spacing the ligatures are off and the
16
+ // spacing follows every letter.
17
+ const ADVANCE = (ch) => {
18
+ if (ch === ' ')
19
+ return 0.25;
20
+ if ('ilf.,'.includes(ch))
21
+ return 0.3;
22
+ if (/[A-Z]/.test(ch))
23
+ return 0.66;
24
+ return 0.5;
25
+ };
26
+ // An Arabic and a Hebrew word, and the ligature each holds in this face
27
+ // (lam + alef, alef + lamed), which the letter-spacing turns off as well.
28
+ const SALAM = '\u0633\u0644\u0627\u0645';
29
+ const ISRAEL = '\u05D9\u05E9\u05E8\u05D0\u05DC';
30
+ /** A Hebrew word with no ligature. */
31
+ const SHALOM = '\u05E9\u05DC\u05D5\u05DD';
32
+ const LIGATURES = [['ffi', 0.75], ['ff', 0.5], ['fi', 0.5], ['fl', 0.5], ['\u0644\u0627', 0.5], ['\u05D0\u05DC', 0.5]];
33
+ function naturalWidth(text, size, ligatures) {
34
+ let w = 0;
35
+ for (let i = 0; i < text.length;) {
36
+ const lig = ligatures ? LIGATURES.find(([l]) => text.startsWith(l, i)) : undefined;
37
+ if (lig) {
38
+ w += lig[1] * size;
39
+ i += lig[0].length;
40
+ }
41
+ else {
42
+ w += ADVANCE(text[i]) * size;
43
+ i++;
44
+ }
45
+ }
46
+ return w;
47
+ }
48
+ const sizeOf = (font) => parseFloat(/(\d*\.?\d+)px/.exec(font)?.[1] ?? '10');
49
+ /** The measuring half of a browser's 2D context. `keeps`: a browser that
50
+ * keeps the ligatures under a letter-spacing, so no word is cut. */
51
+ class BrowserCtx {
52
+ keeps;
53
+ font = '10px Body';
54
+ letterSpacing = '0px';
55
+ measured = 0;
56
+ constructor(keeps = false) {
57
+ this.keeps = keeps;
58
+ }
59
+ measureText(s) {
60
+ this.measured++;
61
+ const spacing = parseFloat(this.letterSpacing) || 0;
62
+ return { width: naturalWidth(s, sizeOf(this.font), this.keeps || spacing === 0) + spacing * s.length };
63
+ }
64
+ }
65
+ function paintingCtx() {
66
+ const seen = [];
67
+ const ctx = Object.assign(new BrowserCtx(), {
68
+ fillStyle: '', textBaseline: 'alphabetic',
69
+ fillText(text, x, y) {
70
+ seen.push({ text, x, y, spacing: parseFloat(this.letterSpacing) || 0, width: this.measureText(text).width });
71
+ },
72
+ save() { }, restore() { }, beginPath() { }, rect() { }, clip() { }, fillRect() { }, moveTo() { }, lineTo() { }, stroke() { },
73
+ });
74
+ return { ctx: ctx, seen };
75
+ }
76
+ // The layout measures through an OffscreenCanvas at letter-spacing 0.
77
+ globalThis.OffscreenCanvas = class {
78
+ getContext() {
79
+ return new BrowserCtx();
80
+ }
81
+ };
82
+ describe('the pieces of a tracked word (#663)', () => {
83
+ it('a word with no ligature is painted whole', () => {
84
+ const { ctx } = paintingCtx();
85
+ expect(shapedOtherwise(ctx, 'hands')).toBe(false);
86
+ expect(trackedPieces(ctx, 'hands')).toBeNull();
87
+ });
88
+ it('a ligature is a piece of its own, set where the natural shaping puts it', () => {
89
+ const { ctx } = paintingCtx();
90
+ expect(shapedOtherwise(ctx, 'first')).toBe(true);
91
+ expect(trackedPieces(ctx, 'first')).toEqual([
92
+ { text: 'fi', offset: 0, glyphsBefore: 0, ligature: true },
93
+ { text: 'rst', offset: 5, glyphsBefore: 1, ligature: false },
94
+ ]);
95
+ // o | ffi | ces: the three letters of one ligature stay together.
96
+ expect(trackedPieces(ctx, 'offices').map((p) => [p.text, p.offset, p.glyphsBefore, p.ligature])).toEqual([
97
+ ['o', 0, 0, false],
98
+ ['ffi', 5, 1, true],
99
+ ['ces', 12.5, 2, false],
100
+ ]);
101
+ });
102
+ it('leaves the letter-spacing as it found it and measures a word once', () => {
103
+ const { ctx } = paintingCtx();
104
+ ctx.letterSpacing = '-0.2px';
105
+ trackedPieces(ctx, 'reflect');
106
+ expect(ctx.letterSpacing).toBe('-0.2px');
107
+ const before = ctx.measured;
108
+ trackedPieces(ctx, 'reflect');
109
+ shapedOtherwise(ctx, 'reflect');
110
+ expect(ctx.measured).toBe(before);
111
+ });
112
+ it('a context that measures no letter-spacing paints every word whole', () => {
113
+ const stub = { font: '10px Stub', letterSpacing: '0px', measureText: (s) => ({ width: s.length * 5 }) };
114
+ expect(trackedPieces(stub, 'first')).toBeNull();
115
+ expect(shapedOtherwise(stub, 'first')).toBe(false);
116
+ expect(trackedPieces({ font: '10px Other' }, 'first')).toBeNull();
117
+ });
118
+ });
119
+ const pt = (value) => ({ value, unit: 'pt' });
120
+ // 72 dpi: 1 pt = 1 px.
121
+ const CONFIG = {
122
+ page: { width: pt(160), height: pt(900), dpi: 72, margins: { top: pt(10), bottom: pt(10), left: pt(10), right: pt(10) } },
123
+ layout: { layoutType: 'single' },
124
+ header: { elements: [] },
125
+ footer: { elements: [] },
126
+ bodyText: { fontSize: pt(10), lineHeight: pt(14), firstLineIndent: pt(0), hyphenation: { enabled: false } },
127
+ };
128
+ // Four lines set naturally, the last a runt; three with 10‰ of negative
129
+ // tracking (`tightenRunts`). Justified, the second line holds no ligature;
130
+ // ragged, the third.
131
+ const JUSTIFIED = 'fire stood the offices first is office within lets stood lets left stood is offices is office first a fire.';
132
+ const RAGGED = 'is of office stood stood find osmosis first of osmosis lets fire its lets within is within and lets.';
133
+ const paragraph = (doc) => doc.blocks.find((b) => b.type === 'paragraph');
134
+ const tracked = (text, config = CONFIG) => {
135
+ const doc = buildDocument({ markdown: text }, config);
136
+ return { doc, block: paragraph(doc) };
137
+ };
138
+ const LEFT = { ...CONFIG, bodyText: { ...CONFIG.bodyText, textAlign: 'left' } };
139
+ /** Where the ink of each word of `line` ends against where its segment
140
+ * does, from the pieces the canvas painted inside it. */
141
+ function wordEnds(block, line, seen) {
142
+ const segments = line.segments;
143
+ const justified = block.textAlign === 'justify' && !line.isLastLine;
144
+ const wordWidth = segments.reduce((w, s) => (s.kind === 'space' ? w : w + s.width), 0);
145
+ const spaces = segments.filter((s) => s.kind === 'space').length;
146
+ const out = [];
147
+ let x = line.bbox.x;
148
+ for (const seg of segments) {
149
+ if (seg.kind === 'space') {
150
+ x += justified ? (block.bbox.width - wordWidth) / spaces : seg.width;
151
+ continue;
152
+ }
153
+ const pieces = seen.filter((p) => p.y === line.baseline && (Math.abs(p.x - x) < 1e-6 || (p.x > x && p.x < x + seg.width - 1e-6)));
154
+ expect(pieces.map((p) => p.text).join('')).toBe(seg.text);
155
+ out.push({ text: seg.text, ink: Math.max(...pieces.map((p) => p.x + p.width)), place: x + seg.width });
156
+ x += seg.width;
157
+ }
158
+ return out;
159
+ }
160
+ describe('a tracked paragraph on canvas (#663)', () => {
161
+ it('the paragraphs are tightened and hold ligatures', () => {
162
+ for (const block of [tracked(JUSTIFIED).block, tracked(RAGGED, LEFT).block]) {
163
+ expect(block.lines).toHaveLength(3);
164
+ expect(block.letterSpacing).toBeCloseTo(-0.1, 5);
165
+ expect(block.lines.filter((l) => /fi/.test(l.text)).length).toBe(2);
166
+ }
167
+ });
168
+ it('paints a word with a ligature in its place: the ligature whole, the rest tracked', () => {
169
+ const { block } = tracked(JUSTIFIED);
170
+ const t = block.letterSpacing;
171
+ const { ctx, seen } = paintingCtx();
172
+ renderBlock(ctx, block, block.bbox.width, block.bbox.x);
173
+ // The ligatures were painted, each with no letter-spacing.
174
+ const ligatures = seen.filter((p) => p.text === 'fi' || p.text === 'ffi');
175
+ expect(ligatures).toHaveLength(8);
176
+ expect(ligatures.every((p) => p.spacing === 0)).toBe(true);
177
+ // No word shaped without its ligature, and every other piece tracked.
178
+ expect(seen.some((p) => /fi/.test(p.text) && p.spacing !== 0)).toBe(false);
179
+ expect(seen.filter((p) => !ligatures.includes(p)).every((p) => Math.abs(p.spacing - t) < 1e-9)).toBe(true);
180
+ // Each word ends where its segment does: within the tracking the
181
+ // letters of its ligatures do not take.
182
+ for (const line of block.lines) {
183
+ for (const word of wordEnds(block, line, seen)) {
184
+ expect(Math.abs(word.ink - word.place)).toBeLessThanOrEqual(2 * Math.abs(t) + 1e-6);
185
+ }
186
+ }
187
+ });
188
+ it('painted with the letter-spacing alone, the same words would overrun their places', () => {
189
+ const { block } = tracked(JUSTIFIED);
190
+ const t = block.letterSpacing;
191
+ const measure = new BrowserCtx();
192
+ measure.letterSpacing = `${t}px`;
193
+ const first = block.lines.flatMap((l) => l.segments).find((s) => s.text === 'first');
194
+ // "first" without its ligature: one letter's advance wider than the place.
195
+ expect(measure.measureText('first').width - first.width).toBeCloseTo(1, 5);
196
+ });
197
+ it('a ragged tracked line painted as one text goes word by word when it holds a ligature', () => {
198
+ const { block } = tracked(RAGGED, LEFT);
199
+ const t = block.letterSpacing;
200
+ const { ctx, seen } = paintingCtx();
201
+ renderBlock(ctx, block, block.bbox.width, block.bbox.x);
202
+ const [one, two, three] = block.lines;
203
+ for (const line of [one, two]) {
204
+ expect(seen.some((p) => p.text === line.text)).toBe(false);
205
+ for (const word of wordEnds(block, line, seen))
206
+ expect(Math.abs(word.ink - word.place)).toBeLessThanOrEqual(2 * Math.abs(t) + 1e-6);
207
+ }
208
+ expect(seen.filter((p) => p.text === 'fi' || p.text === 'ffi').every((p) => p.spacing === 0)).toBe(true);
209
+ // The line with no ligature: one fillText, as before.
210
+ expect(seen.filter((p) => p.text === three.text)).toHaveLength(1);
211
+ expect(ctx.letterSpacing).toBe('0px');
212
+ });
213
+ it('an untracked paragraph is painted as before, nothing measured', () => {
214
+ const doc = buildDocument({ markdown: 'The first of its offices.' }, LEFT);
215
+ const block = paragraph(doc);
216
+ expect(block.letterSpacing).toBeUndefined();
217
+ const ctx = Object.assign(new BrowserCtx(), {
218
+ fillStyle: '', textBaseline: 'alphabetic', texts: [],
219
+ fillText(text) { this.texts.push(text); },
220
+ });
221
+ renderBlock(ctx, block, block.bbox.width, block.bbox.x);
222
+ expect(ctx.texts).toEqual(['The first of its offices.']);
223
+ expect(ctx.measured).toBe(0);
224
+ });
225
+ });
226
+ describe('a tracked line in HTML (#663)', () => {
227
+ it('asks for its ligatures after the letter-spacing', () => {
228
+ const { doc, block } = tracked(JUSTIFIED);
229
+ const html = renderToHtml(doc);
230
+ const t = block.letterSpacing;
231
+ expect(html).toContain(`letter-spacing:${t}px;font-feature-settings:'liga' 1,'clig' 1;`);
232
+ });
233
+ it('a run in another face asks again after its own font', () => {
234
+ const doc = buildDocument({ markdown: JUSTIFIED.replace('first is', '**first** is') }, CONFIG);
235
+ expect(paragraph(doc).letterSpacing).toBeLessThan(0);
236
+ expect(renderToHtml(doc)).toMatch(/<span style="font:[^;]+;font-feature-settings:'liga' 1,'clig' 1;line-height:0;[^"]*">first<\/span>/);
237
+ });
238
+ it('an untracked line carries no feature', () => {
239
+ const html = renderToHtml(buildDocument({ markdown: 'The **first** of its offices.' }, CONFIG));
240
+ expect(html).not.toContain("'liga'");
241
+ });
242
+ });
243
+ const mul = (m, n) => [
244
+ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
245
+ m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
246
+ m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
247
+ ];
248
+ const leftOf = (r) => Math.min(r.x, r.x + r.ux * r.width);
249
+ const rightOf = (r) => Math.max(r.x, r.x + r.ux * r.width);
250
+ /** A browser's 2D context (the measurer above) that keeps its transform
251
+ * and its state. `keeps`: see {@link BrowserCtx}; its fonts carry another
252
+ * name, so what is known of a word in one browser is not taken for the
253
+ * other. */
254
+ function sheetCanvas(keeps = false) {
255
+ const runs = [];
256
+ let m = [1, 0, 0, 1, 0, 0];
257
+ const stack = [];
258
+ const measure = new BrowserCtx(keeps);
259
+ const state = { font: '10px Body', textAlign: 'left', direction: 'ltr', textBaseline: 'alphabetic', letterSpacing: '0px', fillStyle: '' };
260
+ const measureText = (s) => {
261
+ measure.font = state.font;
262
+ measure.letterSpacing = state.letterSpacing;
263
+ return measure.measureText(s);
264
+ };
265
+ const api = {
266
+ save() { stack.push({ m, state: { ...state } }); },
267
+ restore() {
268
+ const top = stack.pop();
269
+ if (!top)
270
+ return;
271
+ m = top.m;
272
+ Object.assign(state, top.state);
273
+ },
274
+ transform(a, b, c, d, e, f) { m = mul(m, [a, b, c, d, e, f]); },
275
+ translate(x, y) { m = mul(m, [1, 0, 0, 1, x, y]); },
276
+ scale(x, y) { m = mul(m, [x, 0, 0, y, 0, 0]); },
277
+ rotate(t) { m = mul(m, [Math.cos(t), Math.sin(t), -Math.sin(t), Math.cos(t), 0, 0]); },
278
+ measureText,
279
+ fillText(text, x, y) {
280
+ const width = measureText(text).width;
281
+ // The run's box from its alignment, as a browser places it.
282
+ const rtl = state.direction === 'rtl';
283
+ const from = state.textAlign === 'right' || (state.textAlign === 'start' && rtl) || (state.textAlign === 'end' && !rtl) ? x - width : x;
284
+ runs.push({
285
+ text, x: m[0] * from + m[2] * y + m[4], y: m[1] * from + m[3] * y + m[5], ux: m[0], uy: m[1], det: m[0] * m[3] - m[1] * m[2],
286
+ width, baseline: y, spacing: parseFloat(state.letterSpacing) || 0, direction: state.direction, font: state.font,
287
+ });
288
+ },
289
+ };
290
+ const ctx = new Proxy(state, {
291
+ get(target, key) {
292
+ if (typeof key === 'string' && key in api)
293
+ return api[key];
294
+ if (typeof key === 'string' && key in target)
295
+ return target[key];
296
+ return () => undefined;
297
+ },
298
+ set(target, key, value) {
299
+ // The mirrored frame puts its own `fillText` on the context.
300
+ if (typeof key === 'string' && key in api)
301
+ api[key] = value;
302
+ else
303
+ target[key] = key === 'font' && keeps ? `${value}, Keeps` : value;
304
+ return true;
305
+ },
306
+ has(target, key) { return typeof key === 'string' && (key in api || key in target); },
307
+ deleteProperty() { return true; },
308
+ getOwnPropertyDescriptor() { return undefined; },
309
+ });
310
+ const canvas = { width: 0, height: 0, getContext: () => ctx };
311
+ return { canvas, ctx, runs };
312
+ }
313
+ function runsOn(doc, page = 0, keeps = false) {
314
+ const { canvas, runs } = sheetCanvas(keeps);
315
+ renderPageToCanvas(doc.pages[page], doc, canvas);
316
+ return runs;
317
+ }
318
+ /** The runs on each baseline of an upright page, from the sheet's left. */
319
+ function rows(runs) {
320
+ const by = new Map();
321
+ for (const r of runs)
322
+ by.set(r.baseline, [...(by.get(r.baseline) ?? []), r]);
323
+ return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row.sort((a, b) => leftOf(a) - leftOf(b)));
324
+ }
325
+ /**
326
+ * The page painted by a browser that drops the ligatures of a tracked word
327
+ * against the same page painted by one that keeps them, which paints every
328
+ * word whole and so where the frame, the line's order and any turn of the
329
+ * block put it. A word in pieces must start where the whole word starts,
330
+ * read the same way, and hold its pieces at their places in the natural
331
+ * shaping. Returns the words painted in pieces. For pages whose tracked
332
+ * lines are painted word by word in either browser.
333
+ */
334
+ function againstWholeWords(doc, page = 0) {
335
+ const cut = runsOn(doc, page);
336
+ const whole = runsOn(doc, page, true);
337
+ const words = [];
338
+ let j = 0;
339
+ for (const w of whole) {
340
+ const pieces = [];
341
+ let text = '';
342
+ while (text.length < w.text.length && j < cut.length) {
343
+ pieces.push(cut[j]);
344
+ text += cut[j++].text;
345
+ }
346
+ expect(text).toBe(w.text);
347
+ const first = pieces[0];
348
+ const known = pieces.length > 1 ? trackedPieces(Object.assign(paintingCtx().ctx, { font: first.font }), w.text) : undefined;
349
+ pieces.forEach((p, k) => {
350
+ const at = known ? known[k].offset + w.spacing * known[k].glyphsBefore : 0;
351
+ expect(p.x).toBeCloseTo(w.x + w.ux * at, 6);
352
+ expect(p.y).toBeCloseTo(w.y + w.uy * at, 6);
353
+ expect(p.ux).toBeCloseTo(w.ux, 9);
354
+ expect(p.uy).toBeCloseTo(w.uy, 9);
355
+ // Read as written, never through the mirror.
356
+ expect(p.det).toBeGreaterThan(0);
357
+ expect(p.direction).toBe(w.direction);
358
+ expect(p.spacing).toBe(known?.[k].ligature ? 0 : w.spacing);
359
+ });
360
+ if (pieces.length > 1)
361
+ words.push(pieces);
362
+ }
363
+ expect(j).toBe(cut.length);
364
+ return words;
365
+ }
366
+ const RTL = { ...CONFIG, direction: 'rtl' };
367
+ const RTL_LEFT = { ...LEFT, direction: 'rtl' };
368
+ const ltrBlock = (text) => `:::paragraphs{dir=ltr}\n${text}\n:::`;
369
+ describe('a word in pieces inside a mirrored frame (#685)', () => {
370
+ it('is turned once, about its box: the pieces in reading order from the box\'s left', () => {
371
+ const { ctx, runs } = sheetCanvas();
372
+ const t = -0.1;
373
+ ctx.letterSpacing = `${t}px`;
374
+ const end = beginMirroredFlow(ctx, 200);
375
+ const paint = (text, x, y) => ctx.fillText(text, x, y);
376
+ fillTrackedWord(ctx, 'first', 40, 50, t, paint);
377
+ // A word with no ligature beside it: one `fillText`, turned by the frame.
378
+ fillTrackedWord(ctx, 'hands', 100, 50, t, paint);
379
+ expect(ctx.letterSpacing).toBe(`${t}px`);
380
+ end();
381
+ expect(runs.map((r) => r.text)).toEqual(['fi', 'rst', 'hands']);
382
+ const [fi, rst, hands] = runs;
383
+ // "first" was measured 20 px wide and five letters tracked: its box is
384
+ // [40, 59.5] in the flow, [140.5, 160] on the sheet.
385
+ expect(leftOf(fi)).toBeCloseTo(200 - 40 - (20 + 5 * t), 6);
386
+ expect(fi.spacing).toBe(0);
387
+ // The rest after the ligature and its one glyph of tracking.
388
+ expect(leftOf(rst)).toBeCloseTo(leftOf(fi) + 5 + t, 6);
389
+ expect(rst.spacing).toBe(t);
390
+ // It ends at the box's right, less the tracking the ligature's second
391
+ // letter does not take (one glyph, as on a left-to-right page).
392
+ expect(rightOf(rst)).toBeCloseTo(200 - 40 - t, 6);
393
+ expect(runs.every((r) => r.det > 0 && r.ux === 1)).toBe(true);
394
+ // The whole word stands as the frame always put it.
395
+ expect(leftOf(hands)).toBeCloseTo(200 - 100 - (25 + 5 * t), 6);
396
+ expect(rightOf(hands)).toBeCloseTo(100, 6);
397
+ });
398
+ it('outside a mirrored frame the pieces start at the word\'s left', () => {
399
+ const { ctx, runs } = sheetCanvas();
400
+ ctx.letterSpacing = '-0.1px';
401
+ fillTrackedWord(ctx, 'first', 40, 50, -0.1, (text, x, y) => ctx.fillText(text, x, y));
402
+ expect(runs.map((r) => [r.text, +leftOf(r).toFixed(6), r.spacing])).toEqual([['fi', 40, 0], ['rst', 44.9, -0.1]]);
403
+ });
404
+ });
405
+ describe('a tracked left-to-right paragraph on a right-to-left page (#685)', () => {
406
+ for (const [name, text, ltr, rtl] of [['justified', JUSTIFIED, CONFIG, RTL], ['ragged', RAGGED, LEFT, RTL_LEFT]]) {
407
+ it(`${name}: every piece lands where a left-to-right page puts it`, () => {
408
+ const plain = buildDocument({ markdown: text }, ltr);
409
+ const mirrored = buildDocument({ markdown: ltrBlock(text) }, rtl);
410
+ expect(mirrored.pages[0].flow?.writingMode).toBe('horizontal-tb');
411
+ const block = paragraph(mirrored);
412
+ const t = block.letterSpacing;
413
+ expect(t).toBeCloseTo(-0.1, 5);
414
+ const a = rows(runsOn(plain));
415
+ const b = rows(runsOn(mirrored));
416
+ expect(b.length).toBe(block.lines.length);
417
+ expect(b.length).toBe(a.length);
418
+ b.forEach((row, i) => {
419
+ // From the sheet's left the pieces spell the line: "fi" before
420
+ // "rst", never after it.
421
+ expect(row.map((r) => r.text).join('')).toBe(block.lines[i].text.replace(/ /g, ''));
422
+ expect(row.every((r) => r.det > 0)).toBe(true);
423
+ // The margins are equal, so the line stands as on the left-to-right
424
+ // page; a ragged line of the mirrored page starts the tracking of
425
+ // one glyph further in (it is aligned by its other end), words in
426
+ // pieces and whole words alike.
427
+ const shift = leftOf(row[0]) - leftOf(a[i][0]);
428
+ expect(Math.abs(shift)).toBeLessThanOrEqual(Math.abs(t) + 1e-9);
429
+ if (a[i].length === 1) {
430
+ // A line with no ligature: one text on the left-to-right page,
431
+ // word by word on the mirrored one, over the same stretch.
432
+ expect(rightOf(row[row.length - 1]) - shift).toBeCloseTo(rightOf(a[i][0]), 6);
433
+ return;
434
+ }
435
+ expect(row.length).toBe(a[i].length);
436
+ row.forEach((r, k) => {
437
+ const q = a[i][k];
438
+ expect(r.text).toBe(q.text);
439
+ expect(r.spacing).toBe(q.spacing);
440
+ expect(leftOf(r) - shift).toBeCloseTo(leftOf(q), 6);
441
+ expect(rightOf(r) - shift).toBeCloseTo(rightOf(q), 6);
442
+ });
443
+ });
444
+ expect(b.flat().filter((r) => r.text === 'fi' || r.text === 'ffi').length).toBeGreaterThan(3);
445
+ });
446
+ }
447
+ });
448
+ /** A page whose level-2 headings are tracked: half a point after each letter. */
449
+ const tracked2 = (config) => ({
450
+ ...config,
451
+ page: { ...config.page, width: pt(400) },
452
+ headings: { levels: [{ level: 2, letterSpacing: pt(0.5) }] },
453
+ });
454
+ const headingOf = (doc) => doc.blocks.find((b) => b.type === 'heading');
455
+ describe('right-to-left words on a tracked line (#685)', () => {
456
+ it('an Arabic or a Hebrew word is never cut, though its face has ligatures', () => {
457
+ const { ctx } = paintingCtx();
458
+ for (const word of [SALAM, ISRAEL, `first${SALAM}`, `${ISRAEL}first`]) {
459
+ // The browser shapes it otherwise under a letter-spacing…
460
+ expect(shapedOtherwise(ctx, word)).toBe(true);
461
+ // …and it is still painted whole, with one `fillText`.
462
+ expect(trackedPieces(ctx, word)).toBeNull();
463
+ const seen = [];
464
+ ctx.letterSpacing = '0.5px';
465
+ fillTrackedWord(ctx, word, 10, 20, 0.5, (text, x, y) => seen.push([text, x, y]));
466
+ expect(seen).toEqual([[word, 10, 20]]);
467
+ expect(ctx.letterSpacing).toBe('0.5px');
468
+ }
469
+ });
470
+ for (const [name, config, dir] of [['a right-to-left line of a left-to-right page', CONFIG, ' {dir=rtl}'], ['a line of a right-to-left page', RTL, '']]) {
471
+ it(`${name}: the Arabic word untracked, the Hebrew ones whole, the Latin ones in reading order`, () => {
472
+ const doc = buildDocument({ markdown: `## ${SHALOM} first ${SALAM} office ${SHALOM}${dir}` }, tracked2(config));
473
+ const line = headingOf(doc).lines[0];
474
+ expect(line.segments.filter((s) => s.rtl && s.kind !== 'space').map((s) => s.text)).toEqual([SHALOM, SALAM, SHALOM]);
475
+ const runs = runsOn(doc);
476
+ // Arabic: one `fillText`, no letter-spacing, right to left.
477
+ expect(runs.filter((r) => r.text.includes('\u0644\u0627')).map((r) => [r.text, r.spacing, r.direction])).toEqual([[SALAM, 0, 'rtl']]);
478
+ // Hebrew: one `fillText` a word, tracked as measured, right to left.
479
+ expect(runs.filter((r) => r.text.includes('\u05E9')).map((r) => [r.text, r.spacing, r.direction])).toEqual([[SHALOM, 0.5, 'rtl'], [SHALOM, 0.5, 'rtl']]);
480
+ // The Latin words: in pieces, where the whole words would stand.
481
+ const words = againstWholeWords(doc);
482
+ const first = words.find((w) => w[0].text === 'fi');
483
+ const office = words.find((w) => w[0].text === 'o');
484
+ expect(words).toHaveLength(2);
485
+ expect(first.map((r) => r.text)).toEqual(['fi', 'rst']);
486
+ expect(office.map((r) => r.text)).toEqual(['o', 'ffi', 'ce']);
487
+ for (const w of words)
488
+ for (let k = 1; k < w.length; k++)
489
+ expect(leftOf(w[k])).toBeGreaterThan(leftOf(w[k - 1]));
490
+ // The line reads from the right: "first" stands right of "office",
491
+ // the Arabic word between them.
492
+ const salam = runs.find((r) => r.text === SALAM);
493
+ expect(leftOf(first[0])).toBeGreaterThanOrEqual(rightOf(salam) - 1e-6);
494
+ expect(leftOf(salam)).toBeGreaterThanOrEqual(rightOf(office[2]) - 1);
495
+ });
496
+ }
497
+ });
498
+ const cell = (content) => ({ content });
499
+ /** A table whose header cells are tracked (`headerLetterSpacing`). */
500
+ const table = (extra = {}, direction) => ({
501
+ id: 'tab', typeId: 'table', kind: 'table', caption: 'A table', createdAt: 0, updatedAt: 0,
502
+ table: {
503
+ model: { headerRowCount: 1, rows: [[cell('first office'), cell('fire lets')], [cell('stood'), cell('is')]] },
504
+ ...(direction ? { direction } : {}),
505
+ },
506
+ placement: { position: 'here' },
507
+ ...extra,
508
+ });
509
+ const withTable = (config) => ({
510
+ ...config,
511
+ page: { ...config.page, width: pt(300), height: pt(400) },
512
+ tableStyle: { headerLetterSpacing: pt(0.5) },
513
+ });
514
+ const TABLE_MD = 'Text.\n\n::resource{id="tab"}\n\nMore.';
515
+ /** The page that holds the table, in the flow or as a float. */
516
+ const tablePage = (doc) => doc.pages.findIndex((p) => [...p.columns.flatMap((c) => c.blocks), ...(p.floats ?? [])].some((b) => b.resourceBlock?.resource.id === 'tab'));
517
+ describe('tracked header cells in either direction (#685)', () => {
518
+ const header = [['fi', 'rst'], ['o', 'ffi', 'ce'], ['fi', 're']];
519
+ for (const [name, config, direction] of [
520
+ ['a table of a left-to-right page', CONFIG, undefined],
521
+ ['a table of a right-to-left page', RTL, undefined],
522
+ ['a left-to-right table of a right-to-left page', RTL, 'ltr'],
523
+ ['a right-to-left table of a left-to-right page', CONFIG, 'rtl'],
524
+ ]) {
525
+ it(`${name}: the words in pieces stand where the whole words would`, () => {
526
+ const doc = buildDocument({ markdown: TABLE_MD, resources: [table({}, direction)] }, withTable(config));
527
+ const words = againstWholeWords(doc, tablePage(doc));
528
+ expect(words.map((w) => w.map((r) => r.text)).sort()).toEqual([...header].sort());
529
+ // Upright: each word reads from the sheet's left.
530
+ for (const w of words)
531
+ for (let k = 1; k < w.length; k++)
532
+ expect(leftOf(w[k])).toBeGreaterThan(leftOf(w[k - 1]));
533
+ });
534
+ }
535
+ for (const rotate of ['ccw', 'cw']) {
536
+ for (const [name, config] of [['left-to-right', CONFIG], ['right-to-left', RTL]]) {
537
+ it(`a table turned ${rotate} on a ${name} page: the pieces follow the turn`, () => {
538
+ const doc = buildDocument({ markdown: TABLE_MD, resources: [table({ placement: { rotate } })] }, withTable(config));
539
+ const page = tablePage(doc);
540
+ expect(page).toBeGreaterThanOrEqual(0);
541
+ const words = againstWholeWords(doc, page);
542
+ expect(words.map((w) => w.map((r) => r.text)).sort()).toEqual([...header].sort());
543
+ for (const w of words) {
544
+ // Turned a quarter: the letters advance up or down the sheet,
545
+ // each piece further along than the one before.
546
+ expect(Math.abs(w[0].ux)).toBeLessThan(1e-9);
547
+ for (let k = 1; k < w.length; k++)
548
+ expect((w[k].y - w[k - 1].y) * w[0].uy).toBeGreaterThan(0);
549
+ }
550
+ });
551
+ }
552
+ }
553
+ });
554
+ describe('lines that are never cut (#685)', () => {
555
+ it('a vertical line is painted the same whether the browser drops ligatures or not', () => {
556
+ const doc = buildDocument({ markdown: '## first office fire' }, tracked2({ ...CONFIG, layout: { writingMode: 'vertical-rl', layoutType: 'single' } }));
557
+ expect(headingOf(doc).letterSpacing ?? headingOf(doc).lines[0].letterSpacing).toBeCloseTo(0.5, 6);
558
+ // What was painted and where; the two browsers measure the run apart.
559
+ const put = (r) => ({ ...r, font: r.font.replace(', Keeps', ''), width: 0 });
560
+ const cut = runsOn(doc).map(put);
561
+ expect(cut.length).toBeGreaterThan(0);
562
+ expect(cut).toEqual(runsOn(doc, 0, true).map(put));
563
+ // The words with their neighbours, tracked: no ligature set apart.
564
+ expect(cut.map((r) => r.text).join(' ')).toContain('first');
565
+ expect(cut.some((r) => r.text === 'fi' || r.text === 'ffi' || r.spacing === 0)).toBe(false);
566
+ });
567
+ });
568
+ describe('a tracked line in HTML, either direction (#685)', () => {
569
+ it('a left-to-right paragraph of a right-to-left page asks for its ligatures as on a left-to-right page', () => {
570
+ const plain = renderToHtml(buildDocument({ markdown: JUSTIFIED }, CONFIG));
571
+ const mirrored = renderToHtml(buildDocument({ markdown: ltrBlock(JUSTIFIED) }, RTL));
572
+ const asked = (html) => html.split("font-feature-settings:'liga' 1,'clig' 1;").length - 1;
573
+ expect(asked(plain)).toBe(3);
574
+ expect(asked(mirrored)).toBe(3);
575
+ // The words are whole in the markup: nothing to put in order.
576
+ for (const word of ['first', 'offices', 'fire'])
577
+ expect(mirrored).toContain(word);
578
+ });
579
+ it('an Arabic word of a tracked line takes no letter-spacing; a Hebrew one keeps the line\'s', () => {
580
+ const html = renderToHtml(buildDocument({ markdown: `## ${SHALOM} first ${SALAM} office ${SHALOM}` }, tracked2(RTL)));
581
+ expect(html).toContain("letter-spacing:0.5px;font-feature-settings:'liga' 1,'clig' 1;");
582
+ expect(html).toMatch(new RegExp(`<span[^>]*letter-spacing:0;[^>]*>${SALAM}</span>`));
583
+ expect(html).toContain(SHALOM);
584
+ expect(html).not.toMatch(new RegExp(`<span[^>]*letter-spacing:0;[^>]*>${SHALOM}</span>`));
585
+ });
586
+ });
587
+ //# sourceMappingURL=trackedLigatures.test.js.map