postext 1.19.3 → 1.20.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 (251) hide show
  1. package/dist/__tests__/comics/art.test.d.ts +2 -0
  2. package/dist/__tests__/comics/art.test.d.ts.map +1 -0
  3. package/dist/__tests__/comics/art.test.js +96 -0
  4. package/dist/__tests__/comics/art.test.js.map +1 -0
  5. package/dist/__tests__/comics/build.test.d.ts +2 -0
  6. package/dist/__tests__/comics/build.test.d.ts.map +1 -0
  7. package/dist/__tests__/comics/build.test.js +215 -0
  8. package/dist/__tests__/comics/build.test.js.map +1 -0
  9. package/dist/__tests__/comics/canvas.test.d.ts +2 -0
  10. package/dist/__tests__/comics/canvas.test.d.ts.map +1 -0
  11. package/dist/__tests__/comics/canvas.test.js +161 -0
  12. package/dist/__tests__/comics/canvas.test.js.map +1 -0
  13. package/dist/__tests__/comics/config.test.d.ts +2 -0
  14. package/dist/__tests__/comics/config.test.d.ts.map +1 -0
  15. package/dist/__tests__/comics/config.test.js +97 -0
  16. package/dist/__tests__/comics/config.test.js.map +1 -0
  17. package/dist/__tests__/comics/geometry.test.d.ts +2 -0
  18. package/dist/__tests__/comics/geometry.test.d.ts.map +1 -0
  19. package/dist/__tests__/comics/geometry.test.js +118 -0
  20. package/dist/__tests__/comics/geometry.test.js.map +1 -0
  21. package/dist/__tests__/comics/html.test.d.ts +2 -0
  22. package/dist/__tests__/comics/html.test.d.ts.map +1 -0
  23. package/dist/__tests__/comics/html.test.js +253 -0
  24. package/dist/__tests__/comics/html.test.js.map +1 -0
  25. package/dist/__tests__/comics/lettering.test.d.ts +2 -0
  26. package/dist/__tests__/comics/lettering.test.d.ts.map +1 -0
  27. package/dist/__tests__/comics/lettering.test.js +241 -0
  28. package/dist/__tests__/comics/lettering.test.js.map +1 -0
  29. package/dist/__tests__/comics/script.test.d.ts +2 -0
  30. package/dist/__tests__/comics/script.test.d.ts.map +1 -0
  31. package/dist/__tests__/comics/script.test.js +147 -0
  32. package/dist/__tests__/comics/script.test.js.map +1 -0
  33. package/dist/__tests__/comics/split.test.d.ts +2 -0
  34. package/dist/__tests__/comics/split.test.d.ts.map +1 -0
  35. package/dist/__tests__/comics/split.test.js +120 -0
  36. package/dist/__tests__/comics/split.test.js.map +1 -0
  37. package/dist/__tests__/comics/spread.test.d.ts +2 -0
  38. package/dist/__tests__/comics/spread.test.d.ts.map +1 -0
  39. package/dist/__tests__/comics/spread.test.js +161 -0
  40. package/dist/__tests__/comics/spread.test.js.map +1 -0
  41. package/dist/__tests__/comics/strip.test.d.ts +2 -0
  42. package/dist/__tests__/comics/strip.test.d.ts.map +1 -0
  43. package/dist/__tests__/comics/strip.test.js +231 -0
  44. package/dist/__tests__/comics/strip.test.js.map +1 -0
  45. package/dist/__tests__/exports.test.js +89 -0
  46. package/dist/__tests__/exports.test.js.map +1 -1
  47. package/dist/__tests__/parse/containers.test.js +1 -1
  48. package/dist/__tests__/parse/containers.test.js.map +1 -1
  49. package/dist/canvas-backend/comic.d.ts +32 -0
  50. package/dist/canvas-backend/comic.d.ts.map +1 -0
  51. package/dist/canvas-backend/comic.js +216 -0
  52. package/dist/canvas-backend/comic.js.map +1 -0
  53. package/dist/canvas-backend/headerFooter.d.ts +4 -1
  54. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  55. package/dist/canvas-backend/headerFooter.js +3 -1
  56. package/dist/canvas-backend/headerFooter.js.map +1 -1
  57. package/dist/canvas-backend/index.d.ts.map +1 -1
  58. package/dist/canvas-backend/index.js +7 -1
  59. package/dist/canvas-backend/index.js.map +1 -1
  60. package/dist/comics/art.d.ts +72 -0
  61. package/dist/comics/art.d.ts.map +1 -0
  62. package/dist/comics/art.js +154 -0
  63. package/dist/comics/art.js.map +1 -0
  64. package/dist/comics/fonts.d.ts +20 -0
  65. package/dist/comics/fonts.d.ts.map +1 -0
  66. package/dist/comics/fonts.js +42 -0
  67. package/dist/comics/fonts.js.map +1 -0
  68. package/dist/comics/geometry.d.ts +87 -0
  69. package/dist/comics/geometry.d.ts.map +1 -0
  70. package/dist/comics/geometry.js +272 -0
  71. package/dist/comics/geometry.js.map +1 -0
  72. package/dist/comics/index.d.ts +29 -0
  73. package/dist/comics/index.d.ts.map +1 -0
  74. package/dist/comics/index.js +18 -0
  75. package/dist/comics/index.js.map +1 -0
  76. package/dist/comics/layoutPage.d.ts +156 -0
  77. package/dist/comics/layoutPage.d.ts.map +1 -0
  78. package/dist/comics/layoutPage.js +697 -0
  79. package/dist/comics/layoutPage.js.map +1 -0
  80. package/dist/comics/lettering/__tests__/harness.test.d.ts +2 -0
  81. package/dist/comics/lettering/__tests__/harness.test.d.ts.map +1 -0
  82. package/dist/comics/lettering/__tests__/harness.test.js +273 -0
  83. package/dist/comics/lettering/__tests__/harness.test.js.map +1 -0
  84. package/dist/comics/lettering/__tests__/letterPanel.test.d.ts +2 -0
  85. package/dist/comics/lettering/__tests__/letterPanel.test.d.ts.map +1 -0
  86. package/dist/comics/lettering/__tests__/letterPanel.test.js +224 -0
  87. package/dist/comics/lettering/__tests__/letterPanel.test.js.map +1 -0
  88. package/dist/comics/lettering/__tests__/polish.test.d.ts +2 -0
  89. package/dist/comics/lettering/__tests__/polish.test.d.ts.map +1 -0
  90. package/dist/comics/lettering/__tests__/polish.test.js +357 -0
  91. package/dist/comics/lettering/__tests__/polish.test.js.map +1 -0
  92. package/dist/comics/lettering/__tests__/round2.test.d.ts +2 -0
  93. package/dist/comics/lettering/__tests__/round2.test.d.ts.map +1 -0
  94. package/dist/comics/lettering/__tests__/round2.test.js +289 -0
  95. package/dist/comics/lettering/__tests__/round2.test.js.map +1 -0
  96. package/dist/comics/lettering/__tests__/shapeText.test.d.ts +2 -0
  97. package/dist/comics/lettering/__tests__/shapeText.test.d.ts.map +1 -0
  98. package/dist/comics/lettering/__tests__/shapeText.test.js +98 -0
  99. package/dist/comics/lettering/__tests__/shapeText.test.js.map +1 -0
  100. package/dist/comics/lettering/__tests__/shapes.test.d.ts +2 -0
  101. package/dist/comics/lettering/__tests__/shapes.test.d.ts.map +1 -0
  102. package/dist/comics/lettering/__tests__/shapes.test.js +137 -0
  103. package/dist/comics/lettering/__tests__/shapes.test.js.map +1 -0
  104. package/dist/comics/lettering/__tests__/stubMeasure.d.ts +5 -0
  105. package/dist/comics/lettering/__tests__/stubMeasure.d.ts.map +1 -0
  106. package/dist/comics/lettering/__tests__/stubMeasure.js +47 -0
  107. package/dist/comics/lettering/__tests__/stubMeasure.js.map +1 -0
  108. package/dist/comics/lettering/__tests__/text.test.d.ts +2 -0
  109. package/dist/comics/lettering/__tests__/text.test.d.ts.map +1 -0
  110. package/dist/comics/lettering/__tests__/text.test.js +86 -0
  111. package/dist/comics/lettering/__tests__/text.test.js.map +1 -0
  112. package/dist/comics/lettering/geom.d.ts +78 -0
  113. package/dist/comics/lettering/geom.d.ts.map +1 -0
  114. package/dist/comics/lettering/geom.js +376 -0
  115. package/dist/comics/lettering/geom.js.map +1 -0
  116. package/dist/comics/lettering/index.d.ts +7 -0
  117. package/dist/comics/lettering/index.d.ts.map +1 -0
  118. package/dist/comics/lettering/index.js +8 -0
  119. package/dist/comics/lettering/index.js.map +1 -0
  120. package/dist/comics/lettering/letter.d.ts +7 -0
  121. package/dist/comics/lettering/letter.d.ts.map +1 -0
  122. package/dist/comics/lettering/letter.js +397 -0
  123. package/dist/comics/lettering/letter.js.map +1 -0
  124. package/dist/comics/lettering/placement.d.ts +117 -0
  125. package/dist/comics/lettering/placement.d.ts.map +1 -0
  126. package/dist/comics/lettering/placement.js +889 -0
  127. package/dist/comics/lettering/placement.js.map +1 -0
  128. package/dist/comics/lettering/presets.d.ts +15 -0
  129. package/dist/comics/lettering/presets.d.ts.map +1 -0
  130. package/dist/comics/lettering/presets.js +61 -0
  131. package/dist/comics/lettering/presets.js.map +1 -0
  132. package/dist/comics/lettering/shape-text.d.ts +72 -0
  133. package/dist/comics/lettering/shape-text.d.ts.map +1 -0
  134. package/dist/comics/lettering/shape-text.js +550 -0
  135. package/dist/comics/lettering/shape-text.js.map +1 -0
  136. package/dist/comics/lettering/shapes.d.ts +83 -0
  137. package/dist/comics/lettering/shapes.d.ts.map +1 -0
  138. package/dist/comics/lettering/shapes.js +512 -0
  139. package/dist/comics/lettering/shapes.js.map +1 -0
  140. package/dist/comics/lettering/text.d.ts +79 -0
  141. package/dist/comics/lettering/text.d.ts.map +1 -0
  142. package/dist/comics/lettering/text.js +488 -0
  143. package/dist/comics/lettering/text.js.map +1 -0
  144. package/dist/comics/lettering/types.d.ts +270 -0
  145. package/dist/comics/lettering/types.d.ts.map +1 -0
  146. package/dist/comics/lettering/types.js +9 -0
  147. package/dist/comics/lettering/types.js.map +1 -0
  148. package/dist/comics/page.d.ts +37 -0
  149. package/dist/comics/page.d.ts.map +1 -0
  150. package/dist/comics/page.js +285 -0
  151. package/dist/comics/page.js.map +1 -0
  152. package/dist/comics/paint.d.ts +65 -0
  153. package/dist/comics/paint.d.ts.map +1 -0
  154. package/dist/comics/paint.js +219 -0
  155. package/dist/comics/paint.js.map +1 -0
  156. package/dist/comics/script.d.ts +42 -0
  157. package/dist/comics/script.d.ts.map +1 -0
  158. package/dist/comics/script.js +302 -0
  159. package/dist/comics/script.js.map +1 -0
  160. package/dist/comics/split.d.ts +135 -0
  161. package/dist/comics/split.d.ts.map +1 -0
  162. package/dist/comics/split.js +514 -0
  163. package/dist/comics/split.js.map +1 -0
  164. package/dist/comics/spread.d.ts +36 -0
  165. package/dist/comics/spread.d.ts.map +1 -0
  166. package/dist/comics/spread.js +172 -0
  167. package/dist/comics/spread.js.map +1 -0
  168. package/dist/comics/strip.d.ts +77 -0
  169. package/dist/comics/strip.d.ts.map +1 -0
  170. package/dist/comics/strip.js +89 -0
  171. package/dist/comics/strip.js.map +1 -0
  172. package/dist/comics/transform.d.ts +37 -0
  173. package/dist/comics/transform.d.ts.map +1 -0
  174. package/dist/comics/transform.js +161 -0
  175. package/dist/comics/transform.js.map +1 -0
  176. package/dist/comics/types.d.ts +143 -0
  177. package/dist/comics/types.d.ts.map +1 -0
  178. package/dist/comics/types.js +13 -0
  179. package/dist/comics/types.js.map +1 -0
  180. package/dist/comics/warnings.d.ts +14 -0
  181. package/dist/comics/warnings.d.ts.map +1 -0
  182. package/dist/comics/warnings.js +94 -0
  183. package/dist/comics/warnings.js.map +1 -0
  184. package/dist/configWarnings.d.ts.map +1 -1
  185. package/dist/configWarnings.js +42 -0
  186. package/dist/configWarnings.js.map +1 -1
  187. package/dist/defaults/comics.d.ts +51 -0
  188. package/dist/defaults/comics.d.ts.map +1 -0
  189. package/dist/defaults/comics.js +385 -0
  190. package/dist/defaults/comics.js.map +1 -0
  191. package/dist/defaults/index.d.ts +1 -0
  192. package/dist/defaults/index.d.ts.map +1 -1
  193. package/dist/defaults/index.js +9 -0
  194. package/dist/defaults/index.js.map +1 -1
  195. package/dist/design/layout.d.ts +13 -1
  196. package/dist/design/layout.d.ts.map +1 -1
  197. package/dist/design/layout.js +17 -0
  198. package/dist/design/layout.js.map +1 -1
  199. package/dist/html-backend.d.ts +3 -0
  200. package/dist/html-backend.d.ts.map +1 -1
  201. package/dist/html-backend.js +44 -8
  202. package/dist/html-backend.js.map +1 -1
  203. package/dist/htmlComic.d.ts +69 -0
  204. package/dist/htmlComic.d.ts.map +1 -0
  205. package/dist/htmlComic.js +179 -0
  206. package/dist/htmlComic.js.map +1 -0
  207. package/dist/index.d.ts +8 -2
  208. package/dist/index.d.ts.map +1 -1
  209. package/dist/index.js +4 -0
  210. package/dist/index.js.map +1 -1
  211. package/dist/parse/blockParser.d.ts.map +1 -1
  212. package/dist/parse/blockParser.js +25 -1
  213. package/dist/parse/blockParser.js.map +1 -1
  214. package/dist/parse/indexMarks.d.ts.map +1 -1
  215. package/dist/parse/indexMarks.js +3 -0
  216. package/dist/parse/indexMarks.js.map +1 -1
  217. package/dist/parse/types.d.ts +7 -1
  218. package/dist/parse/types.d.ts.map +1 -1
  219. package/dist/pipeline/build.d.ts.map +1 -1
  220. package/dist/pipeline/build.js +251 -9
  221. package/dist/pipeline/build.js.map +1 -1
  222. package/dist/pipeline/config.d.ts.map +1 -1
  223. package/dist/pipeline/config.js +4 -1
  224. package/dist/pipeline/config.js.map +1 -1
  225. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  226. package/dist/pipeline/contentWarnings.js +58 -0
  227. package/dist/pipeline/contentWarnings.js.map +1 -1
  228. package/dist/pipeline/floatSlots.js +1 -1
  229. package/dist/pipeline/floatSlots.js.map +1 -1
  230. package/dist/pipeline/headerFooter.d.ts +6 -2
  231. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  232. package/dist/pipeline/headerFooter.js +11 -3
  233. package/dist/pipeline/headerFooter.js.map +1 -1
  234. package/dist/pipeline/pageRoles.d.ts +1 -0
  235. package/dist/pipeline/pageRoles.d.ts.map +1 -1
  236. package/dist/pipeline/pageRoles.js +3 -0
  237. package/dist/pipeline/pageRoles.js.map +1 -1
  238. package/dist/pipeline/placement.d.ts +3 -3
  239. package/dist/pipeline/placement.d.ts.map +1 -1
  240. package/dist/pipeline/placement.js +4 -4
  241. package/dist/pipeline/placement.js.map +1 -1
  242. package/dist/types.d.ts +338 -1
  243. package/dist/types.d.ts.map +1 -1
  244. package/dist/vdt.d.ts +271 -2
  245. package/dist/vdt.d.ts.map +1 -1
  246. package/dist/vdt.js.map +1 -1
  247. package/dist/worker/fonts.d.ts +2 -1
  248. package/dist/worker/fonts.d.ts.map +1 -1
  249. package/dist/worker/fonts.js +11 -1
  250. package/dist/worker/fonts.js.map +1 -1
  251. package/package.json +1 -1
@@ -0,0 +1,889 @@
1
+ // Where the balloons of one panel go (SPEC D3.3). Deterministic:
2
+ // captions pinned to a corner or an edge are butted first; the others are
3
+ // placed greedily in reading order, each at the best of a set of candidate
4
+ // centres (a grid over the panel and slots around its speaker), then a few
5
+ // passes move each one to its best candidate given all the others. The
6
+ // cost weighs hard faults (covering a face, an avoid zone, a speaker's
7
+ // mouth or another balloon; running out of the panel) far above the soft
8
+ // preferences (near its speaker, high in the panel, Kurlander's reading
9
+ // order mirrored for right-to-left pages, tails that do not cross). When a
10
+ // balloon cannot be placed cleanly the fallbacks are tried in order: break
11
+ // the border (when allowed), cover avoid zones (never faces), reshape the
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';
14
+ import { tailLength } from './shapes';
15
+ const W = {
16
+ face: 60,
17
+ avoid: 25,
18
+ anchor: 30,
19
+ balloon: 60,
20
+ outside: 120,
21
+ order: 1.2,
22
+ orderBase: 5,
23
+ distance: 0.7,
24
+ far: 0.18,
25
+ below: 0.3,
26
+ height: 0.1,
27
+ start: 0.03,
28
+ cross: 15,
29
+ tailThrough: 20,
30
+ tangent: 0.6,
31
+ ownTail: 25,
32
+ otherFace: 15,
33
+ tailOverFigure: 5,
34
+ side: 3,
35
+ soft: 0.035,
36
+ longTail: 0.3,
37
+ };
38
+ /** The part of a panel lettering may use: its polygon, less what runs
39
+ * past the page's live area (a bleeding panel's art reaches the edge of
40
+ * the sheet; its lettering stays where the trim cannot cut it). */
41
+ export function livePolygon(panel) {
42
+ if (!panel.limit)
43
+ return panel.polygon.map((p) => ({ ...p }));
44
+ const clipped = clipToRect(panel.polygon, panel.limit);
45
+ return clipped.length >= 3 ? clipped : panel.polygon.map((p) => ({ ...p }));
46
+ }
47
+ function insetRect(r, d) {
48
+ const dx = Math.min(d, r.width / 2);
49
+ const dy = Math.min(d, r.height / 2);
50
+ return { x: r.x + dx, y: r.y + dy, width: r.width - 2 * dx, height: r.height - 2 * dy };
51
+ }
52
+ function sceneOf(panel) {
53
+ const live = livePolygon(panel);
54
+ let visible = insetConvex(live, panel.insetPx);
55
+ if (visible.length < 3)
56
+ visible = live;
57
+ const trim = panel.trim ?? panel.limit;
58
+ return {
59
+ panel,
60
+ visible,
61
+ visBox: boundsOf(visible),
62
+ faces: panel.anchors.filter((a) => a.face).map((a) => a.face),
63
+ avoid: [...(panel.avoid ?? [])],
64
+ soft: [...(panel.softAvoid ?? [])],
65
+ mouths: panel.anchors.filter((a) => a.visible && a.id !== 'sfx').map((a) => a.mouth),
66
+ fwd: panel.direction === 'rtl' ? -1 : 1,
67
+ buttPoly: buttPolygon(panel),
68
+ neighbours: [...(panel.neighbours ?? [])],
69
+ ...(panel.limit ? { limit: panel.limit } : {}),
70
+ ...(trim ? { edge: insetRect(trim, panel.insetPx), edgeSfx: panel.sheet ?? trim } : {}),
71
+ foreign: [...(panel.foreign ?? [])],
72
+ keepOut: [...(panel.keepOut ?? [])],
73
+ live,
74
+ };
75
+ }
76
+ function buttPolygon(panel) {
77
+ const half = (panel.borderPx ?? 0) / 2;
78
+ const live = livePolygon(panel);
79
+ if (half <= 0)
80
+ return live;
81
+ const poly = insetConvex(live, half);
82
+ return poly.length >= 3 ? poly : live;
83
+ }
84
+ const moved = (pts, at) => pts.map((p) => ({ x: p.x + at.x, y: p.y + at.y }));
85
+ const movedRect = (r, at) => ({ x: r.x + at.x, y: r.y + at.y, width: r.width, height: r.height });
86
+ /** Area of `rect` the samples (standing for `area` px²) fall in. */
87
+ function coveredArea(samples, rect, area) {
88
+ let n = 0;
89
+ for (const s of samples)
90
+ if (pointInRect(s, rect))
91
+ n++;
92
+ return (n / Math.max(1, samples.length)) * area;
93
+ }
94
+ function areaOf(v) {
95
+ // The bodies' boxes, as an ellipse fills them.
96
+ return v.bodies.reduce((s, b) => s + b.bbox.width * b.bbox.height * 0.8, 0);
97
+ }
98
+ /** Where a unit's tail goes for the cost: the target point, or none. */
99
+ function tailTargetOf(unit, scene, centre) {
100
+ const t = unit.target;
101
+ if (t.kind === 'point')
102
+ return t.point;
103
+ if (t.kind === 'offPanel')
104
+ return offPanelPoint(scene.panel, centre, t.toward, t.side);
105
+ return undefined;
106
+ }
107
+ /** The border point an off-panel tail runs to: toward a cropped anchor,
108
+ * on a named side, else the nearest border point. */
109
+ export function offPanelPoint(panel, centre, toward, side) {
110
+ const poly = livePolygon(panel);
111
+ if (toward) {
112
+ const u = norm(sub(toward, centre));
113
+ const t = rayExit(centre, u, poly);
114
+ if (t !== undefined)
115
+ return add(centre, scale(u, t));
116
+ }
117
+ if (side) {
118
+ const b = panel.bbox;
119
+ const rtl = panel.direction === 'rtl';
120
+ const phys = side === 'start' ? (rtl ? 'right' : 'left') : side === 'end' ? (rtl ? 'left' : 'right') : side;
121
+ const probe = phys === 'top' ? { x: centre.x, y: b.y - 1e4 }
122
+ : phys === 'bottom' ? { x: centre.x, y: b.y + b.height + 1e4 }
123
+ : phys === 'left' ? { x: b.x - 1e4, y: centre.y } : { x: b.x + b.width + 1e4, y: centre.y };
124
+ const u = norm(sub(probe, centre));
125
+ const t = rayExit(centre, u, poly);
126
+ if (t !== undefined)
127
+ return add(centre, scale(u, t));
128
+ }
129
+ return nearestOnPolygon(centre, poly);
130
+ }
131
+ /** Cost of `unit` (variant `vi`) at `at`, given the other placed units. */
132
+ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
133
+ const v = unit.variants[vi];
134
+ const em = unit.em;
135
+ const em2 = em * em;
136
+ const box = movedRect(v.bbox, at);
137
+ const samples = moved(v.samples, at);
138
+ const area = areaOf(v);
139
+ const reasons = new Set();
140
+ let hard = 0;
141
+ let soft = 0;
142
+ const S = (k, x) => {
143
+ soft += x;
144
+ if (terms)
145
+ terms[k] = (terms[k] ?? 0) + x;
146
+ };
147
+ if (v.cost)
148
+ S('arrangement', v.cost);
149
+ const H = (k, x) => {
150
+ hard += x;
151
+ if (terms)
152
+ terms[`!${k}`] = (terms[`!${k}`] ?? 0) + x;
153
+ };
154
+ // Faces, avoid zones, mouths.
155
+ for (const f of scene.faces) {
156
+ if (!rectsOverlap(box, f))
157
+ continue;
158
+ const a = coveredArea(samples, f, area);
159
+ if (a > 0) {
160
+ H('face', (W.face * a) / em2 + W.face);
161
+ reasons.add('face');
162
+ }
163
+ }
164
+ for (const r of scene.avoid) {
165
+ if (!rectsOverlap(box, r))
166
+ continue;
167
+ const a = coveredArea(samples, r, area);
168
+ if (a > 0) {
169
+ if (level.coverAvoid)
170
+ S('coverAvoid', (2 * a) / em2);
171
+ else {
172
+ H('avoid', (W.avoid * a) / em2 + W.avoid);
173
+ reasons.add('avoid');
174
+ }
175
+ }
176
+ }
177
+ for (const r of scene.soft) {
178
+ if (!rectsOverlap(box, r))
179
+ continue;
180
+ S('softAvoid', ((r.weight ?? 1) * W.soft * coveredArea(samples, r, area)) / em2);
181
+ }
182
+ const bodiesHere = v.bodies.map((b) => moved(b.core, at));
183
+ for (const m of scene.mouths) {
184
+ if (!pointInRect(m, box, 0.4 * em))
185
+ continue;
186
+ if (bodiesHere.some((poly) => pointInConvex(m, poly) || dist(nearestOnPolygon(m, poly), m) < 0.4 * em)) {
187
+ H('anchor', W.anchor);
188
+ reasons.add('anchor');
189
+ }
190
+ }
191
+ // Outside the panel. A unit that breaks its border on purpose (`break`,
192
+ // at the level that allows it; a pin, where its author put it) may run
193
+ // past the border, the gutter and the live area: a little cost, never a
194
+ // fault — but never off the sheet, and never into another panel's art
195
+ // unless it is a sound effect or a `break` (drawn sound and a balloon
196
+ // breaking into the next panel are both the letterer's call).
197
+ const rim = moved(v.rim, at);
198
+ const intended = unit.pin !== undefined || (level.breakBorder && unit.breakBorder === true);
199
+ const edge = intended ? (unit.kind === 'sfx' ? scene.edgeSfx : scene.edge) ?? scene.limit : scene.limit;
200
+ let out = 0;
201
+ let intrude = 0;
202
+ let offPage = 0;
203
+ // A butted caption sits flush with the border itself.
204
+ const area0 = unit.butt && unit.position ? scene.buttPoly : scene.visible;
205
+ for (const p of rim) {
206
+ if (!pointInConvex(p, area0) && !(unit.butt && unit.position && dist(nearestOnPolygon(p, area0), p) < 1)) {
207
+ out++;
208
+ if (edge && !pointInRect(p, edge))
209
+ offPage++;
210
+ else if (scene.neighbours.some((r) => pointInRect(p, r)))
211
+ intrude++;
212
+ }
213
+ }
214
+ const mayIntrude = intended && (unit.kind === 'sfx' || unit.breakBorder === true);
215
+ // Off the sheet is the worst; into another panel (its art, its
216
+ // lettering) a fault weighed against the others — a balloon poking into
217
+ // the next panel reads better than one over its speaker's face.
218
+ if (offPage > 0) {
219
+ H('offPage', (12 * W.outside * offPage) / rim.length + 200);
220
+ reasons.add('outside');
221
+ }
222
+ if (intrude > 0 && !mayIntrude) {
223
+ H('neighbour', (4 * W.outside * intrude) / rim.length + 40);
224
+ reasons.add('outside');
225
+ }
226
+ else if (intrude > 0)
227
+ S('neighbour', 4 + (8 * intrude) / rim.length);
228
+ if (intended) {
229
+ if (out > 0) {
230
+ const f = out / rim.length;
231
+ // Breaking the border is a matter of degree: a balloon may stick out
232
+ // of its panel, but one whose middle stands outside it reads as
233
+ // another panel's (drawn sound may; so may a balloon its author
234
+ // pinned). A pinned balloon (no `break`) slides back in when it can.
235
+ S('breakBorder', 3 * f + (unit.kind !== 'sfx' && !unit.breakBorder ? 6 : 0));
236
+ if (unit.kind !== 'sfx' && !unit.pin && !pointInConvex({ x: box.x + box.width / 2, y: box.y + box.height / 2 }, scene.live)) {
237
+ H('outside', W.outside * f + 10);
238
+ reasons.add('outside');
239
+ }
240
+ }
241
+ }
242
+ else if (out > 0) {
243
+ H('outside', (W.outside * out) / rim.length + 10);
244
+ reasons.add('outside');
245
+ }
246
+ // The fold of a spread: never, at any level, pinned or not.
247
+ for (const r of scene.keepOut) {
248
+ if (!rectsOverlap(box, r))
249
+ continue;
250
+ const ox = Math.min(box.x + box.width, r.x + r.width) - Math.max(box.x, r.x);
251
+ H('avoid', (W.avoid * 4 * ox) / em + 4 * W.avoid);
252
+ reasons.add('avoid');
253
+ }
254
+ // Balloons of the other panels (a balloon breaking into this one).
255
+ for (const r of scene.foreign) {
256
+ if (!rectsOverlap(box, r, 0.3 * em))
257
+ continue;
258
+ const a = coveredArea(samples, grow(r, 0.3 * em), area);
259
+ if (a > 0) {
260
+ H('balloon', (W.balloon * a) / em2 + W.balloon);
261
+ reasons.add('balloon');
262
+ }
263
+ }
264
+ // Other balloons: overlap is a fault, a near miss (a tangent) a blemish.
265
+ const centre = at;
266
+ const target = tailTargetOf(unit, scene, centre);
267
+ for (const o of others) {
268
+ const ov = o.unit.variants[o.variant];
269
+ const obox = movedRect(ov.bbox, o.at);
270
+ if (rectsOverlap(box, obox, 0.5 * em)) {
271
+ const opolys = ov.bodies.map((b) => moved(b.core, o.at));
272
+ let inside = 0;
273
+ for (const s of samples)
274
+ if (opolys.some((poly) => pointInConvex(s, poly)))
275
+ inside++;
276
+ if (inside > 0) {
277
+ H('balloon', (W.balloon * (inside / samples.length) * area) / em2 + W.balloon);
278
+ reasons.add('balloon');
279
+ }
280
+ else
281
+ S('tangent', W.tangent);
282
+ }
283
+ // Reading order (Kurlander), for the two in their script order.
284
+ const [eBox, lBox] = o.unit.order < unit.order ? [obox, box] : [box, obox];
285
+ const forward = (lBox.x + lBox.width / 2 - (eBox.x + eBox.width / 2)) * scene.fwd < 0;
286
+ // `forward`: the earlier one lies forward (in reading direction) of the
287
+ // later one: the later may be no higher than its bottom; else no
288
+ // higher than its top.
289
+ const limit = forward ? eBox.y + eBox.height - 0.25 * em : eBox.y - 0.25 * em;
290
+ if (unit.kind !== 'sfx' && o.unit.kind !== 'sfx' && lBox.y < limit) {
291
+ const overlapX = Math.min(lBox.x + lBox.width, eBox.x + eBox.width) - Math.max(lBox.x, eBox.x);
292
+ // Side by side at one height reads in order when the earlier one
293
+ // is behind; a later one above an earlier one never does.
294
+ S('order', W.orderBase + (W.order * (limit - lBox.y)) / em + (overlapX > 0 ? 1 : 0));
295
+ }
296
+ // Tails that cross (as drawn: from the wall to the tip), tails
297
+ // through another balloon.
298
+ const otarget = tailTargetOf(o.unit, scene, o.at);
299
+ const mine = target ? tailSegment(unit, v, at, target) : undefined;
300
+ const theirs = otarget ? tailSegment(o.unit, ov, o.at, otarget) : undefined;
301
+ if (mine && theirs) {
302
+ // Crossing tails; tails that all but touch read as crossed too
303
+ // (unless they run to one speaker).
304
+ const gapTails = segmentDistance(mine[0], mine[1], theirs[0], theirs[1]);
305
+ if (gapTails === 0)
306
+ S('cross', W.cross);
307
+ else if (gapTails < 0.6 * em && dist(target, otarget) > em)
308
+ S('cross', 0.4 * W.cross);
309
+ }
310
+ if (mine && ov.bodies.some((b) => segmentHitsRect(mine[0], mine[1], shrink(movedRect(b.bbox, o.at), 0.12))))
311
+ S('tailThrough', W.tailThrough);
312
+ if (theirs && v.bodies.some((b) => segmentHitsRect(theirs[0], theirs[1], shrink(movedRect(b.bbox, at), 0.12))))
313
+ S('tailThrough', W.tailThrough);
314
+ }
315
+ // The tail leaves the first body clear of the group's other bodies, by
316
+ // more than its own half width: a tail grazing another body of its group
317
+ // leaves a notch between them that the doubled outline fills with ink.
318
+ if (target && v.bodies.length > 1) {
319
+ const edge = edgePoint(v, at, target);
320
+ for (const b of v.bodies.slice(1))
321
+ if (segmentHitsRect(edge, target, grow(movedRect(b.bbox, at), 0.8 * em)))
322
+ S('ownTail', W.ownTail);
323
+ }
324
+ // Near the speaker, a little above; tails not over faces.
325
+ if (target && unit.target.kind === 'point') {
326
+ const edge = edgePoint(v, at, target);
327
+ const gap = dist(edge, target);
328
+ const h = v.bodies[0].bbox.height;
329
+ // Room for a tail of a few ems: too close is worse than too far.
330
+ const ideal = Math.max(2 * em, 0.7 * h);
331
+ S('distance', gap < ideal ? (W.distance * (ideal - gap)) / em : (W.far * (gap - ideal)) / em);
332
+ if (gap < 0.7 * em)
333
+ S('tooClose', 3);
334
+ // A tail longer than a few ems reads as an arrow: the cost of a far
335
+ // balloon grows faster past six ems. (Over a close-up the balloon goes
336
+ // above the head and its tail runs down past the face: that one is
337
+ // long by necessity.)
338
+ if (gap > 6 * em)
339
+ S('longTail', (W.longTail * (gap - 6 * em)) / em);
340
+ if (centre.y > target.y)
341
+ S('below', (W.below * (centre.y - target.y)) / em);
342
+ // A tail over another character's face (or the top of the head)
343
+ // reads as theirs.
344
+ const tip = add(edge, scale(norm(sub(target, edge)), gap * 0.55));
345
+ for (const f of scene.faces) {
346
+ const head = { x: f.x, y: f.y - 0.3 * f.height, width: f.width, height: 1.3 * f.height };
347
+ if (!segmentHitsRect(edge, tip, shrink(head, 0.1)))
348
+ continue;
349
+ S('otherFace', pointInRect(target, head) ? 1 : W.otherFace);
350
+ }
351
+ // A tail drawn across another figure (the body under a marked face):
352
+ // the eye follows it through the wrong character.
353
+ const drawn = tailSegment(unit, v, at, target);
354
+ for (const a of scene.panel.anchors) {
355
+ if (!a.face || a.mouth === target || a.head === target)
356
+ continue;
357
+ const f = a.face;
358
+ const figure = { x: f.x - 0.2 * f.width, y: f.y + f.height, width: 1.4 * f.width, height: 1.6 * f.height };
359
+ if (segmentHitsRect(drawn[0], drawn[1], figure))
360
+ S('tailOverFigure', W.tailOverFigure);
361
+ }
362
+ }
363
+ else if (unit.target.kind === 'offPanel' && target) {
364
+ // An off-panel speaker's balloon sits by the border its voice comes
365
+ // from, with a short tail to it (the letterer's practice): a tail
366
+ // across the panel reads as an arrow, so the balloon moves instead.
367
+ const reach = dist(edgePoint(v, at, target), target);
368
+ S('side', (W.side * Math.max(0, reach - 1.2 * em) + 3 * W.side * Math.max(0, reach - 3 * em)) / em);
369
+ }
370
+ if (unit.kind === 'sfx' && unit.near)
371
+ S('sfxNear', (0.5 * dist(centre, unit.near)) / em);
372
+ // High in the panel; the first one in the top start corner.
373
+ if (unit.kind !== 'sfx') {
374
+ const top = box.y - scene.visBox.y;
375
+ S('height', ((scene.heightBias ?? 1) * (unit.kind === 'note' ? W.height * Math.max(0, scene.visBox.y + scene.visBox.height - (box.y + box.height)) : W.height * top)) / em);
376
+ if (unit.order === firstOrder && unit.kind !== 'note') {
377
+ const fromStart = scene.fwd === 1 ? box.x - scene.visBox.x : scene.visBox.x + scene.visBox.width - (box.x + box.width);
378
+ S('start', (W.start * fromStart) / em);
379
+ }
380
+ }
381
+ return { hard, soft, reasons };
382
+ }
383
+ /** A rectangle grown by `d` px on every side. */
384
+ function grow(r, d) {
385
+ return { x: r.x - d, y: r.y - d, width: r.width + 2 * d, height: r.height + 2 * d };
386
+ }
387
+ function shrink(r, f) {
388
+ 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) };
389
+ }
390
+ /** A unit's tail as drawn, for the cost: from where it leaves the first
391
+ * body to its tip ({@link tailLength} of the gap). */
392
+ function tailSegment(unit, v, at, target) {
393
+ const edge = edgePoint(v, at, target);
394
+ const gap = dist(edge, target);
395
+ const len = Math.max(Math.min(gap, 0.8 * unit.em), tailLength(gap, unit.em, {}, unit.target.kind === 'offPanel'));
396
+ return [edge, add(edge, scale(norm(sub(target, edge)), len))];
397
+ }
398
+ /** Where the line from the first body's centre to `target` leaves it. */
399
+ function edgePoint(v, at, target) {
400
+ const b = v.bodies[0];
401
+ const c = { x: b.centre.x + at.x, y: b.centre.y + at.y };
402
+ const u = norm(sub(target, c));
403
+ const t = rayExit(c, u, moved(b.core, at));
404
+ return t === undefined ? c : add(c, scale(u, t));
405
+ }
406
+ /** Candidate anchor positions of a unit (deterministic order). */
407
+ function candidates(scene, unit, vi) {
408
+ const v = unit.variants[vi];
409
+ const out = [];
410
+ const seen = new Set();
411
+ const push = (p) => {
412
+ const k = `${Math.round(p.x * 2)},${Math.round(p.y * 2)}`;
413
+ if (seen.has(k))
414
+ return;
415
+ seen.add(k);
416
+ out.push(p);
417
+ };
418
+ const vb = scene.visBox;
419
+ // Clamp the unit's box into the visible box (a candidate hugging a side).
420
+ const clamp = (p) => {
421
+ const box = movedRect(v.bbox, p);
422
+ let dx = 0;
423
+ let dy = 0;
424
+ if (box.width <= vb.width) {
425
+ if (box.x < vb.x)
426
+ dx = vb.x - box.x;
427
+ else if (box.x + box.width > vb.x + vb.width)
428
+ dx = vb.x + vb.width - (box.x + box.width);
429
+ }
430
+ if (box.height <= vb.height) {
431
+ if (box.y < vb.y)
432
+ dy = vb.y - box.y;
433
+ else if (box.y + box.height > vb.y + vb.height)
434
+ dy = vb.y + vb.height - (box.y + box.height);
435
+ }
436
+ return { x: p.x + dx, y: p.y + dy };
437
+ };
438
+ // Anchor-relative offset: the unit box's centre to its anchor.
439
+ const off = { x: -(v.bbox.x + v.bbox.width / 2), y: -(v.bbox.y + v.bbox.height / 2) };
440
+ const at = (cx, cy) => ({ x: cx + off.x, y: cy + off.y });
441
+ const w = v.bbox.width;
442
+ const h = v.bbox.height;
443
+ const em = unit.em;
444
+ // Around the target (speaker or sound-effect point).
445
+ const target = unit.target.kind === 'point' ? unit.target.point : unit.near;
446
+ if (target) {
447
+ for (const k of [0.6, 1.2, 2, 3]) {
448
+ for (const fx of [0, -0.45, 0.45, -0.85, 0.85]) {
449
+ const p = at(target.x + fx * w, target.y - h / 2 - k * em - 0.4 * h);
450
+ push(p);
451
+ push(clamp(p));
452
+ }
453
+ }
454
+ for (const side of [-1, 1]) {
455
+ for (const k of [0, 0.6, 1.2]) {
456
+ const p = at(target.x + side * (w / 2 + 1.5 * em), target.y - k * h);
457
+ push(p);
458
+ push(clamp(p));
459
+ }
460
+ }
461
+ // Under the speaker (a last resort the cost weighs: in a cell with no
462
+ // room above the head, below it beats over the face).
463
+ if (unit.kind !== 'sfx') {
464
+ for (const k of [1.2, 2.4]) {
465
+ for (const fx of [0, -0.45, 0.45])
466
+ push(at(target.x + fx * w, target.y + h / 2 + k * em));
467
+ }
468
+ }
469
+ if (unit.kind === 'sfx')
470
+ push(at(target.x, target.y));
471
+ }
472
+ // A grid over the visible box.
473
+ const step = Math.max(em, Math.min(vb.width, vb.height) / 12);
474
+ for (let y = vb.y + h / 2; y <= vb.y + vb.height - h / 2 + 1e-6; y += step) {
475
+ for (let x = vb.x + w / 2; x <= vb.x + vb.width - w / 2 + 1e-6; x += step)
476
+ push(at(x, y));
477
+ }
478
+ // The corners and edges, butted against the visible box.
479
+ for (const fy of [0, 0.5, 1]) {
480
+ for (const fx of [0, 0.5, 1])
481
+ push(clamp(at(vb.x + fx * vb.width + (0.5 - fx) * w, vb.y + fy * vb.height + (0.5 - fy) * h)));
482
+ }
483
+ if (out.length === 0)
484
+ push(at(vb.x + vb.width / 2, vb.y + vb.height / 2));
485
+ return out;
486
+ }
487
+ /** The anchor a corner or edge keyword puts a unit at: its box flush
488
+ * with the panel (`butt`) or with the inset area, at that corner. */
489
+ export function positionAnchor(scene, v, position, butt) {
490
+ const poly = butt ? scene.buttPoly : scene.visible;
491
+ const box = boundsOf(poly);
492
+ const startLeft = scene.fwd === 1;
493
+ const hx = position === 'top' || position === 'bottom' ? 0.5
494
+ : position.endsWith('start') ? (startLeft ? 0 : 1) : (startLeft ? 1 : 0);
495
+ const vy = position.startsWith('top') ? 0 : 1;
496
+ const bx = box.x + hx * (box.width - v.bbox.width);
497
+ const by = box.y + vy * (box.height - v.bbox.height);
498
+ // Move inward until the box's corners sit inside a slanted cell.
499
+ const inward = { x: hx === 0 ? 1 : hx === 1 ? -1 : 0, y: vy === 0 ? 1 : -1 };
500
+ let p = { x: bx - v.bbox.x, y: by - v.bbox.y };
501
+ for (let i = 0; i < 400; i++) {
502
+ const r = movedRect(v.bbox, p);
503
+ const corners = [{ x: r.x, y: r.y }, { x: r.x + r.width, y: r.y }, { x: r.x + r.width, y: r.y + r.height }, { x: r.x, y: r.y + r.height }];
504
+ if (corners.every((c) => pointInConvex(c, poly) || dist(nearestOnPolygon(c, poly), c) < 0.75))
505
+ break;
506
+ p = { x: p.x + inward.x, y: p.y + inward.y };
507
+ }
508
+ return p;
509
+ }
510
+ /** A corner or edge unit's anchors: its own corner first, then — a cost
511
+ * each, so taken only when its own covers a face, an avoid zone or a
512
+ * balloon — the other corner of that edge, the middle of it, and the
513
+ * corners of the opposite edge. */
514
+ function positionAnchors(scene, v, position, butt) {
515
+ const top = position.startsWith('top');
516
+ const across = position.endsWith('start') ? (top ? 'top-end' : 'bottom-end') : position.endsWith('end') ? (top ? 'top-start' : 'bottom-start') : (top ? 'top-start' : 'bottom-start');
517
+ const middle = top ? 'top' : 'bottom';
518
+ const flip = (q) => (q.startsWith('top') ? q.replace('top', 'bottom') : q.replace('bottom', 'top'));
519
+ const list = [[position, 0], [across, 2], [middle, 3], [flip(position), 5], [flip(across), 6]];
520
+ const seen = new Set();
521
+ return list.filter(([q]) => (seen.has(q) ? false : (seen.add(q), true))).map(([q, extra]) => ({ at: positionAnchor(scene, v, q, butt), extra }));
522
+ }
523
+ const LEVELS = [
524
+ { breakBorder: false, coverAvoid: false },
525
+ { breakBorder: true, coverAvoid: false },
526
+ { breakBorder: true, coverAvoid: true },
527
+ ];
528
+ function better(a, b) {
529
+ if (a.hard > 0 || b.hard > 0)
530
+ return a.hard * 1000 + a.soft < b.hard * 1000 + b.soft - 1e-9;
531
+ return a.soft < b.soft - 1e-9;
532
+ }
533
+ /** Best spot for one unit given the others, over its variants and the
534
+ * fallback levels (in SPEC order); the first clean choice wins. */
535
+ function bestFor(scene, unit, others, firstOrder, fixed) {
536
+ let fallback;
537
+ const variants = fixed ? [fixed.variant] : unit.variants.map((_, i) => i);
538
+ const levels = fixed ? [fixed.level] : LEVELS.map((_, i) => i).filter((i) => i === 0 || unit.breakBorder || i === 2);
539
+ // Every arrangement of a joined group competes on its cost; a reshaped
540
+ // text block (wider or narrower than its best shape) pays a little for
541
+ // its poorer shape, and is tried before covering an avoid zone (a
542
+ // letterer reshapes the text before covering a hand).
543
+ const penalty = (vi) => (unit.variants[vi].reshaped ? RESHAPE_COST : 0);
544
+ for (const li of levels) {
545
+ const level = LEVELS[li];
546
+ let best;
547
+ for (const vi of variants) {
548
+ const cands = unit.pin
549
+ // (The pin as written wins unless it runs out of the panel.)
550
+ ? pinAnchors(scene, unit, vi).map((at, i) => ({ at, extra: i === 0 ? 0 : 4 }))
551
+ : unit.position
552
+ ? positionAnchors(scene, unit.variants[vi], unit.position, unit.butt === true)
553
+ : candidates(scene, unit, vi).map((at) => ({ at, extra: 0 }));
554
+ for (const { at, extra } of cands) {
555
+ const ev = evaluate(scene, unit, vi, at, others, level, firstOrder);
556
+ const score = ev.hard * 1000 + ev.soft + penalty(vi) + extra;
557
+ if (!best || score < best.score - 1e-9)
558
+ best = { variant: vi, level: li, at, ev, score };
559
+ }
560
+ }
561
+ if (!best)
562
+ continue;
563
+ const choice = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
564
+ if (choice.ev.hard === 0)
565
+ return choice;
566
+ if (!fallback || better(choice.ev, fallback.ev))
567
+ fallback = choice;
568
+ }
569
+ return fallback;
570
+ }
571
+ /** What choosing a reshaped text block costs (its poorer shape). */
572
+ const RESHAPE_COST = 1.5;
573
+ /** A pinned unit's anchors: on its pin, and moved the least that keeps
574
+ * its box inside the panel (a pinned label too long for the room left
575
+ * at its pin slides in rather than run out of the panel). */
576
+ function pinAnchors(scene, unit, vi) {
577
+ const p = pinAnchor(unit, vi);
578
+ const v = unit.variants[vi];
579
+ const box = movedRect(v.bbox, p);
580
+ // Drawn sound stays where its author put it, over borders and all; it
581
+ // only slides back onto the sheet. A pinned balloon slides into its
582
+ // panel when it would run out of it.
583
+ const vb = unit.kind === 'sfx' ? (scene.edgeSfx ?? scene.limit ?? scene.visBox) : scene.visBox;
584
+ const shift = (lo, size, min, max) => size > max - min ? (min + max) / 2 - (lo + size / 2) : lo < min ? min - lo : lo + size > max ? max - (lo + size) : 0;
585
+ const dx = shift(box.x, box.width, vb.x, vb.x + vb.width);
586
+ const dy = shift(box.y, box.height, vb.y, vb.y + vb.height);
587
+ const out = dx === 0 && dy === 0 ? [p] : [p, { x: p.x + dx, y: p.y + dy }];
588
+ // Off the fold of a spread, to the nearer side (then the other).
589
+ for (const q of [...out]) {
590
+ const b = movedRect(v.bbox, q);
591
+ for (const r of scene.keepOut) {
592
+ if (!rectsOverlap(b, r))
593
+ continue;
594
+ const left = r.x - (b.x + b.width) - 1;
595
+ const right = r.x + r.width - b.x + 1;
596
+ const sides = Math.abs(left) <= Math.abs(right) ? [left, right] : [right, left];
597
+ for (const d of sides)
598
+ out.push({ x: q.x + d, y: q.y });
599
+ }
600
+ }
601
+ return out;
602
+ }
603
+ /** The anchor that puts a pinned unit's box centre on its pin; for a
604
+ * joined group, the centre of its first body's box (the pinned line's
605
+ * balloon: the lines joined to it follow wherever the group arranges
606
+ * them). */
607
+ function pinAnchor(unit, vi) {
608
+ const v = unit.variants[vi];
609
+ const box = v.bodies.length > 1 ? v.bodies[0].bbox : v.bbox;
610
+ return { x: unit.pin.x - (box.x + box.width / 2), y: unit.pin.y - (box.y + box.height / 2) };
611
+ }
612
+ function solve(scene, ordered, firstOrder, passes, mode = 'reading') {
613
+ const placed = [];
614
+ // Corner and edge captions first, then pinned units, then the rest in
615
+ // reading order (or the biggest first: in a crowded panel the balloon
616
+ // that needs the most room takes it first); a sound effect with no place
617
+ // of its own (no pin, no `sfx` point in the art) last: the dialogue
618
+ // takes the room it needs first, the sound fills what is left.
619
+ const rank = (u) => (u.position ? 0 : u.pin ? 1 : u.kind === 'sfx' && !u.near ? 3 : 2);
620
+ const size = (u) => u.variants[0].bbox.width * u.variants[0].bbox.height;
621
+ const standing = (u) => (u.target.kind === 'point' ? scene.fwd * u.target.point.x : Number.POSITIVE_INFINITY);
622
+ const sequence = [...ordered].sort((a, b) => rank(a) - rank(b)
623
+ || (mode === 'big' ? size(b) - size(a) : mode === 'speaker' ? standing(a) - standing(b) : 0)
624
+ || a.order - b.order);
625
+ for (const unit of sequence) {
626
+ const c = bestFor(scene, unit, placed, firstOrder);
627
+ placed.push({ unit, variant: c.variant, at: c.at, level: c.level, ev: c.ev });
628
+ }
629
+ // Local improvement: each free unit moves to its best spot given the
630
+ // others, keeping its variant and level.
631
+ return solveFrom(scene, placed, firstOrder, passes);
632
+ }
633
+ /** Improvement passes from an existing layout. */
634
+ function solveFrom(scene, start, firstOrder, passes) {
635
+ let placed = [...start];
636
+ for (let pass = 0; pass < passes; pass++) {
637
+ let changed = false;
638
+ // Two balloons whose tails cross trade places (no single move can
639
+ // undo a crossing: each balloon alone is best where it is).
640
+ const swapped = swapCrossing(scene, placed, firstOrder);
641
+ if (swapped) {
642
+ placed = swapped;
643
+ changed = true;
644
+ }
645
+ for (let i = 0; i < placed.length; i++) {
646
+ const p = placed[i];
647
+ if (p.unit.pin || p.unit.position)
648
+ continue;
649
+ const others = placed.filter((_, j) => j !== i);
650
+ const now = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
651
+ const c = bestFor(scene, p.unit, others, firstOrder, { variant: p.variant, level: p.level });
652
+ if (better(c.ev, now) && (c.at.x !== p.at.x || c.at.y !== p.at.y)) {
653
+ placed[i] = { ...p, at: c.at, ev: c.ev };
654
+ changed = true;
655
+ }
656
+ else
657
+ placed[i] = { ...p, ev: now };
658
+ }
659
+ if (!changed)
660
+ break;
661
+ }
662
+ return placed;
663
+ }
664
+ /** The layout with the first pair of free units whose tails cross
665
+ * swapped (each box centred where the other's was, then settled at its
666
+ * best spot), when that lowers the whole layout's cost; else undefined. */
667
+ function swapCrossing(scene, placed, firstOrder) {
668
+ const free = (p) => !p.unit.pin && !p.unit.position && p.unit.kind === 'balloon';
669
+ const centreOf = (p) => {
670
+ const b = p.unit.variants[p.variant].bbox;
671
+ return { x: p.at.x + b.x + b.width / 2, y: p.at.y + b.y + b.height / 2 };
672
+ };
673
+ const base = totalCost(scene, placed, firstOrder);
674
+ for (let i = 0; i < placed.length; i++) {
675
+ for (let j = i + 1; j < placed.length; j++) {
676
+ const a = placed[i];
677
+ const b = placed[j];
678
+ if (!free(a) || !free(b))
679
+ continue;
680
+ const ta = tailTargetOf(a.unit, scene, a.at);
681
+ const tb = tailTargetOf(b.unit, scene, b.at);
682
+ if (!ta || !tb)
683
+ continue;
684
+ const sa = tailSegment(a.unit, a.unit.variants[a.variant], a.at, ta);
685
+ const sb = tailSegment(b.unit, b.unit.variants[b.variant], b.at, tb);
686
+ const em = Math.min(a.unit.em, b.unit.em);
687
+ if (segmentDistance(sa[0], sa[1], sb[0], sb[1]) >= 0.6 * em || dist(ta, tb) <= em)
688
+ continue;
689
+ const ca = centreOf(a);
690
+ const cb = centreOf(b);
691
+ const moveTo = (p, c) => {
692
+ const bb = p.unit.variants[p.variant].bbox;
693
+ return { ...p, at: { x: c.x - (bb.x + bb.width / 2), y: c.y - (bb.y + bb.height / 2) } };
694
+ };
695
+ const raw = placed.map((p, k) => (k === i ? moveTo(a, cb) : k === j ? moveTo(b, ca) : p));
696
+ // Then each settled at its best spot given the other.
697
+ const settled = [...raw];
698
+ for (const k of [i, j]) {
699
+ const p = settled[k];
700
+ const others = settled.filter((_, m) => m !== k);
701
+ const c = bestFor(scene, p.unit, others, firstOrder, { variant: p.variant, level: p.level });
702
+ const now = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
703
+ if (better(c.ev, now))
704
+ settled[k] = { ...p, at: c.at, ev: c.ev };
705
+ }
706
+ // Or both taken out and placed again, each order in turn, the second
707
+ // seeing the first one's tail.
708
+ const replaced = (first, second) => {
709
+ const out = [...placed];
710
+ const rest = () => out.filter((_, m) => m !== i && m !== j);
711
+ const p1 = out[first];
712
+ const c1 = bestFor(scene, p1.unit, rest(), firstOrder);
713
+ out[first] = { ...p1, variant: c1.variant, level: c1.level, at: c1.at, ev: c1.ev };
714
+ const p2 = out[second];
715
+ const c2 = bestFor(scene, p2.unit, out.filter((_, m) => m !== second), firstOrder);
716
+ out[second] = { ...p2, variant: c2.variant, level: c2.level, at: c2.at, ev: c2.ev };
717
+ return out;
718
+ };
719
+ let trial;
720
+ let cost = base;
721
+ for (const t of [raw, settled, replaced(j, i), replaced(i, j)]) {
722
+ const c = totalCost(scene, t, firstOrder);
723
+ if (c < cost - 1e-6) {
724
+ cost = c;
725
+ trial = t;
726
+ }
727
+ }
728
+ if (trial)
729
+ return trial;
730
+ }
731
+ }
732
+ return undefined;
733
+ }
734
+ /** The layout with one pair of free units taken out and placed again
735
+ * (each order in turn, the second seeing the first) when that lowers the
736
+ * whole layout's cost; else undefined. A single move cannot undo a pair
737
+ * that blocks itself: the first speaker's balloon in the corner the second
738
+ * needs, the second pushed onto the figures. */
739
+ function replacePair(scene, placed, firstOrder) {
740
+ const free = (p) => !p.unit.pin && !p.unit.position && p.unit.kind !== 'sfx';
741
+ const base = totalCost(scene, placed, firstOrder);
742
+ let best;
743
+ let cost = base;
744
+ for (let i = 0; i < placed.length; i++) {
745
+ for (let j = i + 1; j < placed.length; j++) {
746
+ if (!free(placed[i]) || !free(placed[j]))
747
+ continue;
748
+ for (const [first, second] of [[i, j], [j, i]]) {
749
+ const out = [...placed];
750
+ const p1 = out[first];
751
+ const c1 = bestFor(scene, p1.unit, out.filter((_, m) => m !== i && m !== j), firstOrder);
752
+ out[first] = { ...p1, variant: c1.variant, level: c1.level, at: c1.at, ev: c1.ev };
753
+ const p2 = out[second];
754
+ const c2 = bestFor(scene, p2.unit, out.filter((_, m) => m !== second), firstOrder);
755
+ out[second] = { ...p2, variant: c2.variant, level: c2.level, at: c2.at, ev: c2.ev };
756
+ const c = totalCost(scene, out, firstOrder);
757
+ if (c < cost - 1e-6) {
758
+ cost = c;
759
+ best = out;
760
+ }
761
+ }
762
+ }
763
+ }
764
+ return best;
765
+ }
766
+ /**
767
+ * A placed layout with its crossed tails undone: pairs of balloons whose
768
+ * tails cross (or all but touch) trade places or are placed again, each
769
+ * seeing the other's tail, as long as the whole layout gets cheaper. What
770
+ * {@link placeUnits} runs between its improvement passes, on its own.
771
+ */
772
+ export function uncrossTails(panel, placed) {
773
+ const scene = sceneOf(panel);
774
+ const ordered = [...placed].sort((a, b) => a.unit.order - b.unit.order);
775
+ const firstOrder = ordered.find((p) => p.unit.kind !== 'sfx' && p.unit.kind !== 'note')?.unit.order ?? Number.NaN;
776
+ let layout = placed.map((p) => {
777
+ const others = placed.filter((o) => o !== p);
778
+ return { ...p, level: 0, ev: evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[0], firstOrder) };
779
+ });
780
+ for (let k = 0; k < 4; k++) {
781
+ const next = swapCrossing(scene, layout, firstOrder);
782
+ if (!next)
783
+ break;
784
+ layout = next;
785
+ }
786
+ return layout.map(({ ev: _ev, level: _level, ...rest }) => rest);
787
+ }
788
+ /** How many pairs of tails of a layout cross (or all but touch). */
789
+ function crossings(scene, placed) {
790
+ let n = 0;
791
+ for (let i = 0; i < placed.length; i++) {
792
+ for (let j = i + 1; j < placed.length; j++) {
793
+ const a = placed[i];
794
+ const b = placed[j];
795
+ const ta = tailTargetOf(a.unit, scene, a.at);
796
+ const tb = tailTargetOf(b.unit, scene, b.at);
797
+ if (!ta || !tb || dist(ta, tb) <= Math.min(a.unit.em, b.unit.em))
798
+ continue;
799
+ const sa = tailSegment(a.unit, a.unit.variants[a.variant], a.at, ta);
800
+ const sb = tailSegment(b.unit, b.unit.variants[b.variant], b.at, tb);
801
+ if (segmentDistance(sa[0], sa[1], sb[0], sb[1]) < 0.6 * Math.min(a.unit.em, b.unit.em))
802
+ n++;
803
+ }
804
+ }
805
+ return n;
806
+ }
807
+ /** The whole layout's cost under the plain weights (hard faults first). */
808
+ function totalCost(scene, placed, firstOrder) {
809
+ let hard = 0;
810
+ let soft = 0;
811
+ for (let i = 0; i < placed.length; i++) {
812
+ const p = placed[i];
813
+ const ev = evaluate(scene, p.unit, p.variant, p.at, placed.filter((_, j) => j !== i), LEVELS[p.level], firstOrder);
814
+ hard += ev.hard;
815
+ soft += ev.soft;
816
+ }
817
+ return hard * 1000 + soft;
818
+ }
819
+ /** Height biases the solver is run with: a greedy pass that lifts every
820
+ * balloon higher first can leave room below for the later ones (the
821
+ * Kurlander order often wants the earlier balloons high); the layout with
822
+ * the lowest plain cost wins. */
823
+ const HEIGHT_BIASES = [1, 3, 7];
824
+ /** Place every unit of a panel (see the module comment). */
825
+ export function placeUnits(panel, units, passes = 3) {
826
+ const scene = sceneOf(panel);
827
+ const ordered = [...units].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
828
+ const firstOrder = ordered.find((u) => u.kind !== 'sfx' && u.kind !== 'note')?.order ?? Number.NaN;
829
+ let placed;
830
+ let bestCost = Number.POSITIVE_INFINITY;
831
+ const tries = (mode) => {
832
+ for (const bias of HEIGHT_BIASES) {
833
+ let run = solve({ ...scene, heightBias: bias }, ordered, firstOrder, passes, mode);
834
+ // Settle under the plain weights.
835
+ if (bias !== 1)
836
+ run = solveFrom(scene, run, firstOrder, passes);
837
+ const cost = totalCost(scene, run, firstOrder);
838
+ if (cost < bestCost - 1e-9) {
839
+ bestCost = cost;
840
+ placed = run;
841
+ }
842
+ }
843
+ };
844
+ tries('reading');
845
+ // A layout with a fault left (a balloon out of the panel, over a face or
846
+ // another balloon): the greedy order may have boxed the last balloons
847
+ // in; try again placing the biggest first.
848
+ if (bestCost >= 1000 && ordered.length > 1)
849
+ tries('big');
850
+ // Tails still crossing (speakers standing in the order opposite to the
851
+ // one they speak in): try again from where the speakers stand, each
852
+ // balloon by its own speaker first.
853
+ if (ordered.length > 1 && crossings(scene, placed) > 0)
854
+ tries('speaker');
855
+ // Pairs placed again together, while that helps (a few rounds).
856
+ for (let round = 0; round < 3 && ordered.length > 1; round++) {
857
+ const next = replacePair(scene, placed, firstOrder);
858
+ if (!next)
859
+ break;
860
+ placed = solveFrom(scene, next, firstOrder, passes);
861
+ }
862
+ const final = placed;
863
+ const diagnostics = [];
864
+ for (let i = 0; i < final.length; i++) {
865
+ const p = final[i];
866
+ const others = final.filter((_, j) => j !== i);
867
+ const ev = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
868
+ const fallbacks = [];
869
+ if (p.level >= 1 && p.unit.breakBorder)
870
+ fallbacks.push('breakBorder');
871
+ if (p.level >= 2)
872
+ fallbacks.push('coverAvoid');
873
+ if (p.unit.variants[p.variant].reshaped)
874
+ fallbacks.push('reshape');
875
+ if (ev.hard > 0) {
876
+ diagnostics.push({
877
+ itemId: p.unit.id,
878
+ panelIndex: panel.index,
879
+ sourceStart: p.unit.sourceStart,
880
+ sourceEnd: p.unit.sourceEnd,
881
+ reasons: [...ev.reasons].sort(),
882
+ fallbacks,
883
+ });
884
+ }
885
+ }
886
+ final.sort((a, b) => a.unit.order - b.unit.order);
887
+ return { placed: final.map(({ ev: _ev, ...rest }) => rest), diagnostics };
888
+ }
889
+ //# sourceMappingURL=placement.js.map