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
@@ -10,7 +10,7 @@
10
10
  // balloon cannot be placed cleanly the fallbacks are tried in order: break
11
11
  // the border (when allowed), cover avoid zones (never faces), reshape the
12
12
  // text block; then it is placed anyway and reported.
13
- import { boundsOf, clipToRect, dist, insetConvex, nearestOnPolygon, norm, pointInConvex, pointInRect, rayExit, rectsOverlap, segmentHitsRect, segmentDistance, sub, add, scale, } from './geom';
13
+ import { boundsOf, clipToRect, dist, insetConvex, nearestOnPolygon, norm, pointInConvex, pointInRect, rayExit, rectsOverlap, segmentHitsRect, segmentDistance, segmentsCross, sub, add, scale, } from './geom';
14
14
  import { tailLength } from './shapes';
15
15
  const W = {
16
16
  face: 60,
@@ -66,10 +66,13 @@ function sceneOf(panel) {
66
66
  fwd: panel.direction === 'rtl' ? -1 : 1,
67
67
  buttPoly: buttPolygon(panel),
68
68
  neighbours: [...(panel.neighbours ?? [])],
69
+ over: [...(panel.over ?? [])],
69
70
  ...(panel.limit ? { limit: panel.limit } : {}),
70
71
  ...(trim ? { edge: insetRect(trim, panel.insetPx), edgeSfx: panel.sheet ?? trim } : {}),
71
72
  bleeds: panel.limit !== undefined && panel.polygon.some((p) => !pointInRect(p, panel.limit, 0.5)),
72
73
  foreign: [...(panel.foreign ?? [])],
74
+ foreignTails: [...(panel.foreignTails ?? [])],
75
+ foreignArt: [...(panel.foreignArt ?? [])],
73
76
  keepOut: [...(panel.keepOut ?? [])],
74
77
  live,
75
78
  };
@@ -113,6 +116,18 @@ function coveredArea(samples, rect, area) {
113
116
  n++;
114
117
  return (n / Math.max(1, samples.length)) * area;
115
118
  }
119
+ /** Area of the ellipse `rect` holds the samples fall in. */
120
+ function coveredEllipse(samples, rect, area) {
121
+ const cx = rect.x + rect.width / 2;
122
+ const cy = rect.y + rect.height / 2;
123
+ const rx = rect.width / 2;
124
+ const ry = rect.height / 2;
125
+ let n = 0;
126
+ for (const s of samples)
127
+ if (((s.x - cx) / rx) ** 2 + ((s.y - cy) / ry) ** 2 <= 1)
128
+ n++;
129
+ return (n / Math.max(1, samples.length)) * area;
130
+ }
116
131
  /** Whether a face fills a good part of its panel (a close shot): its box
117
132
  * then has corners of hair a balloon may cover (in a long shot they are a
118
133
  * few pixels beside the face). */
@@ -136,6 +151,20 @@ function coveredOval(samples, rect, area) {
136
151
  }
137
152
  return (n / Math.max(1, samples.length)) * area;
138
153
  }
154
+ /** Area of a face or avoid zone of another panel the samples fall in:
155
+ * only what the page shows of it counts (inside its own panel, under no
156
+ * panel laid over that one). */
157
+ function coveredForeign(samples, art, area) {
158
+ let n = 0;
159
+ for (const s of samples) {
160
+ if (!pointInRect(s, art.rect) || !pointInConvex(s, art.within))
161
+ continue;
162
+ if (art.under?.some((poly) => pointInConvex(s, poly)))
163
+ continue;
164
+ n++;
165
+ }
166
+ return (n / Math.max(1, samples.length)) * area;
167
+ }
139
168
  function areaOf(v) {
140
169
  // The bodies' boxes, as an ellipse fills them.
141
170
  return v.bodies.reduce((s, b) => s + b.bbox.width * b.bbox.height * 0.8, 0);
@@ -232,6 +261,25 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
232
261
  }
233
262
  }
234
263
  }
264
+ // What the other panels' pictures protect, for a balloon that leaves
265
+ // its panel: their faces as its own, their avoid zones as its own (#671).
266
+ for (const art of scene.foreignArt) {
267
+ if (!rectsOverlap(box, art.rect))
268
+ continue;
269
+ const a = coveredForeign(samples, art, area);
270
+ if (a <= 0)
271
+ continue;
272
+ if (art.kind === 'face') {
273
+ H('face', (W.face * a) / em2 + W.face);
274
+ reasons.add('face');
275
+ }
276
+ else if (level.coverAvoid)
277
+ S(art.guard ? 'coverAvoid' : 'coverArt', ((art.guard ? 2 : 6) * a) / em2);
278
+ else {
279
+ H('avoid', (W.avoid * a) / em2 + W.avoid);
280
+ reasons.add('avoid');
281
+ }
282
+ }
235
283
  for (const r of scene.soft) {
236
284
  if (!rectsOverlap(box, r))
237
285
  continue;
@@ -275,6 +323,12 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
275
323
  else if (scene.neighbours.some((r) => pointInRect(p, r)))
276
324
  intrude++;
277
325
  }
326
+ else if (scene.over.some((poly) => pointInConvex(p, poly))) {
327
+ // Over an inset laid on this panel: out of the panel, in another's
328
+ // (#671).
329
+ out++;
330
+ intrude++;
331
+ }
278
332
  }
279
333
  const mayIntrude = intended && (unit.kind === 'sfx' || unit.breakBorder === true);
280
334
  // Off the sheet is the worst; into another panel (its art, its
@@ -298,7 +352,8 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
298
352
  // another panel's (drawn sound may; so may a balloon its author
299
353
  // pinned). A pinned balloon (no `break`) slides back in when it can.
300
354
  S('breakBorder', 3 * f + (unit.kind !== 'sfx' && !unit.breakBorder ? 6 : 0));
301
- if (unit.kind !== 'sfx' && !unit.pin && !pointInConvex({ x: box.x + box.width / 2, y: box.y + box.height / 2 }, scene.live)) {
355
+ const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
356
+ if (unit.kind !== 'sfx' && !unit.pin && (!pointInConvex(middle, scene.live) || scene.over.some((poly) => pointInConvex(middle, poly)))) {
302
357
  H('outside', W.outside * f + 10);
303
358
  reasons.add('outside');
304
359
  }
@@ -320,7 +375,12 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
320
375
  for (const r of scene.foreign) {
321
376
  if (!rectsOverlap(box, r, 0.3 * em))
322
377
  continue;
323
- const a = coveredArea(samples, grow(r, 0.3 * em), area);
378
+ // (An oval, a cloud or a burst is taken as the oval its box holds: the
379
+ // corners of the box are no part of it. This unit's own outline, where
380
+ // it grows past its core, the spikes of a burst, counts by its points.)
381
+ const g = grow(r, 0.3 * em);
382
+ const a = (r.round ? coveredEllipse(samples, g, area) : coveredArea(samples, g, area))
383
+ || (r.round ? coveredEllipse(rim, r, em2) : coveredArea(rim, r, em2));
324
384
  if (a > 0) {
325
385
  H('balloon', (W.balloon * a) / em2 + W.balloon);
326
386
  reasons.add('balloon');
@@ -329,6 +389,24 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
329
389
  // Other balloons: overlap is a fault, a near miss (a tangent) a blemish.
330
390
  const centre = at;
331
391
  const target = tailTargetOf(unit, scene, centre);
392
+ // Where the tail leaves the body, and the tail as drawn: once for all
393
+ // the terms below (each is a ray cast against the body's outline).
394
+ const leave = target ? edgePoint(v, at, target) : undefined;
395
+ const mine = target && leave ? tailFrom(unit, leave, target) : undefined;
396
+ // The tails of the other panels' balloons are theirs too: no body over
397
+ // one, and no tail of this panel through a balloon of another (#671).
398
+ for (const [a, b] of scene.foreignTails) {
399
+ if (throughBody(a, b, v, at)) {
400
+ H('foreignTail', W.balloon);
401
+ reasons.add('balloon');
402
+ }
403
+ }
404
+ if (mine && scene.foreign.length > 0) {
405
+ if (scene.foreign.some((r) => segmentHitsRect(mine[0], mine[1], shrink(r, 0.12)))) {
406
+ H('tailThroughForeign', W.balloon);
407
+ reasons.add('balloon');
408
+ }
409
+ }
332
410
  for (const o of others) {
333
411
  const ov = o.unit.variants[o.variant];
334
412
  const obox = movedRect(ov.bbox, o.at);
@@ -360,9 +438,9 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
360
438
  }
361
439
  // Tails that cross (as drawn: from the wall to the tip), tails
362
440
  // through another balloon.
363
- const otarget = tailTargetOf(o.unit, scene, o.at);
364
- const mine = target ? tailSegment(unit, v, at, target) : undefined;
365
- const theirs = otarget ? tailSegment(o.unit, ov, o.at, otarget) : undefined;
441
+ const other = tailOf(scene, o);
442
+ const otarget = other?.target;
443
+ const theirs = other?.drawn;
366
444
  if (mine && theirs) {
367
445
  // Crossing tails; tails that all but touch read as crossed too
368
446
  // (unless they run to one speaker).
@@ -372,24 +450,31 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
372
450
  else if (gapTails < 0.6 * em && dist(target, otarget) > em)
373
451
  S('cross', 0.4 * W.cross);
374
452
  }
375
- if (mine && ov.bodies.some((b) => segmentHitsRect(mine[0], mine[1], shrink(movedRect(b.bbox, o.at), 0.12))))
376
- S('tailThrough', W.tailThrough);
377
- if (theirs && v.bodies.some((b) => segmentHitsRect(theirs[0], theirs[1], shrink(movedRect(b.bbox, at), 0.12))))
378
- S('tailThrough', W.tailThrough);
453
+ // A tail through another body covers its lettering as a balloon over
454
+ // it would: a fault (#672). Through the corner of its box, clear of its
455
+ // outline, a cost.
456
+ for (const [tail, w, p] of [[mine, ov, o.at], [theirs, v, at]]) {
457
+ if (!tail)
458
+ continue;
459
+ if (throughBody(tail[0], tail[1], w, p)) {
460
+ H('tailThrough', W.balloon);
461
+ reasons.add('balloon');
462
+ }
463
+ else if (w.bodies.some((b) => segmentHitsRect(tail[0], tail[1], shrink(movedRect(b.bbox, p), 0.12))))
464
+ S('tailThrough', W.tailThrough);
465
+ }
379
466
  }
380
467
  // The tail leaves the first body clear of the group's other bodies, by
381
468
  // more than its own half width: a tail grazing another body of its group
382
469
  // leaves a notch between them that the doubled outline fills with ink.
383
- if (target && v.bodies.length > 1) {
384
- const edge = edgePoint(v, at, target);
470
+ if (target && leave && v.bodies.length > 1) {
385
471
  for (const b of v.bodies.slice(1))
386
- if (segmentHitsRect(edge, target, grow(movedRect(b.bbox, at), 0.8 * em)))
472
+ if (segmentHitsRect(leave, target, grow(movedRect(b.bbox, at), 0.8 * em)))
387
473
  S('ownTail', W.ownTail);
388
474
  }
389
475
  // Near the speaker, a little above; tails not over faces.
390
- if (target && unit.target.kind === 'point') {
391
- const edge = edgePoint(v, at, target);
392
- const gap = dist(edge, target);
476
+ if (target && leave && mine && unit.target.kind === 'point') {
477
+ const gap = dist(leave, target);
393
478
  const h = v.bodies[0].bbox.height;
394
479
  // Room for a tail of a few ems: too close is worse than too far.
395
480
  const ideal = Math.max(2 * em, 0.7 * h);
@@ -406,16 +491,16 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
406
491
  S('below', (W.below * (centre.y - target.y)) / em);
407
492
  // A tail over another character's face (or the top of the head)
408
493
  // reads as theirs.
409
- const tip = add(edge, scale(norm(sub(target, edge)), gap * 0.55));
494
+ const tip = add(leave, scale(norm(sub(target, leave)), gap * 0.55));
410
495
  for (const f of scene.faces) {
411
496
  const head = { x: f.x, y: f.y - 0.3 * f.height, width: f.width, height: 1.3 * f.height };
412
- if (!segmentHitsRect(edge, tip, shrink(head, 0.1)))
497
+ if (!segmentHitsRect(leave, tip, shrink(head, 0.1)))
413
498
  continue;
414
499
  S('otherFace', pointInRect(target, head) ? 1 : W.otherFace);
415
500
  }
416
501
  // A tail drawn across another figure (the body under a marked face):
417
502
  // the eye follows it through the wrong character.
418
- const drawn = tailSegment(unit, v, at, target);
503
+ const drawn = mine;
419
504
  for (const a of scene.panel.anchors) {
420
505
  if (!a.face || a.mouth === target || a.head === target)
421
506
  continue;
@@ -425,11 +510,11 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
425
510
  S('tailOverFigure', W.tailOverFigure);
426
511
  }
427
512
  }
428
- else if (unit.target.kind === 'offPanel' && target) {
513
+ else if (unit.target.kind === 'offPanel' && target && leave) {
429
514
  // An off-panel speaker's balloon sits by the border its voice comes
430
515
  // from, with a short tail to it (the letterer's practice): a tail
431
516
  // across the panel reads as an arrow, so the balloon moves instead.
432
- const reach = dist(edgePoint(v, at, target), target);
517
+ const reach = dist(leave, target);
433
518
  S('side', (W.side * Math.max(0, reach - 1.2 * em) + 3 * W.side * Math.max(0, reach - 3 * em)) / em);
434
519
  }
435
520
  if (unit.kind === 'sfx' && unit.near)
@@ -447,6 +532,22 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
447
532
  }
448
533
  return { hard, soft, reasons, order, cover, coverArt };
449
534
  }
535
+ /** Whether the segment a–b runs through a body of `v` at `at`: across
536
+ * its outline (the spikes of a burst and the bumps of a cloud are part of
537
+ * it; a turned or leaned sound effect's is its turned box) or inside it. */
538
+ function throughBody(a, b, v, at) {
539
+ if (!segmentHitsRect(a, b, movedRect(v.bbox, at)))
540
+ return false;
541
+ const turned = (v.rotate || v.skew) && v.bodies.length === 1;
542
+ return v.bodies.some((body) => {
543
+ const outline = moved(turned ? v.rim : body.hull, at);
544
+ for (let i = 0; i < outline.length; i++) {
545
+ if (segmentsCross(a, b, outline[i], outline[(i + 1) % outline.length]))
546
+ return true;
547
+ }
548
+ return !turned && pointInConvex(a, moved(body.core, at));
549
+ });
550
+ }
450
551
  /** A rectangle grown by `d` px on every side. */
451
552
  function grow(r, d) {
452
553
  return { x: r.x - d, y: r.y - d, width: r.width + 2 * d, height: r.height + 2 * d };
@@ -454,10 +555,26 @@ function grow(r, d) {
454
555
  function shrink(r, f) {
455
556
  return { x: r.x + r.width * f, y: r.y + r.height * f, width: r.width * (1 - 2 * f), height: r.height * (1 - 2 * f) };
456
557
  }
558
+ /** The tail of a placed unit, kept with it: a unit that stays where it is
559
+ * is asked for its tail at every spot tried for the others. (A placed
560
+ * unit is never changed in place; a move makes a new one.) */
561
+ const tailsOf = new WeakMap();
562
+ function tailOf(scene, o) {
563
+ let t = tailsOf.get(o);
564
+ if (t === undefined) {
565
+ const target = tailTargetOf(o.unit, scene, o.at);
566
+ t = target ? { target, drawn: tailSegment(o.unit, o.unit.variants[o.variant], o.at, target) } : null;
567
+ tailsOf.set(o, t);
568
+ }
569
+ return t ?? undefined;
570
+ }
457
571
  /** A unit's tail as drawn, for the cost: from where it leaves the first
458
572
  * body to its tip ({@link tailLength} of the gap). */
459
573
  function tailSegment(unit, v, at, target) {
460
- const edge = edgePoint(v, at, target);
574
+ return tailFrom(unit, edgePoint(v, at, target), target);
575
+ }
576
+ /** The same from the point the tail leaves the body at. */
577
+ function tailFrom(unit, edge, target) {
461
578
  const gap = dist(edge, target);
462
579
  const len = Math.max(Math.min(gap, 0.8 * unit.em), tailLength(gap, unit.em, {}, unit.target.kind === 'offPanel'));
463
580
  return [edge, add(edge, scale(norm(sub(target, edge)), len))];
@@ -470,8 +587,20 @@ function edgePoint(v, at, target) {
470
587
  const t = rayExit(c, u, moved(b.core, at));
471
588
  return t === undefined ? c : add(c, scale(u, t));
472
589
  }
590
+ /** The candidates of each unit's variants: they depend on the unit and
591
+ * the panel alone, and the search asks for them at every step. */
592
+ const candidatesOf = new WeakMap();
473
593
  /** Candidate anchor positions of a unit (deterministic order). */
474
594
  function candidates(scene, unit, vi) {
595
+ let byVariant = candidatesOf.get(unit);
596
+ if (!byVariant)
597
+ candidatesOf.set(unit, (byVariant = new Map()));
598
+ let list = byVariant.get(vi);
599
+ if (!list)
600
+ byVariant.set(vi, (list = candidatesFresh(scene, unit, vi)));
601
+ return list;
602
+ }
603
+ function candidatesFresh(scene, unit, vi) {
475
604
  const v = unit.variants[vi];
476
605
  const out = [];
477
606
  const seen = new Set();
@@ -631,8 +760,16 @@ function bestFor(scene, unit, others, firstOrder, fixed) {
631
760
  // its poorer shape, and is tried before covering an avoid zone (a
632
761
  // letterer reshapes the text before covering a hand).
633
762
  const penalty = (vi) => (unit.variants[vi].reshaped ? RESHAPE_COST : 0);
763
+ // Whether any spot tried at the level before met an avoid zone: when
764
+ // none did, a level that only adds covering them finds the same spots at
765
+ // the same costs, and is not searched again.
766
+ const breaks = (l) => l.breakBorder && unit.breakBorder === true;
767
+ let before;
634
768
  for (const li of levels) {
635
769
  const level = LEVELS[li];
770
+ if (level.coverAvoid && before && !before.metAvoid && before.breaks === breaks(level))
771
+ continue;
772
+ let metAvoid = false;
636
773
  let best;
637
774
  for (const vi of variants) {
638
775
  const cands = unit.pin
@@ -643,11 +780,14 @@ function bestFor(scene, unit, others, firstOrder, fixed) {
643
780
  : candidates(scene, unit, vi).map((at) => ({ at, extra: 0 }));
644
781
  for (const { at, extra } of cands) {
645
782
  const ev = evaluate(scene, unit, vi, at, others, level, firstOrder);
783
+ if (ev.reasons.has('avoid'))
784
+ metAvoid = true;
646
785
  const score = ev.hard * 1000 + ev.soft + penalty(vi) + extra;
647
786
  if (!best || score < best.score - 1e-9)
648
787
  best = { variant: vi, level: li, at, ev, score };
649
788
  }
650
789
  }
790
+ before = { breaks: breaks(level), metAvoid: metAvoid || level.coverAvoid };
651
791
  if (!best)
652
792
  continue;
653
793
  const choice = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };