1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
|
import {
captionFontStack,
hasGlyphs,
layoutText,
textToPathData,
} from './textOutlines.js';
/**
* The speech bubble that sits next to the character.
*
* The same caption is drawn three times: into the 2D canvas that is overlaid on
* the 3D view, into the canvas that gets composited into an exported PNG, and
* into the vector SVG export. Drawing a shape three times by hand is how a
* preview starts to disagree with the file it produced, so the outline is built
* exactly once, here, as a short list of path commands. `drawCaption` replays
* that list through a 2D context and `captionToSvg` prints the same list as SVG
* path data, which is what keeps the two renderers together.
*
* The module never measures or wraps text itself: `textOutlines.js` owns line
* breaking and glyph outlines. This file only decides how large the bubble has
* to be for the block it is handed, padding included, and guarantees that block
* fits inside.
*
* It is pure in the same sense as `trace.js`: no DOM, no globals and no
* asynchronous work, so the tests can drive it in Node with a stub context.
*/
/** Defaults mirroring the `caption` slice of `presets.js`. */
const DEFAULT_FONT_SIZE = 34;
const DEFAULT_LINE_HEIGHT = 1.42;
const DEFAULT_PADDING = 18;
const DEFAULT_RADIUS = 24;
const DEFAULT_BORDER_WIDTH = 4;
const DEFAULT_MAX_WIDTH = 0.36;
const DEFAULT_TEXT_COLOR = '#3f2b52';
const DEFAULT_BUBBLE_COLOR = '#ffffff';
const DEFAULT_BORDER_COLOR = '#55386e';
/** Advance widths used before the vendored font has loaded, as a fraction of em. */
const FALLBACK_WIDE_EM = 1;
const FALLBACK_NARROW_EM = 0.55;
/**
* Vertical metrics of the fallback stack, as a fraction of the font size.
*
* A typical Japanese family sits close to these numbers, so the baseline of the
* pre-load preview barely moves when the real font arrives.
*/
const FALLBACK_ASCENT = 0.88;
const FALLBACK_DESCENT = 0.12;
/** Control-point distance that turns a corner into a quarter circle. */
const KAPPA = 0.5522847498307936;
/** Code-point ranges that deserve a full em in the fallback measurement. */
const WIDE_RANGES = [
[0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf],
[0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef],
];
/**
* Punctuation that may hang past the bottom of a vertical column.
*
* Japanese line breaking (禁則処理) forbids starting a line with a closing
* mark, so when one of these would land at the top of a new column it is kept
* on the previous column and drawn past that column's last cell instead.
*/
const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
/** Small kana, which sit toward the upper-right of their cell when stacked. */
const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
/**
* Characters that may not open a column (行頭禁則).
*
* A closing mark such as `、` simply hangs off the previous column; `ー`, small
* kana and closing brackets are kept company by handing the preceding
* character over to the next column as well, so a column never starts with
* something that has nothing to attach to.
*/
const COLUMN_START_FORBIDDEN = new Set([
'」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー',
...SMALL_KANA,
...HANGING_PUNCTUATION,
]);
/** Characters that may not close a column (行末禁則). */
const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']);
/**
* Per-character offset for vertical (縦書き) text, in em.
*
* A column draws one glyph per cell, so a few characters need a nudge to read
* the way a Japanese typesetter would place them: the prolonged sound mark
* becomes an upright tick, small kana tuck into the upper-right, and the
* brackets lean toward the ends of the span they enclose. Everything else
* stays dead-centre. Sharing this table is what keeps the canvas and the SVG
* export in step.
*/
const VERTICAL_OFFSETS = new Map();
for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 });
// 縦書きの句読点は、横書きの左下ではなく、字枠の右上に置く。
for (const ch of HANGING_PUNCTUATION) VERTICAL_OFFSETS.set(ch, { dx: 0.68, dy: -0.55 });
/**
* Characters drawn turned a quarter turn when stacked, so they read the way
* 縦書き sets them: the prolonged sound mark `ー` becomes an upright bar, and the
* brackets `「」『』[]()` turn so they open down the column.
*/
const VERTICAL_ROTATED = new Set(['ー', '~', '〜', '―']);
const VERTICAL_BRACKETS = new Set([
'「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '〔', '〕', '〈', '〉', '《', '》',
]);
/**
* How far a rotated glyph is nudged down inside its cell, in em. The rotated `ー`
* is centred otherwise, which leaves it hugging the character above it; the
* brackets stay centred.
*/
const VERTICAL_ROTATED_DROP = 0.14;
/**
* The downward nudge (em) for a glyph that is turned a quarter turn in 縦書き, or
* `null` when the glyph is drawn the ordinary way.
*/
function verticalRotation(ch) {
if (VERTICAL_ROTATED.has(ch)) return VERTICAL_ROTATED_DROP;
if (VERTICAL_BRACKETS.has(ch)) return 0;
return null;
}
/** Offset used for the characters that need no special placement. */
const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 });
/**
* The `ctx.font` string for a caption.
*
* Canvas wants size, weight and family in one string, and the size has to carry
* the export scale because the same caption is drawn at 1x for the preview and
* at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored
* subset ready?": until it is, the vendored family is dropped from the stack so
* the preview does not ask for a font that is still downloading.
*
* @param {object} caption the `caption` slice of the state
* @param {number} [scale=1]
* @param {boolean} [loaded=false]
* @returns {string}
*/
export function fontSpec(caption, scale = 1, loaded = false) {
const state = caption ?? {};
const size = resolveFontSize(state, positiveScale(scale));
const weight = state.bold ? 'bold ' : '';
return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`;
}
/**
* Resolve a caption to pixel geometry at the output size.
*
* `x`/`y` are fractions of the image and land on the bubble's top-left corner;
* `maxWidth` is a fraction of the image width and caps the text column. The box
* is always the bubble itself, padding included and the tail excluded, and it
* always fits inside the image: padding gives way first, then the position is
* clamped (leaving room for the tail), and only a block larger than the image
* is clipped. Every returned number is finite, even for an empty caption or a
* zero-sized image.
*
* `font` is an opentype font from `textOutlines.js`. Without it the text is
* wrapped by a rough character count, so the preview still shows a bubble while
* the font is loading.
*
* @param {object} caption
* @param {object} [options]
* @param {number} [options.width=0]
* @param {number} [options.height=0]
* @param {number} [options.scale=1]
* @param {import('opentype.js').Font|null} [options.font=null]
* @returns {{
* box: {x: number, y: number, w: number, h: number},
* lines: Array<{text: string, width: number}>,
* fontSize: number, lineHeight: number, padding: number, scale: number,
* usedOutlines: boolean,
* }}
*/
export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) {
const state = caption ?? {};
const resolvedScale = positiveScale(scale);
const outWidth = toNonNegative(width, 0);
const outHeight = toNonNegative(height, 0);
const fontSize = resolveFontSize(state, resolvedScale);
const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT);
const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale;
const text = typeof state.text === 'string' ? state.text : '';
const vertical = state.vertical === true;
const block = vertical
? layoutVertical(text, {
fontSize,
lineHeight,
maxHeight: resolveColumn(state, outHeight, requested),
})
: layoutBlock(font, text, {
fontSize,
maxWidth: resolveColumn(state, outWidth, requested),
lineHeight,
align: alignOf(state),
});
// Padding gives way before the bubble does, so a caption that cannot fit with
// its usual breathing room still fits inside the image.
const padding = Math.max(
0,
Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2),
);
const w = Math.min(block.width + 2 * padding, outWidth);
const h = Math.min(block.height + 2 * padding, outHeight);
// The tail sticks out of the box, so the box has to keep that much clear of
// the edge it points at.
const tail = tailOf(state);
const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5));
const roomX = Math.max(0, outWidth - w);
const roomY = Math.max(0, outHeight - h);
// Which way the tail leaves the box, as a direction where each axis is -1,
// 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is
// driven by the direction rather than by which name the tail has.
const dir = tailDirection(tail);
let lowX = 0;
let highX = roomX;
if (dir.x < 0) lowX = Math.min(tailLength, roomX);
else if (dir.x > 0) highX = Math.max(0, roomX - tailLength);
let lowY = 0;
let highY = roomY;
if (dir.y < 0) lowY = Math.min(tailLength, roomY);
else if (dir.y > 0) highY = Math.max(0, roomY - tailLength);
const box = {
x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX),
y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY),
w,
h,
};
return {
box,
lines: block.lines,
fontSize,
lineHeight,
padding,
scale: resolvedScale,
vertical,
usedOutlines: Boolean(font),
};
}
/**
* Lay the bubble down in a 2D context, ready to be filled and stroked.
*
* The tail is part of the same path as the bubble, so the border runs along it
* and joins the outline cleanly instead of meeting it at a seam. The caller
* owns the colours; this only traces the shape.
*
* @param {CanvasRenderingContext2D} ctx
* @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded
* @param {object} caption
* @param {number} [scale=1]
* @returns {boolean} `true` when there was a shape to trace
*/
export function bubblePath(ctx, box, caption, scale = 1) {
const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale));
applyCommands(ctx, commands);
return commands.length > 0;
}
/**
* Draw a caption: bubble first, then the text line by line.
*
* The 2D canvas is overlaid exactly on the 3D view for the preview and is
* composited into the PNG on export, which is why the very same function runs
* in both places: what the user sees is what the file contains.
*
* The bubble box comes back even when the caption is switched off, so the app
* can show a placeholder and hit-test a drag without a second layout.
*
* @param {CanvasRenderingContext2D} ctx
* @param {object} caption
* @param {object} [options]
* @param {number} [options.width=0]
* @param {number} [options.height=0]
* @param {number} [options.scale=1]
* @param {import('opentype.js').Font|null} [options.font=null]
* @returns {{x: number, y: number, w: number, h: number}}
*/
export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) {
const state = caption ?? {};
const layout = layoutCaption(state, { width, height, scale, font });
const { box } = layout;
if (!state.enabled || !ctx) return box;
ctx.save();
const commands = bubbleCommands(box, state, layout.scale);
if (commands.length > 0) {
applyCommands(ctx, commands);
ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR);
ctx.fill();
const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
if (borderWidth > 0) {
ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR);
ctx.lineWidth = borderWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
// A whisper is the rounded bubble with a broken outline.
if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') {
ctx.setLineDash([borderWidth * 3, borderWidth * 2]);
}
ctx.stroke();
}
}
if (layout.lines.length > 0) drawText(ctx, layout, state, font);
ctx.restore();
return box;
}
/**
* The caption as an SVG fragment, plus whether the text is outlined.
*
* Text becomes glyph outlines whenever the font covers it, because a `<text>`
* element is redrawn with whatever font the viewer happens to have and a
* caption that reflows is worse than one that cannot be selected. When a glyph
* is missing the whole caption falls back to `<text>` and `usedOutlines` is
* `false`, which is the caller's signal to warn that the file now depends on
* the viewer's fonts.
*
* The bubble and its tail are one `<path>`, traced from the same command list
* the canvas uses. `enabled` is not consulted: the export path only runs for an
* enabled caption, and drawing whatever is handed over keeps this usable for a
* thumbnail.
*
* @param {object} caption
* @param {object} [options]
* @param {number} [options.width=0]
* @param {number} [options.height=0]
* @param {number} [options.scale=1]
* @param {import('opentype.js').Font|null} [options.font=null]
* @param {number} [options.round=2] decimal places in the output
* @returns {{svg: string, usedOutlines: boolean}}
*/
export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) {
const state = caption ?? {};
const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2)));
const layout = layoutCaption(state, { width, height, scale, font });
const { box, lines, fontSize, lineHeight, padding } = layout;
const outWidth = toNonNegative(width, 0);
const outHeight = toNonNegative(height, 0);
const text = typeof state.text === 'string' ? state.text : '';
let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text);
// Vertical text falls back to `<text>`: the outline path lays glyphs out
// horizontally, and a column of glyphs reads fine as live text.
if (layout.vertical) usedOutlines = false;
const parts = [
'<svg xmlns="http://www.w3.org/2000/svg"'
+ ` width="${formatNumber(outWidth, places)}"`
+ ` height="${formatNumber(outHeight, places)}"`
+ ` viewBox="0 0 ${formatNumber(outWidth, places)} ${formatNumber(outHeight, places)}">`,
];
const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places);
if (bubble) {
const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR));
const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
const stroke = borderWidth > 0
? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"`
+ ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"`
+ (bubbleOf(state) === 'whisper'
? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"`
: '')
: '';
parts.push(`<path d="${bubble}" fill="${fill}"${stroke}/>`);
}
if (lines.length > 0) {
const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
let outlined = '';
if (usedOutlines) {
// `textToPathData` re-applies the alignment against the widest line, so
// the block it needs is the one `layoutText` measured.
outlined = textToPathData(font, {
lines,
width: blockWidthOf(lines),
height: lines.length * fontSize * lineHeight,
lineHeight,
fontSize,
align: alignOf(state),
}, { x: box.x + padding, y: box.y + padding, round: places });
usedOutlines = outlined !== '';
}
if (usedOutlines) {
parts.push(`<path d="${outlined}" fill="${escapeAttribute(textColor)}"/>`);
} else {
parts.push(liveText(state, layout, font, textColor, places));
}
}
parts.push('</svg>');
return { svg: parts.join('\n') + '\n', usedOutlines };
}
// --- text --------------------------------------------------------------------
/** Draw every line at its own baseline, shifted sideways by the alignment. */
function drawText(ctx, layout, state, font) {
const { box, lines, fontSize, lineHeight, padding, scale } = layout;
const metrics = baselineMetrics(font, fontSize);
const step = fontSize * lineHeight;
const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
const blockWidth = blockWidthOf(lines);
const left = box.x + padding;
const top = box.y + padding;
const align = alignOf(state);
ctx.font = fontSpec(state, scale, Boolean(font));
ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
if (layout.vertical) {
// Columns run right to left; each glyph sits in its own cell. `ー` and its
// like are turned a quarter turn so they read as vertical strokes.
for (let i = 0; i < lines.length; i++) {
const chars = [...lines[i].text];
const x = left + (lines.length - 1 - i) * step;
for (let j = 0; j < chars.length; j++) {
const ch = chars[j];
const drop = verticalRotation(ch);
if (drop !== null) {
ctx.save();
ctx.translate(x + fontSize / 2, top + j * fontSize + fontSize / 2 + drop * fontSize);
ctx.rotate(Math.PI / 2);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(ch, 0, 0);
ctx.restore();
continue;
}
const offset = verticalOffsetOf(ch);
const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
ctx.fillText(ch, x + offset.dx * fontSize, y);
}
}
return;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const x = left + alignOffset(align, blockWidth, line.width);
const y = top + i * step + halfLeading + metrics.ascent;
ctx.fillText(line.text, x, y);
}
}
/**
* The `<text>` fallback.
*
* The family comes from the font stack alone, not from `fontSpec`: size and
* weight have attributes of their own, and a `font-family` that carried them
* would be ignored by every viewer.
*/
function liveText(state, layout, font, fill, places) {
const { box, lines, fontSize, lineHeight, padding } = layout;
const blockWidth = blockWidthOf(lines);
const align = alignOf(state);
const left = box.x + padding;
const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
const x = align === 'center'
? left + blockWidth / 2
: align === 'right'
? left + blockWidth
: left;
const metrics = baselineMetrics(font, fontSize);
const step = fontSize * lineHeight;
const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
const top = box.y + padding;
if (layout.vertical) {
// The stacked glyphs share one left-anchored <text>. The rotated ones (`ー`)
// get a <text> of their own, centred on the cell and turned a quarter turn.
const base = [
`font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
`font-size="${formatNumber(fontSize, places)}"`,
state.bold ? 'font-weight="bold"' : '',
`fill="${escapeAttribute(fill)}"`,
].filter(Boolean).join(' ');
const tspans = [];
const rotated = [];
for (let i = 0; i < lines.length; i++) {
const chars = [...lines[i].text];
const x = left + (lines.length - 1 - i) * step;
for (let j = 0; j < chars.length; j++) {
const ch = chars[j];
const drop = verticalRotation(ch);
if (drop !== null) {
const cx = x + fontSize / 2;
const cy = top + j * fontSize + fontSize / 2 + drop * fontSize;
rotated.push(`<text ${base} text-anchor="middle" dy="0.36em"`
+ ` transform="rotate(90 ${formatNumber(cx, places)} ${formatNumber(cy, places)})"`
+ ` x="${formatNumber(cx, places)}" y="${formatNumber(cy, places)}">${escapeText(ch)}</text>`);
continue;
}
const offset = verticalOffsetOf(ch);
const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
tspans.push(`<tspan x="${formatNumber(x + offset.dx * fontSize, places)}" y="${formatNumber(y, places)}">${escapeText(ch)}</tspan>`);
}
}
const main = tspans.length ? `<text ${base} text-anchor="start">${tspans.join('')}</text>` : '';
return `${main}${rotated.join('')}`;
}
const attributes = [
`font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
`font-size="${formatNumber(fontSize, places)}"`,
state.bold ? 'font-weight="bold"' : '',
`fill="${escapeAttribute(fill)}"`,
`text-anchor="${anchor}"`,
].filter(Boolean).join(' ');
const tspans = lines.map((line, i) => {
const y = top + i * step + halfLeading + metrics.ascent;
return `<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">`
+ `${escapeText(line.text)}</tspan>`;
}).join('');
return `<text ${attributes}>${tspans}</text>`;
}
/**
* Wrap text without a font, from a character count.
*
* The preview has to show something while the vendored font downloads, and this
* is a deliberately crude stand-in: wide (CJK) characters count as one em and
* everything else as half an em. Once `layoutText` can run its metrics replace
* these numbers, so the rough version only ever decides a preview, never a file.
*/
function wrapByCount(text, { fontSize, maxWidth, lineHeight }) {
const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
const lines = [];
for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
let current = '';
for (const ch of paragraph) {
const candidate = current + ch;
if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) {
lines.push(current.trimEnd());
current = /\s/.test(ch) ? '' : ch; // the break swallows the space
} else {
current = candidate;
}
}
lines.push(current.trimEnd());
}
const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) }));
let width = 0;
for (const line of measured) width = Math.max(width, line.width);
return { lines: measured, width, height: measured.length * fontSize * lineHeight };
}
/** Wrap with `layoutText`, or with the character count when there is no font. */
function layoutBlock(font, text, options) {
if (text === '') return { lines: [], width: 0, height: 0 };
if (font) {
const layout = layoutText(font, text, options);
return { lines: layout.lines, width: layout.width, height: layout.height };
}
return wrapByCount(text, options);
}
/**
* Offset of one character inside its vertical cell, in em.
*
* Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in
* the same place on screen and in the export.
*/
function verticalOffsetOf(ch) {
return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET;
}
/**
* Vertical (縦書き) layout: each paragraph becomes one column, characters stacked
* top to bottom, columns running right to left. A paragraph longer than the
* available height is split into more columns.
*
* Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top
* of a new column, it stays on the previous one and is drawn just below that
* column's last cell instead. Such a hanging character does not count toward
* the column's length, so it never makes the block a cell taller or adds a
* column of its own. `lines[i].hang` reports how many characters on that line
* are hanging.
*
* The other 禁則 cases are repaired too: ー, small kana and closing brackets are
* kept off the top of a column by handing the preceding character down with
* them (追い出し), and an opening bracket is never left at the bottom of one.
*/
function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
const limit = Number.isFinite(maxHeight) && maxHeight > 0
? Math.max(1, Math.floor(maxHeight / fontSize))
: Infinity;
const columns = [];
for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
const chars = [...paragraph];
if (chars.length === 0) {
columns.push({ text: '', count: 0 });
continue;
}
let i = 0;
while (i < chars.length) {
// Fill the column cell by cell, then repair its two ends for 禁則処理.
const cells = [];
const hanging = [];
while (i < chars.length && cells.length < limit) {
cells.push(chars[i]);
i += 1;
}
// 行末禁則: an opening bracket may not close a column, so hand it over to
// the next one (it will open that column instead).
while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) {
cells.pop();
i -= 1;
}
// 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a
// column; move the preceding character down to keep them company.
if (i < chars.length && cells.length > 1
&& COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) {
cells.pop();
i -= 1;
}
// 行頭禁則 (ぶら下げ): a closing mark that would open the next column
// stays on this one, drawn just below its last cell.
while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) {
hanging.push(chars[i]);
i += 1;
}
columns.push({ text: cells.join('') + hanging.join(''), count: cells.length });
}
}
const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
return {
lines: columns.map((column) => ({
text: column.text,
width: fontSize,
hang: [...column.text].length - column.count,
})),
// The block is exactly as wide as the columns that are drawn: `step` apart,
// each cell one em wide (not one whole `step`, which would leave a leading
// of dead space on the right and push the text off-centre).
width: columns.length === 0
? 0
: (columns.length - 1) * fontSize * lineHeight + fontSize,
height: longest * fontSize,
vertical: true,
};
}
/** Text column in pixels: a fraction of the image, never wider than the image. */
function resolveColumn(state, outWidth, padding) {
const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH);
if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText`
return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding));
}
/** Ascent above and descent below the baseline, in pixels. */
function baselineMetrics(font, fontSize) {
const unitsPerEm = font?.unitsPerEm;
if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) {
return {
ascent: (font.ascender * fontSize) / unitsPerEm,
descent: (-font.descender * fontSize) / unitsPerEm,
};
}
return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT };
}
/** Widest line of the block, which is the width the alignment works against. */
function blockWidthOf(lines) {
let width = 0;
for (const line of lines) width = Math.max(width, toFinite(line?.width, 0));
return width;
}
/** Sideways shift of one line inside the block. */
function alignOffset(align, blockWidth, lineWidth) {
const slack = Math.max(0, blockWidth - lineWidth);
if (align === 'center') return slack / 2;
if (align === 'right') return slack;
return 0;
}
/** Width of a string by character count, in pixels. */
function measureFallback(text, fontSize) {
let width = 0;
for (const ch of String(text ?? '')) {
width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM;
}
return width;
}
/** Characters that usually take a full em in a Japanese font. */
function isWideChar(ch) {
const code = ch.codePointAt(0);
for (const [start, end] of WIDE_RANGES) {
if (code >= start && code <= end) return true;
}
return false;
}
// --- geometry ----------------------------------------------------------------
/**
* The bubble outline as path commands.
*
* Both renderers go through here, which is the point: the canvas preview, the
* PNG and the SVG are three views of one geometry rather than three
* implementations of it.
*
* @returns {Array<object>} empty when there is nothing to draw
*/
function bubbleCommands(box, state, scale) {
const shape = bubbleOf(state);
const rect = {
x: toFinite(box?.x, 0),
y: toFinite(box?.y, 0),
w: toNonNegative(box?.w, 0),
h: toNonNegative(box?.h, 0),
};
if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return [];
if (shape === 'think') return thinkCommands(rect, state, scale);
const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale;
const edges = shape === 'shout'
? shoutEdges(rect, scale)
: shape === 'rect'
? rectEdges(rect)
: roundEdges(rect, radius);
return edgesToCommands(withTail(edges, rect, state, scale));
}
/** Clockwise rectangle, starting at the top-left corner. */
function rectEdges(box) {
const { x, y, w, h } = box;
const tl = { x, y };
const tr = { x: x + w, y };
const br = { x: x + w, y: y + h };
const bl = { x, y: y + h };
return [
lineEdge(tl, tr),
lineEdge(tr, br),
lineEdge(br, bl),
lineEdge(bl, tl),
];
}
/**
* Rounded rectangle as straight sides plus quarter-circle corners.
*
* Keeping the sides straight (rather than approximating the whole outline with
* a polyline) is what lets the tail attach to a real straight edge; the radius
* is clamped so the corners can never cross each other.
*/
function roundEdges(box, radius) {
const { x, y, w, h } = box;
const r = clamp(radius, 0, Math.min(w, h) / 2);
if (!(r > 0.5)) return rectEdges(box);
const k = r * KAPPA;
const p = (px, py) => ({ x: px, y: py });
const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to));
return [
lineEdge(p(x + r, y), p(x + w - r, y)),
corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]),
lineEdge(p(x + w, y + r), p(x + w, y + h - r)),
corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]),
lineEdge(p(x + w - r, y + h), p(x + r, y + h)),
corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]),
lineEdge(p(x, y + h - r), p(x, y + r)),
corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]),
];
}
/**
* Star-burst: a rectangle whose four sides zig-zag outwards.
*
* The tooth depth and pitch are fractions of the bubble, so a big "shout" and a
* small one have the same number of spikes instead of the big one looking like
* a saw.
*/
function shoutEdges(box, scale) {
const { x, y, w, h } = box;
const amp = Math.min(w, h) * 0.07;
const pitch = Math.max(scale, Math.min(w, h) * 0.22);
const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];
const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }];
const points = [];
for (let side = 0; side < 4; side++) {
const a = corners[side];
const b = corners[(side + 1) % 4];
const normal = normals[side];
const length = Math.hypot(b.x - a.x, b.y - a.y);
const teeth = Math.max(2, Math.round(length / pitch));
points.push(a);
for (let i = 0; i < teeth; i++) {
if (i > 0) points.push(lerp(a, b, i / teeth));
const out = lerp(a, b, (i + 0.5) / teeth);
points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp });
}
}
return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length]));
}
/**
* Cloud outline for a thought bubble, plus how far its scallops bulge out.
*
* The four sides of the box are replaced by a ring of outward scallops: a
* rounded rectangle is sampled evenly by arc length and each pair of samples is
* joined by a half-ellipse bulging away from the centre.
*/
function thinkEdges(box, scale) {
const { x, y, w, h } = box;
const cx = x + w / 2;
const cy = y + h / 2;
const ring = roundedRectRing(box, Math.min(w, h) * 0.28);
const perimeter = ringLength(ring);
// Fluffier: more scallops, each bulging further, so it reads as a soft cloud.
const pitch = Math.max(scale * 6, Math.min(w, h) * 0.30);
const count = Math.max(6, Math.round(perimeter / pitch));
const points = sampleRing(ring, count);
const amp = Math.min((perimeter / count) * 0.55, Math.min(w, h) * 0.26);
const edges = [];
let outer = amp;
for (let i = 0; i < points.length; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
// Bulge perpendicular to the edge, not away from the centre: on a wide box
// the centre direction points sideways along the top and bottom edges, which
// is what tilted those scallops. The sign is flipped so it always faces out,
// which makes every edge scallop the same way the left and right ones do.
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
let nx = -dy / length;
let ny = dx / length;
if (nx * (mid.x - cx) + ny * (mid.y - cy) < 0) {
nx = -nx;
ny = -ny;
}
// A small, *deterministic* wobble so the scallops are not all identical - the
// even size read as too regular. Same index, same bubble, every render.
const scallopAmp = amp * (0.72 + 0.56 * scallopJitter(i));
if (scallopAmp > outer) outer = scallopAmp;
edges.push(scallopEdge(a, b, { x: nx, y: ny }, scallopAmp));
}
return { edges, amp: outer };
}
/** A closed cloud, followed by the tail: two or three shrinking puffs. */
function thinkCommands(box, state, scale) {
const cloud = thinkEdges(box, scale);
const commands = edgesToCommands(cloud.edges);
for (const puff of thoughtTrail(box, state, scale, cloud.amp)) {
commands.push(...circleCommands(puff.x, puff.y, puff.r));
}
return commands;
}
/**
* The circles that trail away from a thought bubble instead of a pointed tail.
*
* They start just outside the cloud (`outer`) and shrink as they go, ending
* inside the room `layoutCaption` reserves for the tail.
*/
function thoughtTrail(box, state, scale, outer) {
const tail = tailOf(state);
if (tail === 'none') return [];
const dir = tailDirection(tail);
const diagonal = dir.x !== 0 && dir.y !== 0;
const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1);
const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1);
const anchor = {
x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
};
// Sized from the font, not the leftover room, so the puffs stay visible at any
// bubble size; they start just outside the cloud (`outer`) and shrink away.
const unit = resolveFontSize(state, scale);
// 80% of the first cut, which read a touch large.
const r1 = unit * 0.48;
const r2 = unit * 0.34;
const r3 = unit * 0.21;
const gap = unit * 0.5;
const d1 = outer + r1;
const d2 = d1 + r1 + gap;
const d3 = d2 + r2 + gap;
return [
{ x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 },
{ x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 },
{ x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 },
];
}
/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */
function scallopEdge(from, to, normal, amp) {
const k = (4 / 3) * amp;
return cubicEdge(
from,
{ x: from.x + normal.x * k, y: from.y + normal.y * k },
{ x: to.x + normal.x * k, y: to.y + normal.y * k },
to,
);
}
/**
* A small deterministic wobble in 0..1 for the i-th scallop of a cloud.
*
* It is derived from the index alone, so a bubble draws its scallops exactly
* the same in the live preview, a screenshot, and the SVG export.
*/
function scallopJitter(i) {
const x = Math.sin((i + 1) * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */
function circleCommands(cx, cy, r) {
const k = r * KAPPA;
return [
{ type: 'M', x: cx + r, y: cy },
{ type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
{ type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
{ type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
{ type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
{ type: 'Z' },
];
}
/** Points around a rounded rectangle, clockwise from the top-left arc. */
function roundedRectRing(box, radius) {
const { x, y, w, h } = box;
const r = clamp(radius, 0, Math.min(w, h) / 2);
const points = [];
const arc = (cx, cy, from, to) => {
const steps = 6;
for (let i = 1; i <= steps; i++) {
const t = from + (to - from) * (i / steps);
points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });
}
};
points.push({ x: x + r, y });
points.push({ x: x + w - r, y });
arc(x + w - r, y + r, -Math.PI / 2, 0);
points.push({ x: x + w, y: y + h - r });
arc(x + w - r, y + h - r, 0, Math.PI / 2);
points.push({ x: x + r, y: y + h });
arc(x + r, y + h - r, Math.PI / 2, Math.PI);
points.push({ x, y: y + r });
arc(x + r, y + r, Math.PI, Math.PI * 1.5);
return points;
}
/** Total length of a closed polyline's perimeter. */
function ringLength(points) {
let total = 0;
for (let i = 0; i < points.length; i++) {
total += distance(points[i], points[(i + 1) % points.length]);
}
return total;
}
/** `count` points spaced evenly by arc length around a closed polyline. */
function sampleRing(points, count) {
const n = points.length;
const total = ringLength(points);
if (!(total > 0) || !(count > 0)) return [];
const out = [];
let seg = 0;
let start = 0;
let length = n > 1 ? distance(points[0], points[1 % n]) : 0;
for (let i = 0; i < count; i++) {
const target = (total * i) / count;
while (seg < n - 1 && start + length < target) {
start += length;
seg += 1;
length = distance(points[seg], points[(seg + 1) % n]);
}
const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0;
out.push(lerp(points[seg], points[(seg + 1) % n], t));
}
return out;
}
/**
* Replace part of the edge the tail points at with a spike.
*
* The spike is inserted *into* the outline, not appended to it: the two sides
* of the tail and the bubble become one continuous border. The straight edge
* nearest the tail's anchor is used, which for every shape except the shout is
* the obvious side and for the shout is the nearest zig-zag segment.
*/
function withTail(edges, box, state, scale) {
const tail = tailOf(state);
if (tail === 'none' || edges.length === 0) return edges;
const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5);
if (!(tailLength > 0)) return edges;
const dir = tailDirection(tail);
// A diagonal tail leaves from a corner; an axis-aligned one leaves from the
// middle of the side it points at.
const anchor = {
x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
};
const diagonal = dir.x !== 0 && dir.y !== 0;
const normal = {
x: dir.x * (diagonal ? Math.SQRT1_2 : 1),
y: dir.y * (diagonal ? Math.SQRT1_2 : 1),
};
const index = nearestEdge(edges, anchor, box, tail);
if (index < 0) return edges;
const edge = edges[index];
const length = distance(edge.from, edge.to);
const base = Math.min(tailLength * 0.7, length * 0.45);
if (!(base > 0) || !(length > 0)) return edges;
// The spike sits at the point of the edge nearest the anchor - the middle for
// a side, the corner for a diagonal - kept far enough in that both feet land
// on the edge itself.
const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length);
const middle = lerp(edge.from, edge.to, t);
// A diagonal spike advances `tailLength` on *each* axis, so it reaches as far
// as a side tail does and reads as a proper corner.
const reach = tailLength * (diagonal ? Math.SQRT2 : 1);
const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach };
const leftFoot = lerp(edge.from, edge.to, t - base / length);
const rightFoot = lerp(edge.from, edge.to, t + base / length);
const replacement = [
lineEdge(edge.from, leftFoot),
lineEdge(leftFoot, tip),
lineEdge(tip, rightFoot),
lineEdge(rightFoot, edge.to),
];
return edges.slice(0, index).concat(replacement, edges.slice(index + 1));
}
/** Index of the straight edge closest to the tail's anchor, or `-1`. */
function nearestEdge(edges, anchor, box, tail) {
let best = -1;
let bestDistance = Infinity;
for (let i = 0; i < edges.length; i++) {
const edge = edges[i];
if (edge.kind !== 'line') continue;
const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 };
if (!onSide(middle, box, tail)) continue;
const d = distance(middle, anchor);
if (d < bestDistance) {
bestDistance = d;
best = i;
}
}
return best;
}
/** Is `point` on the side of the box the tail points at? */
function onSide(point, box, tail) {
const dir = tailDirection(tail);
const midX = box.x + box.w * 0.5;
const midY = box.y + box.h * 0.5;
if (dir.x < 0 && point.x > midX) return false;
if (dir.x > 0 && point.x < midX) return false;
if (dir.y < 0 && point.y > midY) return false;
if (dir.y > 0 && point.y < midY) return false;
return true;
}
/** How far along `edge` (0..1) the point nearest `point` falls. */
function projectionT(edge, point) {
const dx = edge.to.x - edge.from.x;
const dy = edge.to.y - edge.from.y;
const lengthSq = dx * dx + dy * dy;
if (!(lengthSq > 0)) return 0.5;
return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq;
}
/** One straight edge of a closed outline. */
function lineEdge(from, to) {
return { kind: 'line', from, to };
}
/** One cubic edge of a closed outline. */
function cubicEdge(from, c1, c2, to) {
return { kind: 'cubic', from, c1, c2, to };
}
/** Walk the edges into `M`/`L`/`C`/`Z` commands. */
function edgesToCommands(edges) {
if (edges.length === 0) return [];
const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }];
for (const edge of edges) {
if (edge.kind === 'line') {
commands.push({ type: 'L', x: edge.to.x, y: edge.to.y });
} else {
commands.push({
type: 'C',
x1: edge.c1.x, y1: edge.c1.y,
x2: edge.c2.x, y2: edge.c2.y,
x: edge.to.x, y: edge.to.y,
});
}
}
commands.push({ type: 'Z' });
return commands;
}
/** Replay commands into a 2D context. */
function applyCommands(ctx, commands) {
ctx.beginPath();
for (const command of commands) {
if (command.type === 'M') ctx.moveTo(command.x, command.y);
else if (command.type === 'L') ctx.lineTo(command.x, command.y);
else if (command.type === 'C') {
ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y);
} else if (command.type === 'Z') {
ctx.closePath();
}
}
}
/** Print commands as SVG path data. */
function commandsToPathData(commands, places) {
const parts = [];
for (const command of commands) {
if (command.type === 'M') {
parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
} else if (command.type === 'L') {
parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
} else if (command.type === 'C') {
parts.push(
`C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}`
+ ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}`
+ ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`,
);
} else if (command.type === 'Z') {
parts.push('Z');
}
}
return parts.join(' ');
}
// --- shared helpers ----------------------------------------------------------
/** The family list for a caption, or the system-only list before the font loads. */
function fontFamilyStack(caption, loaded) {
const stack = captionFontStack();
if (loaded && caption?.font !== 'system') return stack;
return dropLeadingFamily(stack);
}
/** `captionFontStack()` minus its first family, for a plain system fallback. */
function dropLeadingFamily(stack) {
let quote = null;
for (let i = 0; i < stack.length; i++) {
const ch = stack[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === "'" || ch === '"') {
quote = ch;
} else if (ch === ',') {
const rest = stack.slice(i + 1).trim();
return rest === '' ? 'sans-serif' : rest;
}
}
return 'sans-serif';
}
/** Font size in output pixels, always positive and finite. */
function resolveFontSize(caption, scale) {
const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE);
return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE);
}
function bubbleOf(caption) {
const value = caption?.bubble;
if (value === 'round' || value === 'rect' || value === 'shout'
|| value === 'whisper' || value === 'think' || value === 'none') return value;
return 'round';
}
function tailOf(caption) {
const value = caption?.tail;
return TAIL_VALUES.has(value) ? value : 'left';
}
const TAIL_VALUES = new Set([
'left', 'right', 'top', 'bottom',
'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
'none',
]);
/**
* Which way a tail leaves the box, as a direction where each axis is -1, 0 or
* 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both
* axes, so `bottomLeft` is `{ x: -1, y: 1 }`.
*/
function tailDirection(tail) {
switch (tail) {
case 'left': return { x: -1, y: 0 };
case 'right': return { x: 1, y: 0 };
case 'top': return { x: 0, y: -1 };
case 'bottom': return { x: 0, y: 1 };
case 'topLeft': return { x: -1, y: -1 };
case 'topRight': return { x: 1, y: -1 };
case 'bottomLeft': return { x: -1, y: 1 };
case 'bottomRight': return { x: 1, y: 1 };
default: return { x: 0, y: 0 };
}
}
function alignOf(caption) {
const value = caption?.align;
return value === 'center' || value === 'right' ? value : 'left';
}
function colorOf(value, fallback) {
return typeof value === 'string' && value !== '' ? value : fallback;
}
/** Scale is a multiplier: anything that is not a positive number means 1. */
function positiveScale(value) {
return Number.isFinite(value) && value > 0 ? value : 1;
}
function toFinite(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
function toPositive(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function toNonNegative(value, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function clamp(value, low, high) {
if (high < low) return low;
return Math.min(Math.max(value, low), high);
}
/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */
function formatNumber(value, places = 2) {
if (!Number.isFinite(value)) return '0';
const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2;
const factor = Math.pow(10, decimals);
const rounded = Math.round(value * factor) / factor;
return Object.is(rounded, -0) ? '0' : String(rounded);
}
/**
* Escape character data.
*
* Quotes are escaped too even though element content allows them: the caption
* is user text, and the extra entities cost nothing next to never emitting a
* document a stricter parser could object to.
*/
function escapeText(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Escape an attribute value.
*
* Only the double quote matters inside a double-quoted attribute, so single
* quotes survive and a family stack such as `'Hiragino Maru Gothic ProN',
* sans-serif` stays readable in the file.
*/
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function distance(a, b) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
function lerp(a, b, t) {
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
|