tessella 0.3.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 (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. metadata +226 -0
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ module Table
5
+ # Cells as lines (DESIGN.md, "Diagram modules"): a cell's "\n"
6
+ # splits it into lines within its row, and every cell of a row
7
+ # is padded with empty lines to the row's tallest, top-aligned.
8
+ module Cells
9
+ def self.lines(cell)
10
+ lines = Table.sanitize(cell.to_s).split("\n", -1)
11
+ lines.empty? ? [""] : lines
12
+ end
13
+
14
+ def self.grid(rows) = rows.map { |row| row.map { lines(it) } }
15
+
16
+ def self.multiline?(grid) = grid.any? { |r| r.any? { it.size > 1 } }
17
+
18
+ def self.width(cell_lines) = cell_lines.map { Width.of(it) }.max
19
+
20
+ # A title: one line, tabs as blanks.
21
+ def self.title_text(title)
22
+ title && Table.sanitize(Compose.text!("title", title, false))
23
+ end
24
+
25
+ # A markdown cell: tabs as blanks, a | escaped, as a GFM pipe
26
+ # table needs.
27
+ def self.md_cell(text) = Table.sanitize(text).gsub("|", "\\|")
28
+
29
+ # One row of cells (each an Array of lines) as Arrays of
30
+ # line strings, one Array per output line.
31
+ def self.rows_of(row)
32
+ height = row.map(&:size).max
33
+ (0...height).map { |i| row.map { it[i] || "" } }
34
+ end
35
+
36
+ BOLD = "\e[1m"
37
+ RESET = "\e[0m"
38
+
39
+ def self.bold(cell_lines)
40
+ cell_lines.map { it.empty? ? it : "#{BOLD}#{it}#{RESET}" }
41
+ end
42
+
43
+ # The header row in bold when colour is on.
44
+ def self.header(row, color)
45
+ color && row ? row.map { bold(it) } : row
46
+ end
47
+
48
+ def self.column_widths(grid)
49
+ grid.transpose.map { |col| col.map { width(it) }.max + 2 }
50
+ end
51
+
52
+ def self.check_markdown!(head, body, fit)
53
+ return unless fit[:width] ||
54
+ multiline?(grid([head, *body].compact))
55
+
56
+ raise ArgumentError,
57
+ "markdown has no multi-line cells and no width: fitting"
58
+ end
59
+ end
60
+
61
+ # A framed table's glyphs and widths under the width target:
62
+ # under :cjk a Unicode glyph takes two columns, so widths go in
63
+ # whole glyphs and edges are padded to the widest glyph.
64
+ module Edges
65
+ # width: as Fit counts it, one column a border glyph: less, under
66
+ # :cjk, the columns its two-column glyphs and the rounding of
67
+ # each column to whole glyphs may add.
68
+ def self.fit_width(fit, g, widths)
69
+ unit = unit(g)
70
+ fit[:width] &&
71
+ (fit[:width] - (((2 * widths.size) + 1) * (unit - 1)))
72
+ end
73
+
74
+ # The style's glyphs, an edge or joint narrower than the widest
75
+ # glyph padded to its columns, so a minimal table's blanks and
76
+ # its narrow โ•ถ โ•ท keep up with its two-column rules under :cjk
77
+ # (the runs, h and sep_h, are repeated to the column instead).
78
+ def self.glyphs(style)
79
+ g = Glyphs.fetch(style)
80
+ unit = unit(g)
81
+ g.to_h do |role, glyph|
82
+ runs = %i[h sep_h].include?(role)
83
+ [role, runs ? glyph : Width.ljust(glyph, unit)]
84
+ end
85
+ end
86
+
87
+ def self.unit(g) = g.values.map { Width.of(it) }.max
88
+
89
+ # The widths, the last column widened when the title would not
90
+ # fit the span with a blank either side.
91
+ def self.titled(widths, title, g)
92
+ return widths unless title
93
+
94
+ short = Width.of(title) + 2 - Render.span_width(widths, g[:cs])
95
+ short.positive? ? [*widths[..-2], widths[-1] + short] : widths
96
+ end
97
+ end
98
+
99
+ # Fitting a width (width:, min_width:): when the framed table is
100
+ # wider than width, the widest column narrows by the overflow
101
+ # and its cells wrap to it -- min_width: one floor for every
102
+ # column, or an Array of one a column; below them it
103
+ # is refused, naming the width the table needs at the least.
104
+ module Fit
105
+ def self.apply(grid, widths, width, min_width)
106
+ floors = min_widths!(min_width, widths.size)
107
+ total = Render.span_width(widths) + 2
108
+ return [grid, widths] if width.nil? || total <= width
109
+
110
+ narrow(grid, widths, total - width, [floors, min_width])
111
+ end
112
+
113
+ # min_width: one floor for every column, or an Array of one a
114
+ # column; each a positive Integer.
115
+ def self.min_widths!(min_width, ncols)
116
+ floors = Array(min_width)
117
+ floors *= ncols unless min_width.is_a?(Array)
118
+ good = floors.all? { it.is_a?(Integer) && it.positive? }
119
+ return floors if good && floors.size == ncols
120
+
121
+ raise ArgumentError, "min_width must be a positive Integer " \
122
+ "or #{ncols} of them: #{min_width.inspect}"
123
+ end
124
+
125
+ # Over columns come off the widest column still above its
126
+ # floor, one at a time, so widths converge downwards and a
127
+ # narrow column is never narrowed first. A column's floor is
128
+ # min_width, or its natural width when that is smaller; the
129
+ # table is refused only when every column is at its floor.
130
+ def self.narrow(grid, widths, over, mins)
131
+ per_column, min_width = mins
132
+ texts = widths.map { it - 2 }
133
+ floors = texts.zip(per_column).map(&:min)
134
+ target = texts.sum - over
135
+ check_room!(floors, target, min_width)
136
+ fitted = level(texts, floors, target)
137
+ [grid.map { wrap_columns(it, texts, fitted) },
138
+ fitted.map { it + 2 }]
139
+ end
140
+
141
+ def self.level(texts, floors, target)
142
+ fitted = texts.dup
143
+ while fitted.sum > target
144
+ i = fitted.each_index.select { fitted[it] > floors[it] }
145
+ .max_by { fitted[it] }
146
+ fitted[i] -= 1
147
+ end
148
+ fitted
149
+ end
150
+
151
+ def self.check_room!(floors, target, min_width)
152
+ return if floors.sum <= target
153
+
154
+ least = Render.span_width(floors.map { it + 2 }) + 2
155
+ raise ArgumentError, "table needs #{least} columns at the " \
156
+ "least (min_width #{min_width})"
157
+ end
158
+
159
+ # Every narrowed column's cells wrapped to its new width.
160
+ def self.wrap_columns(row, texts, fitted)
161
+ row.each_with_index.map do |cell, i|
162
+ next cell if fitted[i] == texts[i]
163
+
164
+ cell.flat_map { Wrap.line(it, fitted[i]) }
165
+ end
166
+ end
167
+ end
168
+
169
+ # Word wrap by display width, ANSI colour kept intact: a word
170
+ # longer than the column breaks inside, and a colour still open
171
+ # at a line's end is closed there and reopened on the next line,
172
+ # so every line stands alone.
173
+ module Wrap
174
+ SGR = /\e\[[0-9;]*m/
175
+ RESETS = ["\e[0m", "\e[m"].freeze
176
+
177
+ def self.line(text, cols)
178
+ return [text] if Width.of(text) <= cols
179
+
180
+ recolour(pack(words(text, cols), cols))
181
+ end
182
+
183
+ def self.words(text, cols)
184
+ text.split.flat_map do |word|
185
+ Width.of(word) > cols ? chop(word, cols) : [word]
186
+ end
187
+ end
188
+
189
+ # A word too long for the column, in pieces of cols columns;
190
+ # an SGR sequence stays whole, attached to the next glyph.
191
+ def self.chop(word, cols)
192
+ glyphs = word.scan(/(?:#{SGR})*(?:.(?:#{SGR})*)/o)
193
+ glyphs.each_with_object([+""]) do |g, out|
194
+ Width.of(out.last + g) > cols ? out << g.dup : out.last << g
195
+ end
196
+ end
197
+
198
+ def self.pack(words, cols)
199
+ words.each_with_object([]) do |w, out|
200
+ if out.any? && Width.of("#{out.last} #{w}") <= cols
201
+ out.last << " " << w
202
+ else
203
+ out << w.dup
204
+ end
205
+ end
206
+ end
207
+
208
+ def self.recolour(lines)
209
+ open = []
210
+ lines.map do |l|
211
+ text = open.join + l
212
+ open = active(text)
213
+ open.empty? ? text : text + Cells::RESET
214
+ end
215
+ end
216
+
217
+ # The SGR sequences still in effect at the end of text.
218
+ def self.active(text)
219
+ text.scan(SGR).each_with_object([]) do |seq, on|
220
+ RESETS.include?(seq) ? on.clear : on << seq
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Where a figure will be read (DESIGN.md, "Width targets"), which
5
+ # decides what a glyph's width is and which glyphs are safe:
6
+ #
7
+ # :term a terminal (the default): ambiguous glyphs -- box
8
+ # drawing, blocks, ยท โ— โ˜…, Greek, accented Latin -- one
9
+ # column, wide ones two.
10
+ # :cjk a CJK-configured terminal: ambiguous glyphs two columns
11
+ # too, so figures draw their structure in ASCII (ascii:
12
+ # defaults to true, text stays as given) and measure text
13
+ # with ambiguous glyphs as two.
14
+ # :web a browser: as :term, but emoji-capable glyphs (โ–ถ โ˜€ โœ” ...)
15
+ # may turn into wide emoji, so a figure's text carries
16
+ # U+FE0E, the text-presentation selector, after each one.
17
+ # That keeps it a text glyph, not its width: a glyph the
18
+ # viewer's monospace font lacks takes its fallback font's
19
+ # advance (โ–ถ two columns, โ†— 1.4 in Chrome on GitHub's font
20
+ # stack), so only glyphs that font carries line up.
21
+ #
22
+ # The target is set for a block of code (Target.with) or for the
23
+ # rest of the fiber (Tessella.target=); the command line takes
24
+ # `tessella --target T COMMAND` or TESSELLA_TARGET.
25
+ module Target
26
+ TARGETS = %i[term cjk web].freeze
27
+ TEXT_STYLE = "\u{FE0E}"
28
+
29
+ # Emoji-capable with text-default presentation (Emoji=Yes minus
30
+ # Emoji_Presentation=Yes, BMP; digits/#/* excluded): one cell in
31
+ # terminals, but web viewers may substitute the 2-cell emoji
32
+ # glyph. Generated from Unicode 15.1 emoji-data.txt.
33
+ EMOJI_RISK = [
34
+ 0x00A9..0x00A9, 0x00AE..0x00AE, 0x203C..0x203C, 0x2049..0x2049,
35
+ 0x2122..0x2122, 0x2139..0x2139, 0x2194..0x2199, 0x21A9..0x21AA,
36
+ 0x2328..0x2328, 0x23CF..0x23CF, 0x23ED..0x23EF, 0x23F1..0x23F2,
37
+ 0x23F8..0x23FA, 0x24C2..0x24C2, 0x25AA..0x25AB, 0x25B6..0x25B6,
38
+ 0x25C0..0x25C0, 0x25FB..0x25FC, 0x2600..0x2604, 0x260E..0x260E,
39
+ 0x2611..0x2611, 0x2618..0x2618, 0x261D..0x261D, 0x2620..0x2620,
40
+ 0x2622..0x2623, 0x2626..0x2626, 0x262A..0x262A, 0x262E..0x262F,
41
+ 0x2638..0x263A, 0x2640..0x2640, 0x2642..0x2642, 0x265F..0x2660,
42
+ 0x2663..0x2663, 0x2665..0x2666, 0x2668..0x2668, 0x267B..0x267B,
43
+ 0x267E..0x267E, 0x2692..0x2692, 0x2694..0x2697, 0x2699..0x2699,
44
+ 0x269B..0x269C, 0x26A0..0x26A0, 0x26A7..0x26A7, 0x26B0..0x26B1,
45
+ 0x26C8..0x26C8, 0x26CF..0x26CF, 0x26D1..0x26D1, 0x26D3..0x26D3,
46
+ 0x26E9..0x26E9, 0x26F0..0x26F1, 0x26F4..0x26F4, 0x26F7..0x26F9,
47
+ 0x2702..0x2702, 0x2708..0x2709, 0x270C..0x270D, 0x270F..0x270F,
48
+ 0x2712..0x2712, 0x2714..0x2714, 0x2716..0x2716, 0x271D..0x271D,
49
+ 0x2721..0x2721, 0x2733..0x2734, 0x2744..0x2744, 0x2747..0x2747,
50
+ 0x2763..0x2764, 0x27A1..0x27A1, 0x2934..0x2935, 0x2B05..0x2B07,
51
+ 0x3030..0x3030, 0x303D..0x303D, 0x3297..0x3297, 0x3299..0x3299
52
+ ].freeze
53
+ def self.current = Fiber[:tessella_target] || :term
54
+ def self.cjk? = current == :cjk
55
+ def self.web? = current == :web
56
+
57
+ # ascii:'s default: true under :cjk, whose terminals give box
58
+ # drawing two columns.
59
+ def self.ascii? = cjk?
60
+
61
+ # A chart's canvas by default: braille, but quadrant blocks
62
+ # under :web, where a browser draws braille from a fallback
63
+ # font a fifth wider than a column (measured in Chrome on
64
+ # GitHub's code fonts), so a braille chart's rows run unequal.
65
+ def self.canvas = web? ? :block : :braille
66
+
67
+ # Text under ascii: true must be ASCII, except under :cjk,
68
+ # where ascii: only means the structure.
69
+ def self.text_free? = cjk?
70
+
71
+ def self.check!(target)
72
+ sym = target.to_s.to_sym
73
+ return sym if TARGETS.include?(sym)
74
+
75
+ raise ArgumentError, "unknown target: #{target.inspect} " \
76
+ "(#{TARGETS.join(", ")})"
77
+ end
78
+
79
+ def self.with(target)
80
+ before = Fiber[:tessella_target]
81
+ Fiber[:tessella_target] = check!(target)
82
+ yield
83
+ ensure
84
+ Fiber[:tessella_target] = before
85
+ end
86
+
87
+ def self.emoji_risk?(code) = EMOJI_RISK.any? { it.cover?(code) }
88
+
89
+ # Under :web, U+FE0E after every emoji-capable glyph not
90
+ # already followed by a presentation selector; else as is.
91
+ def self.adapt(text)
92
+ return text unless web?
93
+
94
+ text.gsub(/(.)(?![\u{FE0E}\u{FE0F}])/) do |ch|
95
+ emoji_risk?(ch.ord) ? ch + TEXT_STYLE : ch
96
+ end
97
+ end
98
+ end
99
+
100
+ def self.target = Target.current
101
+
102
+ def self.target=(target)
103
+ Fiber[:tessella_target] = Target.check!(target)
104
+ end
105
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Text styles (DESIGN.md, "Text styles"): plain letters and digits
5
+ # to the Unicode forms of a style -- bold, italic, script, fraktur,
6
+ # double-struck, sans, monospace, small caps, superscript,
7
+ # subscript, circled, parenthesized, squared, fullwidth -- and back
8
+ # (style: :plain). A character the style has no form for passes
9
+ # through unchanged; strict: true refuses the text instead, naming
10
+ # them. Only letters and digits count as missing: punctuation and
11
+ # spaces are expected to pass. Small caps keep capitals as they
12
+ # are; the styles with capitals only use them for small letters
13
+ # too. Every style is non-ASCII, so ascii: true refuses them all.
14
+ # The output is text, measured by Width like any other: fullwidth
15
+ # is two columns a glyph.
16
+ module Text
17
+ CAPITALS_ONLY = %i[squared negative_circled negative_squared].freeze
18
+ STYLES = (FORMS.keys + %i[plain]).freeze
19
+
20
+ def self.convert(text, style:, strict: false, ascii: false)
21
+ name = style!(style)
22
+ raise ArgumentError, "text styles are not ASCII" if ascii
23
+
24
+ missing = unmapped(text, name)
25
+ if strict && !missing.empty?
26
+ raise ArgumentError, "no #{dashed(name)} form for: " \
27
+ "#{missing.join(" ")}"
28
+ end
29
+ text.to_s.each_char.map { form(it, name) }.join
30
+ end
31
+
32
+ # The letters and digits of text the style has no form for.
33
+ def self.unmapped(text, style)
34
+ name = style!(style)
35
+ return [] if name == :plain
36
+
37
+ text.to_s.each_char.select do |ch|
38
+ ch.match?(/[A-Za-z0-9]/) && form(ch, name) == ch &&
39
+ !(name == :small_caps && ch.match?(/[A-Z]/))
40
+ end.uniq
41
+ end
42
+
43
+ def self.dashed(name) = name.to_s.tr("_", "-")
44
+
45
+ def self.style!(style)
46
+ name = Borders.name(style)
47
+ return name if STYLES.include?(name)
48
+
49
+ raise ArgumentError, "unknown text style: #{style.inspect} " \
50
+ "(#{STYLES.join(", ").tr("_", "-")})"
51
+ end
52
+
53
+ def self.form(char, name)
54
+ return plain_map.fetch(char, char) if name == :plain
55
+
56
+ key = CAPITALS_ONLY.include?(name) ? char.upcase : char
57
+ map(name).fetch(key, char)
58
+ end
59
+
60
+ def self.map(name)
61
+ (@maps ||= {})[name] ||= FORMS.fetch(name).then do |form|
62
+ form[:from].chars.zip(form[:to].chars).to_h
63
+ end
64
+ end
65
+
66
+ # Every style's forms back to their plain characters.
67
+ def self.plain_map
68
+ @plain_map ||= FORMS.keys.each_with_object({}) do |name, back|
69
+ map(name).each { |plain, styled| back[styled] ||= plain }
70
+ end
71
+ end
72
+ end
73
+
74
+ # (text, style:, strict: false, ascii: false) => String.
75
+ def self.text(text, **) = Text.convert(text, **)
76
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Each text style as { from:, to: }: the plain characters it has a
5
+ # form for, and those forms, in the same order -- generated from
6
+ # Unicode 16's names and decompositions (<font>, <super>, <sub>,
7
+ # <circle>, <wide>, <square>), never by code point offsets. The
8
+ # Mathematical Alphanumeric holes are filled from Letterlike
9
+ # Symbols (โ„Ž โ„ฌ โ„ฐ โ„ฑ โ„‹ โ„ โ„’ โ„ณ โ„› โ„ฏ โ„Š โ„ด โ„ญ โ„Œ โ„‘ โ„œ โ„จ โ„‚ โ„ โ„• โ„™ โ„š โ„ โ„ค); the
10
+ # ordinal indicators (ยช ยบ) are not superscripts.
11
+ module Text
12
+ # The Mathematical Alphanumeric Symbols styles.
13
+ module MathForms
14
+ FORMS = {
15
+ bold: {
16
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
17
+ "efghijklmnopqrstuvwxyz",
18
+ to: "๐ŸŽ๐Ÿ๐Ÿ๐Ÿ‘๐Ÿ’๐Ÿ“๐Ÿ”๐Ÿ•๐Ÿ–๐Ÿ—๐€๐๐‚๐ƒ๐„๐…๐†๐‡๐ˆ๐‰๐Š๐‹๐Œ๐๐Ž๐๐๐‘๐’๐“๐”๐•๐–๐—๐˜๐™๐š๐›๐œ๐" \
19
+ "๐ž๐Ÿ๐ ๐ก๐ข๐ฃ๐ค๐ฅ๐ฆ๐ง๐จ๐ฉ๐ช๐ซ๐ฌ๐ญ๐ฎ๐ฏ๐ฐ๐ฑ๐ฒ๐ณ"
20
+ },
21
+ italic: {
22
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
23
+ "opqrstuvwxyz",
24
+ to: "๐ด๐ต๐ถ๐ท๐ธ๐น๐บ๐ป๐ผ๐ฝ๐พ๐ฟ๐‘€๐‘๐‘‚๐‘ƒ๐‘„๐‘…๐‘†๐‘‡๐‘ˆ๐‘‰๐‘Š๐‘‹๐‘Œ๐‘๐‘Ž๐‘๐‘๐‘‘๐‘’๐‘“๐‘”โ„Ž๐‘–๐‘—๐‘˜๐‘™๐‘š๐‘›" \
25
+ "๐‘œ๐‘๐‘ž๐‘Ÿ๐‘ ๐‘ก๐‘ข๐‘ฃ๐‘ค๐‘ฅ๐‘ฆ๐‘ง"
26
+ },
27
+ bold_italic: {
28
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
29
+ "opqrstuvwxyz",
30
+ to: "๐‘จ๐‘ฉ๐‘ช๐‘ซ๐‘ฌ๐‘ญ๐‘ฎ๐‘ฏ๐‘ฐ๐‘ฑ๐‘ฒ๐‘ณ๐‘ด๐‘ต๐‘ถ๐‘ท๐‘ธ๐‘น๐‘บ๐‘ป๐‘ผ๐‘ฝ๐‘พ๐‘ฟ๐’€๐’๐’‚๐’ƒ๐’„๐’…๐’†๐’‡๐’ˆ๐’‰๐’Š๐’‹๐’Œ๐’๐’Ž๐’" \
31
+ "๐’๐’‘๐’’๐’“๐’”๐’•๐’–๐’—๐’˜๐’™๐’š๐’›"
32
+ },
33
+ script: {
34
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
35
+ "opqrstuvwxyz",
36
+ to: "๐’œโ„ฌ๐’ž๐’Ÿโ„ฐโ„ฑ๐’ขโ„‹โ„๐’ฅ๐’ฆโ„’โ„ณ๐’ฉ๐’ช๐’ซ๐’ฌโ„›๐’ฎ๐’ฏ๐’ฐ๐’ฑ๐’ฒ๐’ณ๐’ด๐’ต๐’ถ๐’ท๐’ธ๐’นโ„ฏ๐’ปโ„Š๐’ฝ๐’พ๐’ฟ๐“€๐“๐“‚๐“ƒ" \
37
+ "โ„ด๐“…๐“†๐“‡๐“ˆ๐“‰๐“Š๐“‹๐“Œ๐“๐“Ž๐“"
38
+ },
39
+ bold_script: {
40
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
41
+ "opqrstuvwxyz",
42
+ to: "๐“๐“‘๐“’๐““๐“”๐“•๐“–๐“—๐“˜๐“™๐“š๐“›๐“œ๐“๐“ž๐“Ÿ๐“ ๐“ก๐“ข๐“ฃ๐“ค๐“ฅ๐“ฆ๐“ง๐“จ๐“ฉ๐“ช๐“ซ๐“ฌ๐“ญ๐“ฎ๐“ฏ๐“ฐ๐“ฑ๐“ฒ๐“ณ๐“ด๐“ต๐“ถ๐“ท" \
43
+ "๐“ธ๐“น๐“บ๐“ป๐“ผ๐“ฝ๐“พ๐“ฟ๐”€๐”๐”‚๐”ƒ"
44
+ },
45
+ fraktur: {
46
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
47
+ "opqrstuvwxyz",
48
+ to: "๐”„๐”…โ„ญ๐”‡๐”ˆ๐”‰๐”Šโ„Œโ„‘๐”๐”Ž๐”๐”๐”‘๐”’๐”“๐””โ„œ๐”–๐”—๐”˜๐”™๐”š๐”›๐”œโ„จ๐”ž๐”Ÿ๐” ๐”ก๐”ข๐”ฃ๐”ค๐”ฅ๐”ฆ๐”ง๐”จ๐”ฉ๐”ช๐”ซ" \
49
+ "๐”ฌ๐”ญ๐”ฎ๐”ฏ๐”ฐ๐”ฑ๐”ฒ๐”ณ๐”ด๐”ต๐”ถ๐”ท"
50
+ },
51
+ bold_fraktur: {
52
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
53
+ "opqrstuvwxyz",
54
+ to: "๐•ฌ๐•ญ๐•ฎ๐•ฏ๐•ฐ๐•ฑ๐•ฒ๐•ณ๐•ด๐•ต๐•ถ๐•ท๐•ธ๐•น๐•บ๐•ป๐•ผ๐•ฝ๐•พ๐•ฟ๐–€๐–๐–‚๐–ƒ๐–„๐–…๐–†๐–‡๐–ˆ๐–‰๐–Š๐–‹๐–Œ๐–๐–Ž๐–๐–๐–‘๐–’๐–“" \
55
+ "๐–”๐–•๐––๐–—๐–˜๐–™๐–š๐–›๐–œ๐–๐–ž๐–Ÿ"
56
+ },
57
+ double_struck: {
58
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
59
+ "efghijklmnopqrstuvwxyz",
60
+ to: "๐Ÿ˜๐Ÿ™๐Ÿš๐Ÿ›๐Ÿœ๐Ÿ๐Ÿž๐ŸŸ๐Ÿ ๐Ÿก๐”ธ๐”นโ„‚๐”ป๐”ผ๐”ฝ๐”พโ„๐•€๐•๐•‚๐•ƒ๐•„โ„•๐•†โ„™โ„šโ„๐•Š๐•‹๐•Œ๐•๐•Ž๐•๐•โ„ค๐•’๐•“๐•”๐••" \
61
+ "๐•–๐•—๐•˜๐•™๐•š๐•›๐•œ๐•๐•ž๐•Ÿ๐• ๐•ก๐•ข๐•ฃ๐•ค๐•ฅ๐•ฆ๐•ง๐•จ๐•ฉ๐•ช๐•ซ"
62
+ },
63
+ sans: {
64
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
65
+ "efghijklmnopqrstuvwxyz",
66
+ to: "๐Ÿข๐Ÿฃ๐Ÿค๐Ÿฅ๐Ÿฆ๐Ÿง๐Ÿจ๐Ÿฉ๐Ÿช๐Ÿซ๐– ๐–ก๐–ข๐–ฃ๐–ค๐–ฅ๐–ฆ๐–ง๐–จ๐–ฉ๐–ช๐–ซ๐–ฌ๐–ญ๐–ฎ๐–ฏ๐–ฐ๐–ฑ๐–ฒ๐–ณ๐–ด๐–ต๐–ถ๐–ท๐–ธ๐–น๐–บ๐–ป๐–ผ๐–ฝ" \
67
+ "๐–พ๐–ฟ๐—€๐—๐—‚๐—ƒ๐—„๐—…๐—†๐—‡๐—ˆ๐—‰๐—Š๐—‹๐—Œ๐—๐—Ž๐—๐—๐—‘๐—’๐—“"
68
+ },
69
+ sans_bold: {
70
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
71
+ "efghijklmnopqrstuvwxyz",
72
+ to: "๐Ÿฌ๐Ÿญ๐Ÿฎ๐Ÿฏ๐Ÿฐ๐Ÿฑ๐Ÿฒ๐Ÿณ๐Ÿด๐Ÿต๐—”๐—•๐—–๐——๐—˜๐—™๐—š๐—›๐—œ๐—๐—ž๐—Ÿ๐— ๐—ก๐—ข๐—ฃ๐—ค๐—ฅ๐—ฆ๐—ง๐—จ๐—ฉ๐—ช๐—ซ๐—ฌ๐—ญ๐—ฎ๐—ฏ๐—ฐ๐—ฑ" \
73
+ "๐—ฒ๐—ณ๐—ด๐—ต๐—ถ๐—ท๐—ธ๐—น๐—บ๐—ป๐—ผ๐—ฝ๐—พ๐—ฟ๐˜€๐˜๐˜‚๐˜ƒ๐˜„๐˜…๐˜†๐˜‡"
74
+ },
75
+ sans_italic: {
76
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
77
+ "opqrstuvwxyz",
78
+ to: "๐˜ˆ๐˜‰๐˜Š๐˜‹๐˜Œ๐˜๐˜Ž๐˜๐˜๐˜‘๐˜’๐˜“๐˜”๐˜•๐˜–๐˜—๐˜˜๐˜™๐˜š๐˜›๐˜œ๐˜๐˜ž๐˜Ÿ๐˜ ๐˜ก๐˜ข๐˜ฃ๐˜ค๐˜ฅ๐˜ฆ๐˜ง๐˜จ๐˜ฉ๐˜ช๐˜ซ๐˜ฌ๐˜ญ๐˜ฎ๐˜ฏ" \
79
+ "๐˜ฐ๐˜ฑ๐˜ฒ๐˜ณ๐˜ด๐˜ต๐˜ถ๐˜ท๐˜ธ๐˜น๐˜บ๐˜ป"
80
+ },
81
+ sans_bold_italic: {
82
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn" \
83
+ "opqrstuvwxyz",
84
+ to: "๐˜ผ๐˜ฝ๐˜พ๐˜ฟ๐™€๐™๐™‚๐™ƒ๐™„๐™…๐™†๐™‡๐™ˆ๐™‰๐™Š๐™‹๐™Œ๐™๐™Ž๐™๐™๐™‘๐™’๐™“๐™”๐™•๐™–๐™—๐™˜๐™™๐™š๐™›๐™œ๐™๐™ž๐™Ÿ๐™ ๐™ก๐™ข๐™ฃ" \
85
+ "๐™ค๐™ฅ๐™ฆ๐™ง๐™จ๐™ฉ๐™ช๐™ซ๐™ฌ๐™ญ๐™ฎ๐™ฏ"
86
+ },
87
+ monospace: {
88
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
89
+ "efghijklmnopqrstuvwxyz",
90
+ to: "๐Ÿถ๐Ÿท๐Ÿธ๐Ÿน๐Ÿบ๐Ÿป๐Ÿผ๐Ÿฝ๐Ÿพ๐Ÿฟ๐™ฐ๐™ฑ๐™ฒ๐™ณ๐™ด๐™ต๐™ถ๐™ท๐™ธ๐™น๐™บ๐™ป๐™ผ๐™ฝ๐™พ๐™ฟ๐š€๐š๐š‚๐šƒ๐š„๐š…๐š†๐š‡๐šˆ๐š‰๐šŠ๐š‹๐šŒ๐š" \
91
+ "๐šŽ๐š๐š๐š‘๐š’๐š“๐š”๐š•๐š–๐š—๐š˜๐š™๐šš๐š›๐šœ๐š๐šž๐šŸ๐š ๐šก๐šข๐šฃ"
92
+ }
93
+ }.freeze
94
+ end
95
+
96
+ # Small caps, super/subscripts, enclosed and fullwidth forms.
97
+ module OtherForms
98
+ FORMS = {
99
+ small_caps: {
100
+ from: "abcdefghijklmnopqrstuvwyz",
101
+ to: "แด€ส™แด„แด…แด‡๊œฐษขสœษชแดŠแด‹สŸแดษดแดแด˜๊žฏส€๊œฑแด›แดœแด แดกสแดข"
102
+ },
103
+ superscript: {
104
+ from: "0123456789ABCDEFGHIJKLMNOPQRTUVWabcdefgh" \
105
+ "ijklmnopqrstuvwxyz",
106
+ to: "โฐยนยฒยณโดโตโถโทโธโนแดฌแดฎ๊Ÿฒแดฐแดฑ๊Ÿณแดณแดดแดตแดถแดทแดธแดนแดบแดผแดพ๊Ÿดแดฟแต€แตโฑฝแต‚แตƒแต‡แถœแตˆแต‰แถ แตสฐ" \
107
+ "โฑสฒแตหกแตโฟแต’แต–๐žฅสณหขแต—แต˜แต›สทหฃสธแถป"
108
+ },
109
+ subscript: {
110
+ from: "0123456789aehijklmnoprstuvx",
111
+ to: "โ‚€โ‚โ‚‚โ‚ƒโ‚„โ‚…โ‚†โ‚‡โ‚ˆโ‚‰โ‚โ‚‘โ‚•แตขโฑผโ‚–โ‚—โ‚˜โ‚™โ‚’โ‚šแตฃโ‚›โ‚œแตคแตฅโ‚“"
112
+ },
113
+ circled: {
114
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd" \
115
+ "efghijklmnopqrstuvwxyz",
116
+ to: "โ“ชโ‘ โ‘กโ‘ขโ‘ฃโ‘คโ‘ฅโ‘ฆโ‘งโ‘จโ’ถโ’ทโ’ธโ’นโ’บโ’ปโ’ผโ’ฝโ’พโ’ฟโ“€โ“โ“‚โ“ƒโ“„โ“…โ“†โ“‡โ“ˆโ“‰โ“Šโ“‹โ“Œโ“โ“Žโ“โ“โ“‘โ“’โ““" \
117
+ "โ“”โ“•โ“–โ“—โ“˜โ“™โ“šโ“›โ“œโ“โ“žโ“Ÿโ“ โ“กโ“ขโ“ฃโ“คโ“ฅโ“ฆโ“งโ“จโ“ฉ"
118
+ },
119
+ negative_circled: {
120
+ from: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
121
+ to: "โ“ฟโถโทโธโนโบโปโผโฝโพ๐Ÿ…๐Ÿ…‘๐Ÿ…’๐Ÿ…“๐Ÿ…”๐Ÿ…•๐Ÿ…–๐Ÿ…—๐Ÿ…˜๐Ÿ…™๐Ÿ…š๐Ÿ…›๐Ÿ…œ๐Ÿ…๐Ÿ…ž๐Ÿ…Ÿ๐Ÿ… ๐Ÿ…ก๐Ÿ…ข๐Ÿ…ฃ๐Ÿ…ค๐Ÿ…ฅ๐Ÿ…ฆ๐Ÿ…ง๐Ÿ…จ๐Ÿ…ฉ"
122
+ },
123
+ parenthesized: {
124
+ from: "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcde" \
125
+ "fghijklmnopqrstuvwxyz",
126
+ to: "โ‘ดโ‘ตโ‘ถโ‘ทโ‘ธโ‘นโ‘บโ‘ปโ‘ผ๐Ÿ„๐Ÿ„‘๐Ÿ„’๐Ÿ„“๐Ÿ„”๐Ÿ„•๐Ÿ„–๐Ÿ„—๐Ÿ„˜๐Ÿ„™๐Ÿ„š๐Ÿ„›๐Ÿ„œ๐Ÿ„๐Ÿ„ž๐Ÿ„Ÿ๐Ÿ„ ๐Ÿ„ก๐Ÿ„ข๐Ÿ„ฃ๐Ÿ„ค๐Ÿ„ฅ๐Ÿ„ฆ๐Ÿ„ง๐Ÿ„จ๐Ÿ„ฉโ’œโ’โ’žโ’Ÿโ’ " \
127
+ "โ’กโ’ขโ’ฃโ’คโ’ฅโ’ฆโ’งโ’จโ’ฉโ’ชโ’ซโ’ฌโ’ญโ’ฎโ’ฏโ’ฐโ’ฑโ’ฒโ’ณโ’ดโ’ต"
128
+ },
129
+ squared: {
130
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
131
+ to: "๐Ÿ„ฐ๐Ÿ„ฑ๐Ÿ„ฒ๐Ÿ„ณ๐Ÿ„ด๐Ÿ„ต๐Ÿ„ถ๐Ÿ„ท๐Ÿ„ธ๐Ÿ„น๐Ÿ„บ๐Ÿ„ป๐Ÿ„ผ๐Ÿ„ฝ๐Ÿ„พ๐Ÿ„ฟ๐Ÿ…€๐Ÿ…๐Ÿ…‚๐Ÿ…ƒ๐Ÿ…„๐Ÿ……๐Ÿ…†๐Ÿ…‡๐Ÿ…ˆ๐Ÿ…‰"
132
+ },
133
+ negative_squared: {
134
+ from: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
135
+ to: "๐Ÿ…ฐ๐Ÿ…ฑ๐Ÿ…ฒ๐Ÿ…ณ๐Ÿ…ด๐Ÿ…ต๐Ÿ…ถ๐Ÿ…ท๐Ÿ…ธ๐Ÿ…น๐Ÿ…บ๐Ÿ…ป๐Ÿ…ผ๐Ÿ…ฝ๐Ÿ…พ๐Ÿ…ฟ๐Ÿ†€๐Ÿ†๐Ÿ†‚๐Ÿ†ƒ๐Ÿ†„๐Ÿ†…๐Ÿ††๐Ÿ†‡๐Ÿ†ˆ๐Ÿ†‰"
136
+ },
137
+ fullwidth: {
138
+ from: "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGH" \
139
+ "IJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnop" \
140
+ "qrstuvwxyz{|}~ ",
141
+ to: "๏ผ๏ผ‚๏ผƒ๏ผ„๏ผ…๏ผ†๏ผ‡๏ผˆ๏ผ‰๏ผŠ๏ผ‹๏ผŒ๏ผ๏ผŽ๏ผ๏ผ๏ผ‘๏ผ’๏ผ“๏ผ”๏ผ•๏ผ–๏ผ—๏ผ˜๏ผ™๏ผš๏ผ›๏ผœ๏ผ๏ผž๏ผŸ๏ผ ๏ผก๏ผข๏ผฃ๏ผค๏ผฅ๏ผฆ๏ผง๏ผจ" \
142
+ "๏ผฉ๏ผช๏ผซ๏ผฌ๏ผญ๏ผฎ๏ผฏ๏ผฐ๏ผฑ๏ผฒ๏ผณ๏ผด๏ผต๏ผถ๏ผท๏ผธ๏ผน๏ผบ๏ผป๏ผผ๏ผฝ๏ผพ๏ผฟ๏ฝ€๏ฝ๏ฝ‚๏ฝƒ๏ฝ„๏ฝ…๏ฝ†๏ฝ‡๏ฝˆ๏ฝ‰๏ฝŠ๏ฝ‹๏ฝŒ๏ฝ๏ฝŽ๏ฝ๏ฝ" \
143
+ "๏ฝ‘๏ฝ’๏ฝ“๏ฝ”๏ฝ•๏ฝ–๏ฝ—๏ฝ˜๏ฝ™๏ฝš๏ฝ›๏ฝœ๏ฝ๏ฝžใ€€"
144
+ }
145
+ }.freeze
146
+ end
147
+
148
+ FORMS = MathForms::FORMS.merge(OtherForms::FORMS).freeze
149
+ end
150
+ end