@textui/widgets 0.7.0 → 0.8.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 (74) hide show
  1. package/dist/control/text-area.d.ts +2 -0
  2. package/dist/control/text-area.d.ts.map +1 -1
  3. package/dist/control/text-area.js +16 -8
  4. package/dist/data/code-viewer.d.ts +2 -0
  5. package/dist/data/code-viewer.d.ts.map +1 -1
  6. package/dist/data/code-viewer.js +9 -1
  7. package/dist/data/index.d.ts +9 -0
  8. package/dist/data/index.d.ts.map +1 -1
  9. package/dist/data/index.js +8 -2
  10. package/dist/data/list.d.ts +9 -0
  11. package/dist/data/list.d.ts.map +1 -1
  12. package/dist/data/list.js +20 -3
  13. package/dist/data/markdown-view.d.ts.map +1 -1
  14. package/dist/data/markdown-view.js +4 -1
  15. package/dist/data/pagination.d.ts.map +1 -1
  16. package/dist/data/pagination.js +4 -1
  17. package/dist/data/tree.d.ts +2 -0
  18. package/dist/data/tree.d.ts.map +1 -1
  19. package/dist/data/tree.js +8 -2
  20. package/dist/display/card.d.ts.map +1 -1
  21. package/dist/display/card.js +12 -0
  22. package/dist/display/color-text.d.ts +13 -1
  23. package/dist/display/color-text.d.ts.map +1 -1
  24. package/dist/display/color-text.js +28 -6
  25. package/dist/display/font-text.d.ts +72 -0
  26. package/dist/display/font-text.d.ts.map +1 -0
  27. package/dist/display/font-text.js +35 -0
  28. package/dist/display/fonts.d.ts +138 -0
  29. package/dist/display/fonts.d.ts.map +1 -0
  30. package/dist/display/fonts.js +893 -0
  31. package/dist/display/gard.d.ts +26 -0
  32. package/dist/display/gard.d.ts.map +1 -0
  33. package/dist/display/gard.js +479 -0
  34. package/dist/display/index.d.ts +3 -0
  35. package/dist/display/index.d.ts.map +1 -1
  36. package/dist/display/index.js +7 -0
  37. package/dist/display/key-value.d.ts +25 -5
  38. package/dist/display/key-value.d.ts.map +1 -1
  39. package/dist/display/key-value.js +13 -3
  40. package/dist/display/pagga.d.ts +29 -0
  41. package/dist/display/pagga.d.ts.map +1 -0
  42. package/dist/display/pagga.js +101 -0
  43. package/dist/display/pattern.d.ts +117 -0
  44. package/dist/display/pattern.d.ts.map +1 -0
  45. package/dist/display/pattern.js +257 -0
  46. package/dist/display/spinner.d.ts +2 -0
  47. package/dist/display/spinner.d.ts.map +1 -1
  48. package/dist/display/spinner.js +3 -2
  49. package/dist/navigation/menu.d.ts +2 -0
  50. package/dist/navigation/menu.d.ts.map +1 -1
  51. package/dist/navigation/menu.js +19 -6
  52. package/dist/navigation/tabs.d.ts +2 -0
  53. package/dist/navigation/tabs.d.ts.map +1 -1
  54. package/dist/navigation/tabs.js +12 -2
  55. package/package.json +2 -2
  56. package/src/control/text-area.ts +18 -7
  57. package/src/data/code-viewer.ts +10 -1
  58. package/src/data/index.ts +9 -2
  59. package/src/data/list.ts +21 -3
  60. package/src/data/markdown-view.ts +7 -4
  61. package/src/data/pagination.ts +4 -1
  62. package/src/data/tree.ts +9 -2
  63. package/src/display/card.ts +12 -0
  64. package/src/display/color-text.ts +33 -10
  65. package/src/display/font-text.tsx +94 -0
  66. package/src/display/fonts.ts +990 -0
  67. package/src/display/gard.ts +475 -0
  68. package/src/display/index.ts +7 -0
  69. package/src/display/key-value.ts +44 -6
  70. package/src/display/pagga.ts +102 -0
  71. package/src/display/pattern.tsx +427 -0
  72. package/src/display/spinner.ts +5 -2
  73. package/src/navigation/menu.ts +20 -6
  74. package/src/navigation/tabs.ts +13 -2
@@ -0,0 +1,990 @@
1
+ /**
2
+ * Block fonts, and the engine that draws text in them.
3
+ *
4
+ * A font is data: a table of glyphs, a tracking and a space width. `banner()`
5
+ * lays a string out in one and answers with plain text, which is the shape
6
+ * half only - `ColorText` colours it cell by cell, and the two are deliberately
7
+ * separate, because a banner wants a ramp across it and prose does not.
8
+ * [`FontText`](font-text.tsx) is the one-liner over this: text and a font in,
9
+ * letters out.
10
+ *
11
+ * These ship so that component has something to draw with; the tables are data
12
+ * an application can replace, and `Font` is the whole contract.
13
+ *
14
+ * There is one hand-drawn table and three transforms of it, which is the other
15
+ * thing worth showing: a bitmap font is a grid of characters, and a grid of
16
+ * characters can be sheared, doubled or duplicated by ten lines of code. Every
17
+ * font below has both cases, because the table does.
18
+ *
19
+ * A glyph is a grid of characters. Four of them are placeholders the renderer
20
+ * fills in from the theme - `#` a full cell, `%` a lighter one, `^` the top
21
+ * half of a cell and `v` the bottom half - and every other character is drawn
22
+ * as itself, which is what lets `dots`, `stars` and `mini` be made of dots,
23
+ * stars and pipes rather than of blocks. A space is nothing. Blank columns at
24
+ * either edge are trimmed when a glyph is used, so the letters are
25
+ * proportional rather than a fixed pitch.
26
+ */
27
+
28
+ import { graphemeWidth, stringWidth } from '@textui/core';
29
+ import { GARD } from './gard.js';
30
+ import { PAGGA } from './pagga.js';
31
+
32
+ type Grid = string[];
33
+
34
+ /**
35
+ * Capitals, five rows on a five-column body.
36
+ *
37
+ * Every stroke is one cell thick, which is what makes these legible at this
38
+ * size and what makes the `shadow` transform read as a shadow rather than as
39
+ * a thicker letter.
40
+ */
41
+ const CAPS: Record<string, string> = {
42
+ A: ' ### |# #|#####|# #|# #',
43
+ B: '#### |# #|#### |# #|#### ',
44
+ C: ' ####|# |# |# | ####',
45
+ D: '#### |# #|# #|# #|#### ',
46
+ E: '#####|# |#### |# |#####',
47
+ F: '#####|# |#### |# |# ',
48
+ G: ' ####|# |# ##|# #| ####',
49
+ H: '# #|# #|#####|# #|# #',
50
+ I: '#####| # | # | # |#####',
51
+ J: '#####| # | # |# # | ## ',
52
+ K: '# #|# # |### |# # |# #',
53
+ L: '# |# |# |# |#####',
54
+ M: '# #|## ##|# # #|# #|# #',
55
+ N: '# #|## #|# # #|# ##|# #',
56
+ O: ' ### |# #|# #|# #| ### ',
57
+ P: '#### |# #|#### |# |# ',
58
+ Q: ' ### |# #|# #|# # | ## #',
59
+ R: '#### |# #|#### |# # |# #',
60
+ S: ' ####|# | ### | #|#### ',
61
+ T: '#####| # | # | # | # ',
62
+ U: '# #|# #|# #|# #| ### ',
63
+ V: '# #|# #|# #| # # | # ',
64
+ W: '# #|# #|# # #|## ##|# #',
65
+ X: '# #| # # | # | # # |# #',
66
+ Y: '# #| # # | # | # | # ',
67
+ Z: '#####| # | # | # |#####',
68
+ };
69
+
70
+ /**
71
+ * Lowercase, on the same five rows.
72
+ *
73
+ * **One x-height, for all of them.** The body of every lowercase letter sits
74
+ * on rows two to five and the ascenders reach up into row one, so the two
75
+ * cases share a baseline and a cap is visibly taller than an x - which is the
76
+ * whole reason for a second set rather than folding the case away. Letting an
77
+ * `e` be four rows while the `o` beside it was three is what made `Hello` read
78
+ * as a ransom note, and it is the kind of thing only a rendered alphabet shows.
79
+ *
80
+ * Nothing descends below the baseline: five rows is not enough to put a tail
81
+ * under a `g` and keep the line spacing honest, so `g` and `y` hook left
82
+ * instead - which is also what keeps `g` from being a `q`.
83
+ */
84
+ const LOWER: Record<string, string> = {
85
+ a: ' | ###|# #|# #| ###',
86
+ b: '# |### |# #|# #|### ',
87
+ c: ' | ###|# |# | ###',
88
+ d: ' #| ###|# #|# #| ###',
89
+ e: ' | ## |# #|### | ###',
90
+ f: ' ##| # |### | # | # ',
91
+ g: ' | ###|# #| ###|## ',
92
+ h: '# |### |# #|# #|# #',
93
+ i: ' # | | # | # | # ',
94
+ j: ' # | | # | # |## ',
95
+ k: '# |# #|## |# # |# #',
96
+ l: '## | # | # | # |### ',
97
+ m: ' |#####|# # #|# # #|# # #',
98
+ n: ' |### |# #|# #|# #',
99
+ o: ' | ## |# #|# #| ## ',
100
+ p: ' |### |# #|### |# ',
101
+ q: ' | ###|# #| ###| #',
102
+ r: ' |# ##|## |# |# ',
103
+ s: ' | ###|## | ##|### ',
104
+ t: ' # |### | # | # | # ',
105
+ u: ' |# #|# #|# #| ###',
106
+ v: ' |# #|# #| ## | # ',
107
+ w: ' |# #|# # #|# # #| # # ',
108
+ x: ' |# #| ## | ## |# #',
109
+ y: ' |# #|# #| ###|## ',
110
+ z: ' |####| # | # |####',
111
+ };
112
+
113
+ const REST: Record<string, string> = {
114
+ 0: ' ### |# ##|# # #|## #| ### ',
115
+ 1: ' # | ## | # | # |#####',
116
+ 2: ' ### |# #| # | # |#####',
117
+ 3: '#### | #| ### | #|#### ',
118
+ 4: '# # |# # |#####| # | # ',
119
+ 5: '#####|# |#### | #|#### ',
120
+ 6: ' ### |# |#### |# #| ### ',
121
+ 7: '#####| # | # | # | # ',
122
+ 8: ' ### |# #| ### |# #| ### ',
123
+ 9: ' ### |# #| ####| #| ### ',
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
+ /** The one hand-drawn table: every glyph, in both cases. */
159
+ const BLOCK: Record<string, Grid> = Object.fromEntries(
160
+ Object.entries({ ...CAPS, ...LOWER, ...REST }).map(([key, spec]) => [key, pad(spec.split('|'))]),
161
+ );
162
+
163
+ /**
164
+ * A second hand-drawn table: three rows, and drawn in strokes rather than in
165
+ * cells.
166
+ *
167
+ * Three rows of *solid* cells cannot hold an alphabet - at that size an `A` and
168
+ * an `M` are the same three-by-three block, and so are `G` and `O`. The way out
169
+ * is the one every small figlet font takes: stop filling cells and start
170
+ * drawing strokes, so a `|` and a `/` and a `\` each carry a direction the cell
171
+ * on its own could not. That is why this table is characters rather than `#`,
172
+ * and why it needs no placeholder - it is already what it looks like.
173
+ *
174
+ * One case. At three rows there is no room under a cap for an x-height, so
175
+ * lowercase folds to these; `banner` does the folding.
176
+ */
177
+ const MINI: Record<string, Grid> = {
178
+ A: [' _ ', '|_|', '| |'],
179
+ B: [' _ ', '|_)', '|_)'],
180
+ C: [' _ ', '/ ', '\\_ '],
181
+ D: [' _ ', '| \\', '|_/'],
182
+ E: [' _ ', '|_ ', '|_ '],
183
+ F: [' _ ', '|_ ', '| '],
184
+ G: [' _ ', '/ _', '\\_|'],
185
+ H: [' ', '|_|', '| |'],
186
+ I: [' _ ', ' | ', ' | '],
187
+ J: [' _ ', ' |', '._|'],
188
+ K: [' ', '|/ ', '|\\ '],
189
+ L: [' ', '| ', '|_ '],
190
+ M: [' ', '|\\/|', '| |'],
191
+ N: [' ', '|\\ |', '| \\|'],
192
+ O: [' _ ', '/ \\', '\\_/'],
193
+ P: [' _ ', '|_)', '| '],
194
+ Q: [' _ ', '/ \\', '\\_\\'],
195
+ R: [' _ ', '|_)', '| \\'],
196
+ S: [' _ ', '(_ ', '._)'],
197
+ T: ['___', ' | ', ' | '],
198
+ U: [' ', '| |', '╰─╯'],
199
+ V: [' ', '\\ /', ' \\/ '],
200
+ W: [' ', '| |', '|\\/|'],
201
+ X: [' ', '\\ /', '/ \\'],
202
+ Y: [' ', '\\ /', ' | '],
203
+ Z: ['___', ' / ', '/_ '],
204
+ // Struck through, or it is the letter `O` again.
205
+ 0: [' _ ', '/|\\', '\\_/'],
206
+ 1: [' ', ' /|', ' |'],
207
+ 2: [' _ ', ' _)', '(_ '],
208
+ 3: [' _ ', ' _)', ' _)'],
209
+ 4: [' ', '|_|', ' |'],
210
+ 5: [' _ ', '|_ ', ' _)'],
211
+ 6: [' _ ', '/_ ', '(_)'],
212
+ 7: ['___', ' /', ' / '],
213
+ 8: [' _ ', '(_)', '(_)'],
214
+ 9: [' _ ', '(_)', ' _/'],
215
+ '!': [' ', '|', '.'],
216
+ '?': [' _ ', ' _)', ' . '],
217
+ '.': [' ', ' ', '. '],
218
+ ',': [' ', ' ', ', '],
219
+ ':': [' ', '. ', '. '],
220
+ ';': [' ', '. ', ', '],
221
+ '-': [' ', '___', ' '],
222
+ '+': [' ', ' + ', ' '],
223
+ '=': [' _ ', '___', ' '],
224
+ '/': [' ', ' /', ' / '],
225
+ "'": ['| ', ' ', ' '],
226
+ '(': [' /', '| ', ' \\'],
227
+ ')': ['\\ ', ' |', '/ '],
228
+ '_': [' ', ' ', '___'],
229
+
230
+ // The rest of printable ascii, in the same strokes. `$ % & @` are not here:
231
+ // at three rows there is no stroke that says "dollar" rather than "S with a
232
+ // line", and a stand-in that says `$` is more use than one that lies.
233
+ '"': ['||', ' ', ' '],
234
+ '#': ['_|_|', '_|_|', ' | |'],
235
+ '*': [' ', '\\|/', '/|\\'],
236
+ '<': [' /', '< ', ' \\'],
237
+ '>': ['\\ ', ' >', '/ '],
238
+ '[': ['|-', '| ', '|_'],
239
+ ']': ['-|', ' |', '_|'],
240
+ '{': [' /', '{ ', ' \\'],
241
+ '}': ['\\ ', ' }', '/ '],
242
+ '\\': ['\\ ', ' \\ ', ' \\'],
243
+ '^': ['/\\', ' ', ' '],
244
+ '`': ['\\ ', ' ', ' '],
245
+ '|': ['|', '|', '|'],
246
+ '~': [' ', '/\\/', ' '],
247
+ };
248
+
249
+
250
+ /**
251
+ * A third table: three rows of heavy box-drawing.
252
+ *
253
+ * The capitals are transcribed from a font Softov brought, character for
254
+ * character - which is why `X` is four cells wide and `I` is one, and why they
255
+ * are not going to be tidied into a grid. The digits and the punctuation are
256
+ * drawn to match rather than transcribed, because the source had none.
257
+ *
258
+ * One case, and it is the only font here that cannot be drawn at all on an
259
+ * ascii terminal - box-drawing has no `#` to fall back to the way a block does,
260
+ * so this one names `mini` as what to use instead. Same three rows, same
261
+ * stroke-drawn idea, characters a teletype could manage.
262
+ */
263
+ const TMPLT: Record<string, Grid> = {
264
+ A: ["┏┓", "┣┫", "┛┗"],
265
+ B: ["┳┓", "┣┫", "┻┛"],
266
+ C: ["┏┓", "┃ ", "┗┛"],
267
+ D: ["┳┓", "┃┃", "┻┛"],
268
+ E: ["┏┓", "┣ ", "┗┛"],
269
+ F: ["┏┓", "┣ ", "┻ "],
270
+ G: ["┏┓", "┃┓", "┗┛"],
271
+ H: ["┓┏", "┣┫", "┛┗"],
272
+ I: ["┳", "┃", "┻"],
273
+ J: ["┏┳", " ┃", "┗┛"],
274
+ K: ["┓┏┓", "┃┫ ", "┛┗┛"],
275
+ L: ["┓ ", "┃ ", "┗┛"],
276
+ M: ["┳┳┓", "┃┃┃", "┛ ┗"],
277
+ N: ["┳┓", "┃┃", "┛┗"],
278
+ O: ["┏┓", "┃┃", "┗┛"],
279
+ P: ["┏┓", "┃┃", "┣┛"],
280
+ Q: ["┏┓", "┃┃", "┗┻"],
281
+ R: ["┳┓", "┣┫", "┛┗"],
282
+ S: ["┏┓", "┗┓", "┗┛"],
283
+ T: ["┏┳┓", " ┃ ", " ┻ "],
284
+ U: ["┳┳", "┃┃", "┗┛"],
285
+ V: ["┓┏", "┃┃", "┗┛"],
286
+ W: ["┓ ┏", "┃┃┃", "┗┻┛"],
287
+ X: ["┏┓┏┓", " ┃┃ ", "┗┛┗┛"],
288
+ Y: ["┓┏", "┗┫", "┗┛"],
289
+ Z: ["┏┓", "┏┛", "┗┛"],
290
+ // Drawn to match, not transcribed. `0` keeps `O`'s shape, which is what a
291
+ // font this small usually does with them.
292
+ // A slash through it, or a zero and a letter `O` are the same glyph.
293
+ 0: ["┏┓", "┃╋", "┗┛"],
294
+ 1: [" ┓", " ┃", " ┻"],
295
+ 2: ["┏┓", "┏┛", "┗┻"],
296
+ 3: ["┏┓", " ┫", "┗┛"],
297
+ 4: ["┓┏", "┗╋", " ┃"],
298
+ 5: ["┏┳", "┗┓", "┗┛"],
299
+ 6: ["┏┓", "┣┓", "┗┛"],
300
+ 7: ["┳┳", " ┃", " ┛"],
301
+ 8: ["┏┓", "┣┫", "┗┛"],
302
+ 9: ["┏┓", "┗┫", "┗┛"],
303
+ 'a': ["", "┏┓", "┗┻"],
304
+ 'b': ["┓ ", "┣┓", "┗┛"],
305
+ 'c': ["", "┏", "┗"],
306
+ 'd': [" ┓", "┏┫", "┗┻"],
307
+ 'e': ["", "┏┓", "┗━"],
308
+ 'f': [" ┏", " ╋", " ┛"],
309
+ 'g': ["", "┏┓", "┗┫", " ┛"],
310
+ 'h': ["┓ ", "┣┓ ", "┛┗ ",],
311
+ 'i': ["•", "┓ ", "┗ ",],
312
+ 'j': ["• ", "┓ ", "┃ ", "┛ ",],
313
+ 'k': ["┓ ", "┃┏", "┛┗",],
314
+ 'l': ["┓ ", "┃ ", "┗ ",],
315
+ 'm': ["", "┏┳┓", "┛┗┗",],
316
+ 'n': ["", "┏┓ ", "┛┗ ",],
317
+ 'o': ["", "┏┓", "┗┛",],
318
+ 'p': ["", "┏┓", "┣┛", "┛ ",],
319
+ 'q': ["", "┏┓", "┗┫", " ┗",],
320
+ 'r': ["", "┏┓", "┛ ",],
321
+ 's': ["", "┏ ", "┛ ",],
322
+ 't': ["┃ ", "╋ ", "┗━"],
323
+ 'u': ["", "┓┏", "┗┻",],
324
+ 'v': ["", "┓┏", "┗┛",],
325
+ 'w': ["", "┓┃┏", "┗┻┛"],
326
+ 'x': ["", "┓┏", "┛┗",],
327
+ 'y': [" ", "┓┏", "┗┫", " ┛"],
328
+ 'z': ["", "━┓", "┗━"],
329
+ '.': [" ", " ", "•"],
330
+ ',': [" ", " ", "┛"],
331
+ ':': [" ", "•", "•"],
332
+ ';': [" ", "•", "┛"],
333
+ '!': ["┃", "┃", "•"],
334
+ '?': ["┏┓", " ┛", " •"],
335
+ '@': ["┏━┓", "┃┗┛", "┗━┛"],
336
+ '$': ["┏┻┓", "┗━┓", "┗┳┛"],
337
+ '&': ["•┏", " ┃", " ┛•"],
338
+ '%': ["┏┓", "┣╋", "┗┻"],
339
+ '¨': ["••"],
340
+ '*': [" ", "•"],
341
+ '-': [" ", "━━", " "],
342
+ '+': [" ", "╋ ", " "],
343
+ '=': [" ", "━━", "━━"],
344
+ '/': [" ┏", "┏┛", "┛ "],
345
+ "'": ["┃", " ", " "],
346
+ '(': ["┏", "┃", "┗"],
347
+ ')': ["┓", "┃", "┛"],
348
+ '_': [" ", " ", "━━"],
349
+
350
+ // Also drawn to match. `$ % & @ *` are left out: box-drawing has no stroke
351
+ // for any of them, and a `╋` standing in for a `*` is a plus sign lying.
352
+ '"': ["┃┃", " ", " "],
353
+ '#': ["", "╋╋", "╋╋", " "],
354
+ '<': [" ┏", "┫ ", " ┗"],
355
+ '>': ["┓ ", " ┣", "┛ "],
356
+ // The square pair takes a bar off each corner, so it is not the round pair
357
+ // drawn a second time - which is what it was until a test said so.
358
+ '[': ["┏━", "┃ ", "┗━"],
359
+ ']': ["━┓", " ┃", "━┛"],
360
+ '{': ["┏", "┫", "┗"],
361
+ '}': ["┓", "┣", "┛"],
362
+ '\\': ["┓ ", "┗┓", " ┛"],
363
+ '^': ["┏┓", " ", " "],
364
+ '`': ["┓ ", " ", " "],
365
+ '|': ["┃", "┃", "┃"],
366
+ '~': [" ", "┏┛", " "],
367
+ };
368
+
369
+ // ------------------------------------------------------------- transforms
370
+
371
+ /** Every row the same length, so a transform can index a rectangle. */
372
+ function pad(rows: Grid): Grid {
373
+ const width = rows.reduce((w, row) => Math.max(w, row.length), 0);
374
+ return rows.map((row) => row.padEnd(width, ' '));
375
+ }
376
+
377
+ function mapGlyphs(font: Record<string, Grid>, fn: (rows: Grid) => Grid): Record<string, Grid> {
378
+ return Object.fromEntries(Object.entries(font).map(([key, rows]) => [key, pad(fn(rows))]));
379
+ }
380
+
381
+ /** Every column twice. Twice the letter, the same shape. */
382
+ function wide(rows: Grid): Grid {
383
+ return rows.map((row) => Array.from(row).map((c) => c + c).join(''));
384
+ }
385
+
386
+ /**
387
+ * Sheared right, more the higher up the row is - an italic.
388
+ *
389
+ * Half a cell per row rather than one, because a five-row letter leaning five
390
+ * columns is a letter lying down. The shear is what a bitmap font has instead
391
+ * of a second table: the letters are the same letters.
392
+ */
393
+ function slant(rows: Grid): Grid {
394
+ const height = rows.length;
395
+ return rows.map((row, y) => ' '.repeat(Math.floor((height - 1 - y) / 2)) + row);
396
+ }
397
+
398
+ /**
399
+ * The letter, and a second copy of it down and to the right in `%`.
400
+ *
401
+ * Two rules, and the second is the one that took a try to get right. The
402
+ * letter is drawn over the shadow, so where they overlap the letter wins. And
403
+ * the shadow is kept only *outside* the letter - the counter of an `A` is
404
+ * enclosed, a real shadow could not fall into it, and one that did turned a
405
+ * five-row capital into a smudge. `outside` is a flood fill from the border,
406
+ * which is the cheapest way to ask whether a hole is a hole.
407
+ */
408
+ function shadow(rows: Grid): Grid {
409
+ const height = rows.length + 1;
410
+ const width = (rows[0] as string).length + 1;
411
+ const lit = (y: number, x: number): boolean =>
412
+ y >= 0 && y < rows.length && x >= 0 && x < (rows[y] as string).length
413
+ && (rows[y] as string)[x] !== ' ';
414
+
415
+ const open = outside(height, width, (y, x) => lit(y, x));
416
+ const out: string[][] = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
417
+ for (let y = 0; y < height; y++) {
418
+ for (let x = 0; x < width; x++) {
419
+ if (lit(y - 1, x - 1) && open[y * width + x]) (out[y] as string[])[x] = '%';
420
+ }
421
+ }
422
+ for (let y = 0; y < rows.length; y++) {
423
+ for (let x = 0; x < (rows[y] as string).length; x++) {
424
+ if (lit(y, x)) (out[y] as string[])[x] = '#';
425
+ }
426
+ }
427
+ return out.map((row) => row.join(''));
428
+ }
429
+
430
+ /** Which blank cells a flood fill from the border can reach. */
431
+ function outside(height: number, width: number, lit: (y: number, x: number) => boolean): boolean[] {
432
+ const open = new Array<boolean>(height * width).fill(false);
433
+ const queue: [number, number][] = [];
434
+ const push = (y: number, x: number): void => {
435
+ if (y < 0 || y >= height || x < 0 || x >= width) return;
436
+ if (open[y * width + x] || lit(y, x)) return;
437
+ open[y * width + x] = true;
438
+ queue.push([y, x]);
439
+ };
440
+ for (let x = 0; x < width; x++) { push(0, x); push(height - 1, x); }
441
+ for (let y = 0; y < height; y++) { push(y, 0); push(y, width - 1); }
442
+ while (queue.length > 0) {
443
+ const [y, x] = queue.pop() as [number, number];
444
+ push(y - 1, x); push(y + 1, x); push(y, x - 1); push(y, x + 1);
445
+ }
446
+ return open;
447
+ }
448
+
449
+ /**
450
+ * Two rows of the table to one row of output, as half cells.
451
+ *
452
+ * The only transform that changes the *height*: five rows become three, and a
453
+ * banner that would not fit a short terminal does. A cell is full where both
454
+ * source rows are lit, a top or a bottom half where one is, and nothing where
455
+ * neither - which is four characters standing in for four combinations, and
456
+ * why it is a substitution rather than a redrawing.
457
+ */
458
+ function half(rows: Grid): Grid {
459
+ const width = (rows[0] as string).length;
460
+ const out: string[] = [];
461
+ for (let y = 0; y < rows.length; y += 2) {
462
+ let line = '';
463
+ for (let x = 0; x < width; x++) {
464
+ const top = (rows[y] as string)[x] !== ' ';
465
+ const bottom = y + 1 < rows.length && (rows[y + 1] as string)[x] !== ' ';
466
+ line += top && bottom ? '#' : top ? '^' : bottom ? 'v' : ' ';
467
+ }
468
+ out.push(line);
469
+ }
470
+ return out;
471
+ }
472
+
473
+ /**
474
+ * Drawn in dots and colons rather than in blocks.
475
+ *
476
+ * A cell takes the character of the stroke that owns it, and the two strokes
477
+ * that can both claim a cell are settled by one question: **does anything come
478
+ * down into it?**
479
+ *
480
+ * * a neighbour across, and nothing above - the bar owns it, a dot;
481
+ * * anything else with a neighbour above or below - a stem owns it, a colon;
482
+ * * neither - a dot, which is what a diagonal and a full stop are made of.
483
+ *
484
+ * Both halves of that were learnt the hard way and neither is arbitrary. Ask
485
+ * about the vertical first and the top bar of a `T` comes out `..:..`, because
486
+ * the stem hangs off its middle - but a stem hanging *below* a bar does not
487
+ * take a cell out of the bar. Ask only about the horizontal and the bottom bar
488
+ * of an `I` comes out `.....`, because the stem is above it - and a stem
489
+ * landing *on* a bar does show where it lands. A `T` and an `I` are the same
490
+ * two strokes; which one wins depends on which way the stem runs.
491
+ *
492
+ * The letters are the same letters. This is a change of pen, not of hand, which
493
+ * is why it is twenty lines.
494
+ */
495
+ function dots(rows: Grid): Grid {
496
+ // Both bounds, on both axes. Indexing a string past its end gives
497
+ // `undefined`, and `undefined !== ' '` reads as lit - so the last column of
498
+ // every row believed it had a neighbour to the right.
499
+ const lit = (y: number, x: number): boolean =>
500
+ y >= 0 && y < rows.length
501
+ && x >= 0 && x < (rows[y] as string).length
502
+ && (rows[y] as string)[x] !== ' ';
503
+
504
+ return rows.map((row, y) => Array.from(row)
505
+ .map((cell, x) => {
506
+ if (cell === ' ') return ' ';
507
+ const across = lit(y, x - 1) || lit(y, x + 1);
508
+ if (across && !lit(y - 1, x)) return '.';
509
+ return lit(y - 1, x) || lit(y + 1, x) ? ':' : '.';
510
+ })
511
+ .join(''));
512
+ }
513
+
514
+ /** Every lit cell a star with a gap after it, which is `wide` with one column of two drawn. */
515
+ function stars(rows: Grid): Grid {
516
+ return rows.map((row) => Array.from(row).map((c) => (c === ' ' ? ' ' : '* ')).join(''));
517
+ }
518
+
519
+ // ------------------------------------------------------------------ fonts
520
+
521
+ export interface Font {
522
+ id: string;
523
+ title: string;
524
+ /** What is different about it, for the panel under the list. */
525
+ note: string;
526
+ glyphs: Record<string, Grid>;
527
+ /** Columns between two letters. */
528
+ tracking: number;
529
+ /** Columns a word space takes. */
530
+ space: number;
531
+ /**
532
+ * Two sets of letters, or one.
533
+ *
534
+ * Everything off the five-row table has `both`, because the table does and a
535
+ * transform of it cannot lose a case. `mini` has one set and folds - at three
536
+ * rows there is no room under a cap for an x-height.
537
+ */
538
+ cases: 'both' | 'folded';
539
+ /**
540
+ * Whether this font's glyphs are written in placeholders.
541
+ *
542
+ * The ones off the five-row table are: `#` and `%` and the two halves, filled
543
+ * in from the theme at paint time. The hand-drawn tables are not - they are
544
+ * already what they look like - and it matters, because a literal `#` in one
545
+ * of them would otherwise be swapped for a full block, which is how the
546
+ * character `#` would come out as a solid bar.
547
+ */
548
+ placeholders?: boolean;
549
+ /**
550
+ * Which row of the glyph box the letters sit on.
551
+ *
552
+ * Only needed where it is not the last one: `gard` keeps two blank rows
553
+ * under its baseline for the descenders, so a character drawn on the last
554
+ * row would hang below every letter beside it. Used for the stand-in a
555
+ * missing character gets, which has to land on the line like anything else.
556
+ */
557
+ baseline?: number;
558
+ /**
559
+ * The font to draw instead where this one's characters do not exist.
560
+ *
561
+ * Only `tmplt` needs one. Every other font here is made of placeholders the
562
+ * theme fills in, or of characters a teletype has - box-drawing is neither,
563
+ * and there is no `#` to downgrade a `┏` to. So it names a stand-in of the
564
+ * same height rather than putting a row of question marks on the screen.
565
+ */
566
+ fallback?: string;
567
+ }
568
+
569
+ export const FONTS: Font[] = [
570
+ {
571
+ id: 'block',
572
+ title: 'block',
573
+ note: 'The hand-drawn table: five rows, one cell to a stroke, both cases on one baseline.',
574
+ glyphs: BLOCK,
575
+ tracking: 1,
576
+ space: 3,
577
+ placeholders: true,
578
+ cases: 'both',
579
+ },
580
+ {
581
+ id: 'wide',
582
+ title: 'wide',
583
+ note: 'The same table with every column drawn twice. Twice the letter, the same shape - and twice as much of it for a ramp to run across.',
584
+ glyphs: mapGlyphs(BLOCK, wide),
585
+ tracking: 2,
586
+ space: 4,
587
+ placeholders: true,
588
+ cases: 'both',
589
+ },
590
+ {
591
+ id: 'slant',
592
+ title: 'slant',
593
+ note: 'The same table sheared half a column per row. A bitmap font gets its italic the way it gets everything else: by moving the cells.',
594
+ glyphs: mapGlyphs(BLOCK, slant),
595
+ tracking: 1,
596
+ space: 3,
597
+ placeholders: true,
598
+ cases: 'both',
599
+ },
600
+ {
601
+ id: 'shadow',
602
+ title: 'shadow',
603
+ note: 'The same table twice: the letter, and a copy of it one cell down and right in a second glyph. Two characters in one block, which an ink colours without being told there are two.',
604
+ glyphs: mapGlyphs(BLOCK, shadow),
605
+ tracking: 1,
606
+ space: 3,
607
+ placeholders: true,
608
+ cases: 'both',
609
+ },
610
+ {
611
+ id: 'half',
612
+ title: 'half',
613
+ note: 'Two rows of the table to one row of half cells. The only transform that changes the height - five rows become three, and a banner that would not fit a short terminal does.',
614
+ glyphs: mapGlyphs(BLOCK, half),
615
+ tracking: 1,
616
+ space: 3,
617
+ placeholders: true,
618
+ cases: 'both',
619
+ },
620
+ {
621
+ id: 'dots',
622
+ title: 'dots',
623
+ note: 'A dot where a bar runs across, a colon where a stem runs down - and the junction goes to whichever one comes down into it. The same letters in a different pen.',
624
+ glyphs: mapGlyphs(BLOCK, dots),
625
+ tracking: 1,
626
+ space: 3,
627
+ cases: 'both',
628
+ },
629
+ {
630
+ id: 'stars',
631
+ title: 'stars',
632
+ note: 'Every lit cell a star with a gap after it, which is `wide` with one column of the two drawn. Airy enough that a per-cell ink reads as a pattern rather than as a wash.',
633
+ glyphs: mapGlyphs(BLOCK, stars),
634
+ tracking: 2,
635
+ space: 4,
636
+ cases: 'both',
637
+ },
638
+ {
639
+ id: 'pagga',
640
+ title: 'pagga',
641
+ note: 'Three rows of half cells on a shaded ground - the difference from half, and the point of it: the letters are knocked out of a block of texture rather than floating on the terminal.',
642
+ glyphs: PAGGA,
643
+ tracking: 0,
644
+ space: 4,
645
+ cases: 'folded',
646
+ fallback: 'mini',
647
+ },
648
+ {
649
+ id: 'gard',
650
+ title: 'gard',
651
+ note: 'Quotes, pipes and dots, transcribed glyph for glyph - the only font here with two cases of its own rather than a fold, and the only one with letters that hang below the baseline.',
652
+ glyphs: GARD,
653
+ tracking: 1,
654
+ space: 3,
655
+ baseline: 6,
656
+ cases: 'both',
657
+ },
658
+ {
659
+ id: 'tmplt',
660
+ title: 'tmplt',
661
+ note: 'Heavy box-drawing, transcribed capital for capital, with a lowercase drawn to match - three rows and a fourth for the tails of g, j, p, q and y. Nothing to fall back to on an ascii terminal, so it borrows mini there.',
662
+ glyphs: TMPLT,
663
+ tracking: 1,
664
+ space: 2,
665
+ // Row two, not row three: the box grew a fourth row for the descenders
666
+ // and a stand-in belongs on the line the letters sit on, not on the one
667
+ // their tails reach down to.
668
+ baseline: 2,
669
+ cases: 'both',
670
+ fallback: 'mini',
671
+ },
672
+ {
673
+ id: 'mini',
674
+ title: 'mini',
675
+ note: 'A second table, three rows, drawn in strokes rather than in cells - because at three rows a solid A and a solid M are the same block. One case: lowercase folds to it.',
676
+ glyphs: MINI,
677
+ tracking: 1,
678
+ space: 2,
679
+ cases: 'folded',
680
+ },
681
+ ];
682
+
683
+
684
+ export function fontAt(id: string): Font {
685
+ return FONTS.find((f) => f.id === id) ?? (FONTS[0] as Font);
686
+ }
687
+
688
+ /** The font that is drawn in when a caller names none: the hand-drawn table. */
689
+ export const DEFAULT_FONT: Font = fontAt('block');
690
+
691
+ /** The tallest glyph in a font, which is how many rows a line of it takes. */
692
+ export function heightOf(font: Font): number {
693
+ return Object.values(font.glyphs).reduce((h, rows) => Math.max(h, rows.length), 0);
694
+ }
695
+
696
+ // ----------------------------------------------------------------- render
697
+
698
+ /**
699
+ * The glyph for a character, or nothing.
700
+ *
701
+ * Its own, or the other case's - folding is what lets a font with one set of
702
+ * letters take either - and trimmed of blank edge columns, so a `1` is narrow
703
+ * and an `M` is wide. Shared by the drawing and the measuring, because a wrap
704
+ * that measured a letter differently from the way it is drawn is a wrap that
705
+ * is wrong by however much they disagree.
706
+ */
707
+ function glyphOf(font: Font, char: string): Grid | undefined {
708
+ if (invisible(char)) return undefined;
709
+ const found = font.glyphs[char]
710
+ ?? font.glyphs[char.toUpperCase()]
711
+ ?? font.glyphs[char.toLowerCase()];
712
+ if (found !== undefined) return trim(found);
713
+ return char === ' ' ? undefined : standIn(font, char);
714
+ }
715
+
716
+ /**
717
+ * A character the font has no glyph for, drawn as itself.
718
+ *
719
+ * Not a gap. A gap is indistinguishable from a space, so a font missing its
720
+ * punctuation renders `hello, world!` as `hello world` and looks like it
721
+ * worked - the reader has no way to tell a missing glyph from a word break.
722
+ * One cell with the character in it is legible, obviously not part of the
723
+ * font, and says exactly which character is absent.
724
+ *
725
+ * On the baseline, so it sits on the line with the letters beside it rather
726
+ * than under them.
727
+ */
728
+ function standIn(font: Font, char: string): Grid {
729
+ const height = heightOf(font);
730
+ const line = font.baseline ?? height - 1;
731
+ // Padded to the cells it actually occupies. A wide character is one string
732
+ // index and two columns, and a stand-in measured by index put every letter
733
+ // after it one column to the left of where it was drawn.
734
+ const width = Math.max(1, stringWidth(char));
735
+ return Array.from({ length: height }, (_, y) => (y === line ? char : ' '.repeat(width)));
736
+ }
737
+
738
+ /**
739
+ * A character that draws nothing and takes no room, because it is not there.
740
+ *
741
+ * Zero-width and control characters arrive by paste - a byte-order mark off a
742
+ * web page, a zero-width space out of a code block - and they used to come out
743
+ * as a *word gap*, because a character with no glyph gets one. So `AB` pasted
744
+ * with a joiner between the letters rendered as `A B`, and the banner had a
745
+ * word break in it that the field it was typed in did not.
746
+ *
747
+ * Invisible in the text, invisible in the banner. It is the only reading that
748
+ * cannot surprise anyone.
749
+ */
750
+ const INVISIBLE = /^[\p{Cf}\p{Cc}\p{Mn}\p{Me}]$/u;
751
+
752
+ function invisible(char: string): boolean {
753
+ return graphemeWidth(char) === 0 || INVISIBLE.test(char);
754
+ }
755
+
756
+ /**
757
+ * A character that is a space, whatever its code point.
758
+ *
759
+ * A non-breaking space is a space: it arrives by paste out of anything that
760
+ * has ever been near a web page, and it was drawing as a stand-in - one blank
761
+ * cell where a word gap belonged, so the words either side ran together.
762
+ */
763
+ const SPACE = /^\p{Zs}$/u;
764
+
765
+ /** The columns a character takes, not counting the gap before it. */
766
+ function widthOf(font: Font, char: string): number {
767
+ if (invisible(char)) return 0;
768
+ if (SPACE.test(char)) return font.space;
769
+ const glyph = glyphOf(font, char);
770
+ return glyph ? stringWidth(glyph[0] as string) : font.space;
771
+ }
772
+
773
+ /**
774
+ * Blank edge columns removed, so a `1` is narrow and an `M` is wide.
775
+ *
776
+ * The width is the widest row and not the first one. A table written by hand
777
+ * has rows of whatever length they happened to be typed at, and `gard` has a
778
+ * blank first row on every letter - so taking row zero's length trimmed every
779
+ * one of them to nothing at all.
780
+ */
781
+ function trim(rows: Grid): Grid {
782
+ const width = rows.reduce((w, row) => Math.max(w, row.length), 0);
783
+ const padded = rows.map((row) => row.padEnd(width, ' '));
784
+ let start = 0;
785
+ let end = width - 1;
786
+ const blank = (x: number): boolean => padded.every((row) => row[x] === ' ');
787
+ while (start < width && blank(start)) start++;
788
+ while (end >= start && blank(end)) end--;
789
+ return padded.map((row) => row.slice(start, end + 1));
790
+ }
791
+
792
+ /**
793
+ * The four characters a glyph's placeholders are drawn in.
794
+ *
795
+ * `fill` and `shade` are theme glyphs; the two halves are not, because the
796
+ * theme has no name for half a cell. What they all share is that the *font*
797
+ * names a role and something else decides what the terminal can show - which
798
+ * is the rule the whole catalog follows, and the reason `half` degrades to
799
+ * quotes and underscores instead of putting a row of question marks on the
800
+ * screen that needs the banner most.
801
+ */
802
+ export interface InkGlyphs {
803
+ fill: string;
804
+ shade: string;
805
+ top: string;
806
+ bottom: string;
807
+ }
808
+
809
+ export function inkGlyphs(
810
+ glyphs: { progressFull: string; progressEmpty: string },
811
+ unicode = true,
812
+ ): InkGlyphs {
813
+ return {
814
+ fill: glyphs.progressFull,
815
+ shade: glyphs.progressEmpty,
816
+ top: unicode ? '▀' : '"',
817
+ bottom: unicode ? '▄' : '_',
818
+ };
819
+ }
820
+
821
+ /** What the fonts are drawn in where nothing has said otherwise. */
822
+ export const PLAIN_GLYPHS: InkGlyphs = { fill: '#', shade: '-', top: '"', bottom: '_' };
823
+
824
+ /**
825
+ * Text as block letters.
826
+ *
827
+ * Newlines are lines: each one becomes its own block of rows, stacked with
828
+ * `lineGap` blank rows between them so two lines of banner do not read as one.
829
+ * A character with no glyph becomes a word space, so a missing one shows as a
830
+ * gap the reader can see rather than silently closing up.
831
+ */
832
+ export function bannerLines(
833
+ text: string,
834
+ font: Font,
835
+ ink: InkGlyphs = PLAIN_GLYPHS,
836
+ width = Infinity,
837
+ ): string[] {
838
+ const height = heightOf(font);
839
+ return text
840
+ .split('\n')
841
+ .flatMap((line) => wrapToWidth(line, font, width))
842
+ .map((line) => bannerLine(line, font, height, ink));
843
+ }
844
+
845
+ /**
846
+ * The same banner, one string per line.
847
+ *
848
+ * For a caller that has to place the lines itself, and the case that makes it
849
+ * necessary is centring: a multi-line block is centred once, as a block, so its
850
+ * shorter lines start at the widest line's edge and read as left-aligned. One
851
+ * `ColorText` per line - each `alignBlock` and `textAlign="center"` - centres
852
+ * every line over its own width while keeping the rows of each in step.
853
+ *
854
+ * The catch is the ink: a [`ColorText`](color-text.md) starts its ink at its
855
+ * own first cell, so a block drawn as several pieces restarts a `cycle` (and a
856
+ * `{ gradient }` measures each piece rather than the lot). That is a reason to
857
+ * keep one component for a continuous ramp, and this for centring.
858
+ */
859
+ export function banner(
860
+ text: string,
861
+ font: Font,
862
+ ink: InkGlyphs = PLAIN_GLYPHS,
863
+ width = Infinity,
864
+ /**
865
+ * Blank rows between one line and the next. One by default.
866
+ *
867
+ * Counts the *empty* rows, so `0` butts two lines together and `2` leaves two
868
+ * rows of air. It applies to every line the block has, wrapped or written -
869
+ * the break between two lines of a sentence is the same break as the one
870
+ * between two paragraphs, which is what makes a wrapped banner read as a
871
+ * block of its own rather than as one long line that ran out of room.
872
+ */
873
+ lineGap = 1,
874
+ ): string {
875
+ const gap = `\n${'\n'.repeat(Math.max(0, Math.floor(lineGap)))}`;
876
+ return bannerLines(text, font, ink, width).join(gap);
877
+ }
878
+
879
+ /**
880
+ * Break a line of text into lines that fit, measured in this font.
881
+ *
882
+ * The wrapping has to happen *here*, on the text, and not on the drawn block:
883
+ * a line of block letters cut at column sixty is a line of half letters, and
884
+ * five rows each cut in a different place is not a word at all. So the letters
885
+ * are measured before they are drawn, and the break goes between them.
886
+ *
887
+ * Words first, characters only when one word cannot fit on a line of its own -
888
+ * because breaking `Deployment` in half is bad and dropping the second half is
889
+ * worse, and one of those has to happen.
890
+ */
891
+ function wrapToWidth(text: string, font: Font, width: number): string[] {
892
+ if (!Number.isFinite(width) || width <= 0) return [text];
893
+
894
+ const lines: string[] = [];
895
+ let line = '';
896
+ let used = 0;
897
+ const flush = (): void => { lines.push(line); line = ''; used = 0; };
898
+
899
+ /**
900
+ * Put a piece on the line, or on the next one.
901
+ *
902
+ * `sep` is the gap it needs from what is already there - a word space
903
+ * between words, tracking between the letters of one word - and `gap` is
904
+ * what that gap is spelled as in the text, which is a space for the first
905
+ * and nothing for the second.
906
+ */
907
+ const add = (piece: string, cost: number, sep: number, gap: string): void => {
908
+ if (line !== '' && used + sep + cost > width) flush();
909
+ if (line === '') { line = piece; used = cost; return; }
910
+ line += gap + piece;
911
+ used += sep + cost;
912
+ };
913
+
914
+ for (const word of text.split(' ')) {
915
+ const cost = costOf(word, font);
916
+ if (cost === 0) continue;
917
+ if (cost <= width) { add(word, cost, font.space, ' '); continue; }
918
+ // Wider than a whole line even on its own, so it is spent a character at a
919
+ // time - starting in whatever room is left on this line rather than on a
920
+ // fresh one, because a line holding three letters and a lot of air is not
921
+ // an improvement on a broken word.
922
+ let first = true;
923
+ for (const char of Array.from(word)) {
924
+ add(char, widthOf(font, char), first ? font.space : font.tracking, first ? ' ' : '');
925
+ first = false;
926
+ }
927
+ }
928
+ if (line !== '' || lines.length === 0) lines.push(line);
929
+ return lines;
930
+ }
931
+
932
+ /** The columns a word takes, letters and the tracking between them. */
933
+ function costOf(word: string, font: Font): number {
934
+ // Invisible characters are dropped before the tracking is counted, or a
935
+ // word of nothing but a byte-order mark would still be charged for the gaps
936
+ // between the letters it does not have.
937
+ const chars = Array.from(word).filter((char) => !invisible(char));
938
+ return chars.reduce((total, char, i) => total + (i === 0 ? 0 : font.tracking) + widthOf(font, char), 0);
939
+ }
940
+
941
+ function bannerLine(text: string, font: Font, height: number, ink: InkGlyphs): string {
942
+ const rows: string[] = Array.from({ length: height }, () => '');
943
+ let first = true;
944
+
945
+ for (const char of Array.from(text)) {
946
+ if (invisible(char)) continue;
947
+ // A space is a gap unless the font draws one. `pagga` does: its ground has
948
+ // to run through the gap between two words, and a gap would be a hole in
949
+ // it - so the table has a glyph for `' '` and that glyph wins.
950
+ const glyph = SPACE.test(char)
951
+ ? (font.glyphs[' '] === undefined ? undefined : trim(font.glyphs[' ']))
952
+ : glyphOf(font, char);
953
+ if (!glyph) {
954
+ for (let y = 0; y < height; y++) rows[y] += ' '.repeat(font.space);
955
+ first = true;
956
+ continue;
957
+ }
958
+ // Top-aligned: row nought of a glyph is row nought of the line, and a
959
+ // glyph shorter than the font is short at the *bottom*.
960
+ //
961
+ // Which is what lets a descender be written as one row taller than
962
+ // everything else and nothing else be touched. An `a` says where it starts
963
+ // with a blank first row; a `g` says where it ends by having a fourth. Line
964
+ // them up from the bottom instead and adding a tail to `g` silently pushes
965
+ // every three-row capital down a row, so `A` comes to rest on the same line
966
+ // as the tail rather than on the same line as the `g` itself.
967
+ const width = glyph.reduce((w, row) => Math.max(w, row.length), 0);
968
+ for (let y = 0; y < height; y++) {
969
+ const row = y < glyph.length ? (glyph[y] as string) : ' '.repeat(width);
970
+ rows[y] += (first ? '' : ' '.repeat(font.tracking)) + row;
971
+ }
972
+ first = false;
973
+ }
974
+
975
+ // Blank rows off the top and the bottom, so a line of nothing but x-height
976
+ // letters is four rows rather than five with a gap over it. Per line, which
977
+ // is the unit that is stacked - the letters inside one still share a
978
+ // baseline, and that is the alignment that has to hold.
979
+ // Only where the font said its glyphs are placeholders. A hand-drawn table
980
+ // is already what it looks like, and running this over one would turn its
981
+ // literal `#` into a full block and its `v` into half a cell.
982
+ const ink_ = (row: string): string => (font.placeholders
983
+ ? row.replace(/#/g, ink.fill).replace(/%/g, ink.shade)
984
+ .replace(/\^/g, ink.top).replace(/v/g, ink.bottom)
985
+ : row);
986
+ const drawn = rows.map((row) => ink_(row).trimEnd());
987
+ while (drawn.length > 0 && drawn[0] === '') drawn.shift();
988
+ while (drawn.length > 0 && drawn[drawn.length - 1] === '') drawn.pop();
989
+ return drawn.join('\n');
990
+ }