postext 1.20.0 → 1.20.2

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.
@@ -61,18 +61,40 @@ function sceneOf(panel) {
61
61
  visBox: boundsOf(visible),
62
62
  faces: panel.anchors.filter((a) => a.face).map((a) => a.face),
63
63
  avoid: [...(panel.avoid ?? [])],
64
- soft: [...(panel.softAvoid ?? [])],
64
+ soft: figureContacts(panel.softAvoid ?? [], panel.anchors),
65
65
  mouths: panel.anchors.filter((a) => a.visible && a.id !== 'sfx').map((a) => a.mouth),
66
66
  fwd: panel.direction === 'rtl' ? -1 : 1,
67
67
  buttPoly: buttPolygon(panel),
68
68
  neighbours: [...(panel.neighbours ?? [])],
69
69
  ...(panel.limit ? { limit: panel.limit } : {}),
70
70
  ...(trim ? { edge: insetRect(trim, panel.insetPx), edgeSfx: panel.sheet ?? trim } : {}),
71
+ bleeds: panel.limit !== undefined && panel.polygon.some((p) => !pointInRect(p, panel.limit, 0.5)),
71
72
  foreign: [...(panel.foreign ?? [])],
72
73
  keepOut: [...(panel.keepOut ?? [])],
73
74
  live,
74
75
  };
75
76
  }
77
+ /** How many times a balloon costs more over another character's figure
78
+ * than over its own speaker's (a letterer covers the speaker's shoulder
79
+ * before the one being spoken to). */
80
+ const OTHER_FIGURE = 4;
81
+ /** Soft zones with `contact` marked on the figures of characters whose
82
+ * faces all but touch (a hug, cheek to cheek): there the arms of one lie
83
+ * over the other, and no speaker's own figure is free to cover. Two
84
+ * figures merely standing side by side are not in contact. */
85
+ function figureContacts(zones, anchors) {
86
+ const faceOf = (id) => anchors.find((a) => a.id === id)?.face;
87
+ const touching = (a, b) => {
88
+ const gx = Math.max(a.x, b.x) - Math.min(a.x + a.width, b.x + b.width);
89
+ const gy = Math.max(a.y, b.y) - Math.min(a.y + a.height, b.y + b.height);
90
+ return gx <= 0.25 * Math.min(a.width, b.width) && gy <= 0.25 * Math.min(a.height, b.height);
91
+ };
92
+ return zones.map((z) => {
93
+ const f = z.owner !== undefined ? faceOf(z.owner) : undefined;
94
+ const contact = f !== undefined && anchors.some((a) => a.id !== z.owner && a.face !== undefined && touching(f, a.face));
95
+ return contact ? { ...z, contact: true } : { ...z };
96
+ });
97
+ }
76
98
  function buttPolygon(panel) {
77
99
  const half = (panel.borderPx ?? 0) / 2;
78
100
  const live = livePolygon(panel);
@@ -91,6 +113,29 @@ function coveredArea(samples, rect, area) {
91
113
  n++;
92
114
  return (n / Math.max(1, samples.length)) * area;
93
115
  }
116
+ /** Whether a face fills a good part of its panel (a close shot): its box
117
+ * then has corners of hair a balloon may cover (in a long shot they are a
118
+ * few pixels beside the face). */
119
+ function closeShot(face, vis) {
120
+ return face.width >= 0.35 * vis.width || face.height >= 0.35 * vis.height;
121
+ }
122
+ /** Area of a face's box the samples fall in, less its top corners: above
123
+ * its middle a face is the oval its box holds (the corners are hair or
124
+ * background); below it, the whole box (cheeks, jaw and chin). */
125
+ function coveredOval(samples, rect, area) {
126
+ const cx = rect.x + rect.width / 2;
127
+ const cy = rect.y + rect.height / 2;
128
+ const rx = rect.width / 2;
129
+ const ry = rect.height / 2;
130
+ let n = 0;
131
+ for (const s of samples) {
132
+ if (!pointInRect(s, rect))
133
+ continue;
134
+ if (s.y >= cy || ((s.x - cx) / rx) ** 2 + ((s.y - cy) / ry) ** 2 <= 1)
135
+ n++;
136
+ }
137
+ return (n / Math.max(1, samples.length)) * area;
138
+ }
94
139
  function areaOf(v) {
95
140
  // The bodies' boxes, as an ellipse fills them.
96
141
  return v.bodies.reduce((s, b) => s + b.bbox.width * b.bbox.height * 0.8, 0);
@@ -139,8 +184,17 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
139
184
  const reasons = new Set();
140
185
  let hard = 0;
141
186
  let soft = 0;
187
+ let order = 0;
188
+ let cover = 0;
189
+ let coverArt = 0;
142
190
  const S = (k, x) => {
143
191
  soft += x;
192
+ if (k === 'order')
193
+ order += x;
194
+ else if (k === 'coverAvoid' || k === 'coverArt')
195
+ cover += x;
196
+ if (k === 'coverArt')
197
+ coverArt += x;
144
198
  if (terms)
145
199
  terms[k] = (terms[k] ?? 0) + x;
146
200
  };
@@ -151,11 +205,12 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
151
205
  if (terms)
152
206
  terms[`!${k}`] = (terms[`!${k}`] ?? 0) + x;
153
207
  };
154
- // Faces, avoid zones, mouths.
208
+ // Faces (in a close shot less the top corners of their boxes, hair or
209
+ // background), avoid zones, mouths.
155
210
  for (const f of scene.faces) {
156
211
  if (!rectsOverlap(box, f))
157
212
  continue;
158
- const a = coveredArea(samples, f, area);
213
+ const a = closeShot(f, scene.visBox) ? coveredOval(samples, f, area) : coveredArea(samples, f, area);
159
214
  if (a > 0) {
160
215
  H('face', (W.face * a) / em2 + W.face);
161
216
  reasons.add('face');
@@ -166,8 +221,11 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
166
221
  continue;
167
222
  const a = coveredArea(samples, r, area);
168
223
  if (a > 0) {
224
+ // (What the art marks costs three times what a guard over a head
225
+ // does: a balloon touches the top of the hair before it covers a
226
+ // hand the artist asked to keep clear.)
169
227
  if (level.coverAvoid)
170
- S('coverAvoid', (2 * a) / em2);
228
+ S(r.guard ? 'coverAvoid' : 'coverArt', ((r.guard ? 2 : 6) * a) / em2);
171
229
  else {
172
230
  H('avoid', (W.avoid * a) / em2 + W.avoid);
173
231
  reasons.add('avoid');
@@ -177,7 +235,8 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
177
235
  for (const r of scene.soft) {
178
236
  if (!rectsOverlap(box, r))
179
237
  continue;
180
- S('softAvoid', ((r.weight ?? 1) * W.soft * coveredArea(samples, r, area)) / em2);
238
+ const other = r.owner !== undefined && (r.contact === true || r.owner !== unit.speaker);
239
+ S(other ? 'otherFigure' : 'softAvoid', ((r.weight ?? 1) * (other ? OTHER_FIGURE : 1) * W.soft * coveredArea(samples, r, area)) / em2);
181
240
  }
182
241
  const bodiesHere = v.bodies.map((b) => moved(b.core, at));
183
242
  for (const m of scene.mouths) {
@@ -190,13 +249,19 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
190
249
  }
191
250
  // Outside the panel. A unit that breaks its border on purpose (`break`,
192
251
  // 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).
252
+ // past the border and the gutter: a little cost, never a fault — but
253
+ // never off the sheet, and never into another panel's art unless it is
254
+ // a sound effect or a `break` (drawn sound and a balloon breaking into
255
+ // the next panel are both the letterer's call). Only a pin, drawn sound
256
+ // or the `break` of a panel that bleeds leaves the live area: a balloon
257
+ // left to the engine breaks into the gutter and the next panel, never
258
+ // into the margin.
197
259
  const rim = moved(v.rim, at);
198
260
  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;
261
+ const edge = !intended ? scene.limit
262
+ : unit.kind === 'sfx' ? scene.edgeSfx ?? scene.limit
263
+ : unit.pin !== undefined || scene.bleeds ? scene.edge ?? scene.limit
264
+ : scene.limit ?? scene.edge;
200
265
  let out = 0;
201
266
  let intrude = 0;
202
267
  let offPage = 0;
@@ -369,8 +434,10 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
369
434
  }
370
435
  if (unit.kind === 'sfx' && unit.near)
371
436
  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') {
437
+ // High in the panel; the first one in the top start corner. (A unit set
438
+ // at a corner or an edge has its place: a caption at the foot of a panel
439
+ // is not drawn to the top of it.)
440
+ if (unit.kind !== 'sfx' && !unit.position) {
374
441
  const top = box.y - scene.visBox.y;
375
442
  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
443
  if (unit.order === firstOrder && unit.kind !== 'note') {
@@ -378,7 +445,7 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
378
445
  S('start', (W.start * fromStart) / em);
379
446
  }
380
447
  }
381
- return { hard, soft, reasons };
448
+ return { hard, soft, reasons, order, cover, coverArt };
382
449
  }
383
450
  /** A rectangle grown by `d` px on every side. */
384
451
  function grow(r, d) {
@@ -486,11 +553,12 @@ function candidates(scene, unit, vi) {
486
553
  }
487
554
  /** The anchor a corner or edge keyword puts a unit at: its box flush
488
555
  * with the panel (`butt`) or with the inset area, at that corner. */
489
- export function positionAnchor(scene, v, position, butt) {
556
+ export function positionAnchor(scene, v, position, butt, along) {
490
557
  const poly = butt ? scene.buttPoly : scene.visible;
491
558
  const box = boundsOf(poly);
492
559
  const startLeft = scene.fwd === 1;
493
- const hx = position === 'top' || position === 'bottom' ? 0.5
560
+ // `along`: where on its edge (0 left, 1 right) instead of the keyword's.
561
+ const hx = along !== undefined ? along : position === 'top' || position === 'bottom' ? 0.5
494
562
  : position.endsWith('start') ? (startLeft ? 0 : 1) : (startLeft ? 1 : 0);
495
563
  const vy = position.startsWith('top') ? 0 : 1;
496
564
  const bx = box.x + hx * (box.width - v.bbox.width);
@@ -509,8 +577,10 @@ export function positionAnchor(scene, v, position, butt) {
509
577
  }
510
578
  /** A corner or edge unit's anchors: its own corner first, then — a cost
511
579
  * 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. */
580
+ * balloon — the other corner of that edge, the middle of it, the spots
581
+ * along it just clear of what it would cover (between two heads), and
582
+ * only then the corners of the opposite edge: a caption at the head of a
583
+ * panel (a strip's title) stays at its head when there is room there. */
514
584
  function positionAnchors(scene, v, position, butt) {
515
585
  const top = position.startsWith('top');
516
586
  const across = position.endsWith('start') ? (top ? 'top-end' : 'bottom-end') : position.endsWith('end') ? (top ? 'top-start' : 'bottom-start') : (top ? 'top-start' : 'bottom-start');
@@ -518,7 +588,27 @@ function positionAnchors(scene, v, position, butt) {
518
588
  const flip = (q) => (q.startsWith('top') ? q.replace('top', 'bottom') : q.replace('bottom', 'top'));
519
589
  const list = [[position, 0], [across, 2], [middle, 3], [flip(position), 5], [flip(across), 6]];
520
590
  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 }));
591
+ const out = list.filter(([q]) => (seen.has(q) ? false : (seen.add(q), true))).map(([q, extra]) => ({ at: positionAnchor(scene, v, q, butt), extra }));
592
+ // Along the edge: the box butted against each side of every face or
593
+ // avoid zone in the edge's band, nearer its own corner first.
594
+ const own = positionAnchor(scene, v, position, butt);
595
+ const band = movedRect(v.bbox, own);
596
+ const span = boundsOf(butt ? scene.buttPoly : scene.visible);
597
+ const free = span.width - v.bbox.width;
598
+ if (free > 0) {
599
+ const ownX = band.x;
600
+ for (const r of [...scene.faces, ...scene.avoid]) {
601
+ if (r.y > band.y + band.height || r.y + r.height < band.y)
602
+ continue;
603
+ for (const x of [r.x + r.width + 1, r.x - v.bbox.width - 1]) {
604
+ const f = (x - span.x) / free;
605
+ if (f <= 0 || f >= 1)
606
+ continue;
607
+ out.push({ at: positionAnchor(scene, v, position, butt, f), extra: 3.5 + Math.abs(x - ownX) / span.width });
608
+ }
609
+ }
610
+ }
611
+ return out;
522
612
  }
523
613
  const LEVELS = [
524
614
  { breakBorder: false, coverAvoid: false },
@@ -562,12 +652,53 @@ function bestFor(scene, unit, others, firstOrder, fixed) {
562
652
  continue;
563
653
  const choice = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
564
654
  if (choice.ev.hard === 0)
565
- return choice;
655
+ return fixed || li !== 0 ? choice : orderBeforeAvoid(scene, unit, others, firstOrder, best, penalty);
566
656
  if (!fallback || better(choice.ev, fallback.ev))
567
657
  fallback = choice;
568
658
  }
569
659
  return fallback;
570
660
  }
661
+ /** What taking the last fallback (covering an avoid zone a little) to keep
662
+ * the reading order costs on top of the cover itself. */
663
+ const ORDER_OVER_AVOID = 4;
664
+ /** The most a balloon may cover of avoid zones (as their cost: 2 per em²
665
+ * of a guard over a head, 6 of a zone the art marks) to keep the reading
666
+ * order: the top of the hair over a face, the edge of a hat; of what the
667
+ * art marks, hardly a corner. */
668
+ const ORDER_COVER_MAX = 16;
669
+ const ORDER_COVER_ART_MAX = 6;
670
+ /** Whether a spot (its evaluation and cost terms) may stand in for one
671
+ * that reads out of order: clean, in order, covering no more of avoid
672
+ * zones than the caps, and its tail off the other faces (a tail across
673
+ * another character's face reads as theirs, worse than a line read
674
+ * late). */
675
+ function keepsOrder(ev, terms) {
676
+ return ev.hard === 0 && ev.order < W.orderBase && ev.cover <= ORDER_COVER_MAX && ev.coverArt <= ORDER_COVER_ART_MAX
677
+ && (terms.otherFace ?? 0) < W.otherFace;
678
+ }
679
+ /** A clean choice that reads out of order (the balloon above the one it
680
+ * answers, or beside it on the wrong side) against the same unit covering
681
+ * a little of an avoid zone in reading order: a letterer lets a balloon
682
+ * touch the top of a head before setting a reply ahead of its line. */
683
+ function orderBeforeAvoid(scene, unit, others, firstOrder, best, penalty) {
684
+ const plain = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
685
+ if (best.ev.order < W.orderBase || unit.pin || unit.position)
686
+ return plain;
687
+ const level = LEVELS[2];
688
+ let alt;
689
+ for (let vi = 0; vi < unit.variants.length; vi++) {
690
+ for (const at of candidates(scene, unit, vi)) {
691
+ const terms = {};
692
+ const ev = evaluate(scene, unit, vi, at, others, level, firstOrder, terms);
693
+ if (!keepsOrder(ev, terms))
694
+ continue;
695
+ const score = ev.soft + penalty(vi) + ORDER_OVER_AVOID;
696
+ if (!alt || score < alt.score - 1e-9)
697
+ alt = { variant: vi, level: 2, at, ev, score };
698
+ }
699
+ }
700
+ return alt && alt.score < best.score - 1e-9 ? { variant: alt.variant, level: alt.level, at: alt.at, ev: alt.ev } : plain;
701
+ }
571
702
  /** What choosing a reshaped text block costs (its poorer shape). */
572
703
  const RESHAPE_COST = 1.5;
573
704
  /** A pinned unit's anchors: on its pin, and moved the least that keeps
@@ -731,6 +862,59 @@ function swapCrossing(scene, placed, firstOrder) {
731
862
  }
732
863
  return undefined;
733
864
  }
865
+ /** The cheapest spot for `unit` that reads in order with the others, over
866
+ * all its shapes and fallback levels (see {@link keepsOrder}; covering an
867
+ * avoid zone pays as much more as in {@link orderBeforeAvoid}); undefined
868
+ * when there is none. */
869
+ function inOrder(scene, unit, others, firstOrder) {
870
+ const levels = LEVELS.map((_, i) => i).filter((i) => i === 0 || unit.breakBorder || i === 2);
871
+ let best;
872
+ for (const li of levels) {
873
+ for (let vi = 0; vi < unit.variants.length; vi++) {
874
+ const penalty = unit.variants[vi].reshaped ? RESHAPE_COST : 0;
875
+ for (const at of candidates(scene, unit, vi)) {
876
+ const terms = {};
877
+ const ev = evaluate(scene, unit, vi, at, others, LEVELS[li], firstOrder, terms);
878
+ if (!keepsOrder(ev, terms))
879
+ continue;
880
+ const score = ev.soft + penalty + (ev.cover > 0 ? ORDER_OVER_AVOID : 0);
881
+ if (!best || score < best.score - 1e-9)
882
+ best = { variant: vi, level: li, at, ev, score };
883
+ }
884
+ }
885
+ }
886
+ return best && { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
887
+ }
888
+ /** The layout with one balloon that reads out of order placed again over
889
+ * all its shapes and fallbacks, the others where they stand, when that
890
+ * lowers the whole layout's cost; else undefined. The improvement passes
891
+ * keep a balloon's shape and level, and a pair placed again has the later
892
+ * balloon alone first, in the room the earlier one needs: neither moves
893
+ * the first line of a pair above the hair of its speaker, in a shape a
894
+ * line deeper, ahead of the reply that already stands below it. */
895
+ function reorder(scene, placed, firstOrder) {
896
+ const base = totalCost(scene, placed, firstOrder);
897
+ let best;
898
+ let cost = base;
899
+ for (let i = 0; i < placed.length; i++) {
900
+ const p = placed[i];
901
+ if (p.unit.pin || p.unit.position || p.unit.kind === 'sfx' || p.ev.order < W.orderBase)
902
+ continue;
903
+ const others = placed.filter((_, j) => j !== i);
904
+ const c = inOrder(scene, p.unit, others, firstOrder);
905
+ if (!c)
906
+ continue;
907
+ const out = placed.map((q, j) => (j === i ? { ...q, variant: c.variant, level: c.level, at: c.at, ev: c.ev } : q));
908
+ // (Covering an avoid zone to read in order pays what `bestFor` asks;
909
+ // the whole layout's cost counts the order on both balloons of a pair.)
910
+ const t = totalCost(scene, out, firstOrder) + (c.ev.cover > 0 ? ORDER_OVER_AVOID : 0);
911
+ if (t < cost - 1e-6) {
912
+ cost = t;
913
+ best = out;
914
+ }
915
+ }
916
+ return best;
917
+ }
734
918
  /** The layout with one pair of free units taken out and placed again
735
919
  * (each order in turn, the second seeing the first) when that lowers the
736
920
  * whole layout's cost; else undefined. A single move cannot undo a pair
@@ -816,16 +1000,9 @@ function totalCost(scene, placed, firstOrder) {
816
1000
  }
817
1001
  return hard * 1000 + soft;
818
1002
  }
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;
1003
+ /** The search of {@link placeUnits}: greedy runs in a few orders and
1004
+ * height biases, then pairs placed again; the cheapest layout. */
1005
+ function search(scene, ordered, firstOrder, passes) {
829
1006
  let placed;
830
1007
  let bestCost = Number.POSITIVE_INFINITY;
831
1008
  const tries = (mode) => {
@@ -859,6 +1036,35 @@ export function placeUnits(panel, units, passes = 3) {
859
1036
  break;
860
1037
  placed = solveFrom(scene, next, firstOrder, passes);
861
1038
  }
1039
+ // Balloons still read out of order placed again with every shape and
1040
+ // fallback open, the others where they stand.
1041
+ for (let round = 0; round < 2 && ordered.length > 1; round++) {
1042
+ const next = reorder(scene, placed, firstOrder);
1043
+ if (!next)
1044
+ break;
1045
+ placed = solveFrom(scene, next, firstOrder, passes);
1046
+ }
1047
+ return { placed: placed, cost: totalCost(scene, placed, firstOrder) };
1048
+ }
1049
+ /** Height biases the solver is run with: a greedy pass that lifts every
1050
+ * balloon higher first can leave room below for the later ones (the
1051
+ * Kurlander order often wants the earlier balloons high); the layout with
1052
+ * the lowest plain cost wins. */
1053
+ const HEIGHT_BIASES = [1, 3, 7];
1054
+ /** Place every unit of a panel (see the module comment). `extend`, when
1055
+ * given, adds spare variants to the units (see `letterPanelDetailed`):
1056
+ * a panel the first search leaves with a fault is searched again with
1057
+ * them, and the cheaper layout is kept. */
1058
+ export function placeUnits(panel, units, passes = 3, extend) {
1059
+ const scene = sceneOf(panel);
1060
+ const ordered = [...units].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1061
+ const firstOrder = ordered.find((u) => u.kind !== 'sfx' && u.kind !== 'note')?.order ?? Number.NaN;
1062
+ let { placed, cost } = search(scene, ordered, firstOrder, passes);
1063
+ if (cost >= 1000 && extend?.()) {
1064
+ const again = search(scene, ordered, firstOrder, passes);
1065
+ if (again.cost < cost - 1e-9)
1066
+ ({ placed, cost } = again);
1067
+ }
862
1068
  const final = placed;
863
1069
  const diagnostics = [];
864
1070
  for (let i = 0; i < final.length; i++) {