mahjong-render 0.1.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +7 -0
  4. data/LICENSE +202 -0
  5. data/LICENSES/CC0-1.0.txt +121 -0
  6. data/README.ja.md +90 -0
  7. data/README.md +112 -0
  8. data/SECURITY.md +5 -0
  9. data/THIRD_PARTY_NOTICES.md +17 -0
  10. data/assets/manifest.json +45 -0
  11. data/assets/tiles/Chun.svg +232 -0
  12. data/assets/tiles/Front.svg +334 -0
  13. data/assets/tiles/Haku.svg +214 -0
  14. data/assets/tiles/Hatsu.svg +309 -0
  15. data/assets/tiles/Man1.svg +276 -0
  16. data/assets/tiles/Man2.svg +286 -0
  17. data/assets/tiles/Man3.svg +292 -0
  18. data/assets/tiles/Man4.svg +292 -0
  19. data/assets/tiles/Man5-Dora.svg +319 -0
  20. data/assets/tiles/Man5.svg +313 -0
  21. data/assets/tiles/Man6.svg +297 -0
  22. data/assets/tiles/Man7.svg +283 -0
  23. data/assets/tiles/Man8.svg +286 -0
  24. data/assets/tiles/Man9.svg +292 -0
  25. data/assets/tiles/Nan.svg +270 -0
  26. data/assets/tiles/Pei.svg +236 -0
  27. data/assets/tiles/Pin1.svg +544 -0
  28. data/assets/tiles/Pin2.svg +400 -0
  29. data/assets/tiles/Pin3.svg +362 -0
  30. data/assets/tiles/Pin4.svg +407 -0
  31. data/assets/tiles/Pin5-Dora.svg +460 -0
  32. data/assets/tiles/Pin5.svg +454 -0
  33. data/assets/tiles/Pin6.svg +477 -0
  34. data/assets/tiles/Pin7.svg +567 -0
  35. data/assets/tiles/Pin8.svg +570 -0
  36. data/assets/tiles/Pin9.svg +623 -0
  37. data/assets/tiles/Shaa.svg +248 -0
  38. data/assets/tiles/Sou1.svg +764 -0
  39. data/assets/tiles/Sou2.svg +372 -0
  40. data/assets/tiles/Sou3.svg +462 -0
  41. data/assets/tiles/Sou4.svg +573 -0
  42. data/assets/tiles/Sou5-Dora.svg +614 -0
  43. data/assets/tiles/Sou5.svg +608 -0
  44. data/assets/tiles/Sou6.svg +688 -0
  45. data/assets/tiles/Sou7.svg +628 -0
  46. data/assets/tiles/Sou8.svg +712 -0
  47. data/assets/tiles/Sou9.svg +748 -0
  48. data/assets/tiles/Ton.svg +242 -0
  49. data/docs/adr/0001-renderer-selection.md +7 -0
  50. data/docs/adr/0002-license.md +5 -0
  51. data/docs/adr/0003-notation.md +5 -0
  52. data/docs/adr/0004-assets.md +5 -0
  53. data/docs/adr/0005-package.md +5 -0
  54. data/docs/release.md +32 -0
  55. data/docs/research/existing-renderers.md +21 -0
  56. data/examples/basic.adoc +13 -0
  57. data/examples/hand.png +0 -0
  58. data/lib/mahjong_render/asciidoctor.rb +32 -0
  59. data/lib/mahjong_render/parser.rb +113 -0
  60. data/lib/mahjong_render/renderer.rb +105 -0
  61. data/lib/mahjong_render/version.rb +8 -0
  62. data/lib/mahjong_render.rb +14 -0
  63. metadata +106 -0
@@ -0,0 +1,242 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
3
+
4
+ <svg
5
+ xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
6
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
7
+ xmlns:cc="http://creativecommons.org/ns#"
8
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
9
+ xmlns:svg="http://www.w3.org/2000/svg"
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
13
+ width="300"
14
+ height="400"
15
+ viewBox="0 0 300 400"
16
+ id="svg2"
17
+ version="1.1"
18
+ inkscape:version="0.91 r13725"
19
+ sodipodi:docname="Ton.svg"
20
+ inkscape:export-filename="C:\Users\Fluffy\Documents\Projects\ExtraRiichi\Ton.png"
21
+ inkscape:export-xdpi="180"
22
+ inkscape:export-ydpi="180">
23
+ <defs
24
+ id="defs4">
25
+ <inkscape:path-effect
26
+ effect="skeletal"
27
+ id="path-effect7963"
28
+ is_visible="true"
29
+ pattern="m -90.825902,-314.06958 23.03016,41.38503 13.798268,-41.38503 z"
30
+ copytype="repeated_stretched"
31
+ prop_scale="1"
32
+ scale_y_rel="false"
33
+ spacing="0"
34
+ normal_offset="0"
35
+ tang_offset="0"
36
+ prop_units="false"
37
+ vertical_pattern="false"
38
+ fuse_tolerance="0"
39
+ pattern-nodetypes="cccc" />
40
+ <inkscape:path-effect
41
+ effect="skeletal"
42
+ id="path-effect7830"
43
+ is_visible="true"
44
+ pattern="M -12.828427,33.715729 -17,-11 l 9.0000001,0 z"
45
+ copytype="repeated_stretched"
46
+ prop_scale="-1"
47
+ scale_y_rel="false"
48
+ spacing="5.1"
49
+ normal_offset="0"
50
+ tang_offset="0"
51
+ prop_units="false"
52
+ vertical_pattern="false"
53
+ fuse_tolerance="0"
54
+ pattern-nodetypes="cccc" />
55
+ <linearGradient
56
+ id="linearGradient10055"
57
+ osb:paint="solid">
58
+ <stop
59
+ style="stop-color:#000000;stop-opacity:1;"
60
+ offset="0"
61
+ id="stop10057" />
62
+ </linearGradient>
63
+ <marker
64
+ inkscape:stockid="Arrow1Lstart"
65
+ orient="auto"
66
+ refY="0"
67
+ refX="0"
68
+ id="Arrow1Lstart"
69
+ style="overflow:visible"
70
+ inkscape:isstock="true">
71
+ <path
72
+ id="path4978"
73
+ d="M 0,0 5,-5 -12.5,0 5,5 0,0 Z"
74
+ style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#ff5c00;stroke-width:1pt;stroke-opacity:1"
75
+ transform="matrix(0.8,0,0,0.8,10,0)"
76
+ inkscape:connector-curvature="0" />
77
+ </marker>
78
+ <clipPath
79
+ clipPathUnits="userSpaceOnUse"
80
+ id="clipPath4243">
81
+ <circle
82
+ style="opacity:1;fill:#000000;fill-opacity:0.29670332;fill-rule:nonzero;stroke:#000000;stroke-width:19.13299942;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
83
+ id="circle4245"
84
+ cx="-264.65997"
85
+ cy="-198.20665"
86
+ r="293.95438" />
87
+ </clipPath>
88
+ <clipPath
89
+ clipPathUnits="userSpaceOnUse"
90
+ id="clipPath7847">
91
+ <ellipse
92
+ style="opacity:1;fill:#822600;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:12;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
93
+ id="ellipse7849"
94
+ cx="394"
95
+ cy="552.36218"
96
+ rx="349.49533"
97
+ ry="216" />
98
+ </clipPath>
99
+ <clipPath
100
+ clipPathUnits="userSpaceOnUse"
101
+ id="clipPath4243-1">
102
+ <circle
103
+ style="opacity:1;fill:#000000;fill-opacity:0.29670332;fill-rule:nonzero;stroke:#000000;stroke-width:19.13299942;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
104
+ id="circle4245-4"
105
+ cx="-264.65997"
106
+ cy="-198.20665"
107
+ r="293.95438" />
108
+ </clipPath>
109
+ <clipPath
110
+ clipPathUnits="userSpaceOnUse"
111
+ id="clipPath7876">
112
+ <circle
113
+ style="opacity:1;fill:#000000;fill-opacity:0.29670332;fill-rule:nonzero;stroke:#000000;stroke-width:19.13299942;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
114
+ id="circle7878"
115
+ cx="-264.65997"
116
+ cy="-198.20665"
117
+ r="293.95438" />
118
+ </clipPath>
119
+ <clipPath
120
+ clipPathUnits="userSpaceOnUse"
121
+ id="clipPath14693">
122
+ <rect
123
+ style="opacity:1;fill:#a53c3c;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
124
+ id="rect14695"
125
+ width="131.78395"
126
+ height="168.82127"
127
+ x="-332.59583"
128
+ y="383.49765"
129
+ rx="1.2551664"
130
+ ry="3.7514515"
131
+ transform="matrix(0.99939083,-0.03489951,0.03489951,0.99939083,0,0)" />
132
+ </clipPath>
133
+ <clipPath
134
+ clipPathUnits="userSpaceOnUse"
135
+ id="clipPath14952">
136
+ <ellipse
137
+ style="opacity:1;fill:#a53c3c;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:7;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
138
+ id="ellipse14954"
139
+ cx="-271.34384"
140
+ cy="647.25604"
141
+ rx="69.057365"
142
+ ry="116.91089"
143
+ transform="matrix(0.99939083,-0.03489951,0.03489951,0.99939083,0,0)" />
144
+ </clipPath>
145
+ </defs>
146
+ <sodipodi:namedview
147
+ id="base"
148
+ pagecolor="#aeffff"
149
+ bordercolor="#666666"
150
+ borderopacity="1"
151
+ inkscape:pageopacity="0"
152
+ inkscape:pageshadow="2"
153
+ inkscape:zoom="0.50548197"
154
+ inkscape:cx="363.15718"
155
+ inkscape:cy="458.34028"
156
+ inkscape:document-units="px"
157
+ inkscape:current-layer="layer1"
158
+ showgrid="true"
159
+ inkscape:window-width="1920"
160
+ inkscape:window-height="1017"
161
+ inkscape:window-x="1912"
162
+ inkscape:window-y="-8"
163
+ inkscape:window-maximized="1"
164
+ showguides="true"
165
+ inkscape:guide-bbox="true"
166
+ units="px">
167
+ <inkscape:grid
168
+ type="xygrid"
169
+ id="grid4774"
170
+ visible="true"
171
+ dotted="false"
172
+ color="#3f3fff"
173
+ opacity="0.03921569"
174
+ empcolor="#3f3fff"
175
+ empopacity="0.07843137"
176
+ enabled="false" />
177
+ <sodipodi:guide
178
+ position="150,200"
179
+ orientation="0,1"
180
+ id="guide8231"
181
+ inkscape:label=""
182
+ inkscape:color="rgb(0,0,255)" />
183
+ <sodipodi:guide
184
+ position="150,200"
185
+ orientation="1,0"
186
+ id="guide8233"
187
+ inkscape:label=""
188
+ inkscape:color="rgb(0,0,255)" />
189
+ </sodipodi:namedview>
190
+ <metadata
191
+ id="metadata7">
192
+ <rdf:RDF>
193
+ <cc:Work
194
+ rdf:about="">
195
+ <dc:format>image/svg+xml</dc:format>
196
+ <dc:type
197
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
198
+ <dc:title />
199
+ </cc:Work>
200
+ </rdf:RDF>
201
+ </metadata>
202
+ <g
203
+ inkscape:label="Layer 1"
204
+ inkscape:groupmode="layer"
205
+ id="layer1"
206
+ transform="translate(0,-652.36216)">
207
+ <g
208
+ id="g4182"
209
+ transform="matrix(0.90070005,0,0,0.97528977,22.480539,21.079387)">
210
+ <path
211
+ sodipodi:nodetypes="ccccccccccccccccccccccc"
212
+ inkscape:connector-curvature="0"
213
+ id="path4169"
214
+ d="M 233.62512,755.29109 C 178.13441,766.77996 64.370638,790.79873 58.640429,785.71129 42.658218,774.8367 25.686157,781.4533 35.789288,796.11051 c 16.240064,27.58037 41.08272,52.95267 53.054176,82.84405 2.288277,4.33456 4.578233,5.00952 6.866511,4.97624 34.894055,0.59778 59.617015,-7.13696 92.959705,-10.00028 5.24648,-0.50585 12.18032,5.19713 16.07777,3.49424 3.45211,-1.88029 4.88869,-8.56661 5.7989,-12.75873 6.85855,-25.41545 28.05224,-47.67676 39.89617,-72.71328 1.50503,-2.99478 9.84505,-3.54971 9.78218,-6.44028 -1.21917,-10.81663 -17.3035,-30.7056 -26.59958,-30.22138 z m -23.13944,33.57895 c 12.46189,-0.30489 -0.39856,25.04533 -5.5016,26.11044 -5.68441,0.83505 -6.59045,-7.30247 -13.55667,-7.44593 -5.23392,0.54881 -4.04327,3.93106 -9.43905,5.54094 -32.92047,6.46479 -57.05235,8.76033 -91.40148,12.13597 -4.332557,0.10941 -15.065211,-17.22193 -9.013958,-18.63046 40.940618,-9.93723 109.784008,-17.44402 128.912758,-17.71096 z m -16.38918,50.02681 c 4.01395,6.86764 -2.84402,13.79985 -7.80917,14.56359 -30.19331,4.51502 -78.25363,12.14709 -82.00667,8.23091 -2.66879,-2.84434 -8.082537,-10.97917 -7.221842,-16.53793 11.136252,-3.71556 92.300252,-14.42426 97.037682,-6.25657 z"
215
+ style="fill:#142896;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
216
+ <path
217
+ sodipodi:nodetypes="ccccccccccscccc"
218
+ inkscape:connector-curvature="0"
219
+ id="path4165"
220
+ d="m 123.10112,656.13043 c -18.37065,-7.40086 -20.37189,5.83216 -17.90295,14.33847 6.2135,20.20143 14.42853,28.63207 24.53852,42.67785 1.80515,2.41778 3.80884,7.05879 3.50496,9.27068 l -2.80317,207.5969 c 0.0245,6.49844 -1.22391,27.05521 -5.33713,23.81363 -16.25707,-12.35739 -21.17086,-19.29273 -47.244611,-51.54601 -11.141011,-14.92882 -19.888636,-6.9441 -6.365572,12.59128 42.459393,54.62391 46.079123,59.83408 46.706613,115.10687 1.6238,29.1788 31.75855,24.6486 34.5721,6.1383 3.42954,-19.9718 -1.64734,-51.78318 -1.84988,-73.7618 -0.77433,-84.02764 -0.85925,-158.3381 12.84541,-239.53344 2.59385,-12.6668 22.99001,-23.06614 33.97476,-26.71095 4.86607,-1.61964 10.28698,-13.97417 0.90028,-14.45229 -25.17978,-2.64878 -52.28301,-15.55116 -75.53933,-25.52949 z"
221
+ style="fill:#142896;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
222
+ <path
223
+ sodipodi:nodetypes="cccccc"
224
+ inkscape:connector-curvature="0"
225
+ id="path4167"
226
+ d="m 90.058998,749.95518 c -28.498411,-1.30744 -7.40777,15.78722 -1.003352,15.35489 35.469434,-3.22236 93.934624,-11.66451 130.102704,-19.88981 2.50231,-0.0783 -0.19108,-17.18993 -17.33839,-18.91906 -5.21285,-0.25484 -4.31683,2.60115 -10.47943,4.83506 -27.63258,6.00227 -76.15552,20.22066 -101.281532,18.61892 z"
227
+ style="fill:#142896;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
228
+ <path
229
+ sodipodi:nodetypes="ccccc"
230
+ inkscape:connector-curvature="0"
231
+ id="path4178"
232
+ d="M 101.07932,860.47476 C 81.038575,899.28326 62.611644,913.1005 1.9825743,939.28575 -21.477214,948.67859 -19.805065,962.93934 8.6272375,953.18208 85.796234,923.19531 101.97857,904.47533 114.56321,868.12926 Z"
233
+ style="fill:#142896;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
234
+ <path
235
+ sodipodi:nodetypes="ccccccc"
236
+ inkscape:connector-curvature="0"
237
+ id="path4180"
238
+ d="m 148.90381,861.23655 c 24.81185,28.93873 58.74403,106.95253 77.55965,95.36697 14.85312,-9.54021 33.94987,-10.66782 42.06647,-8.48169 10.62302,2.79871 11.13375,7.75465 17.07981,10.96967 15.63489,6.5882 12.36449,-2.02074 6.9373,-9.59456 -14.04344,-21.5996 -80.57109,-34.66827 -122.31035,-92.57081 z"
239
+ style="fill:#142896;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
240
+ </g>
241
+ </g>
242
+ </svg>
@@ -0,0 +1,7 @@
1
+ # ADR 0001: Renderer selection
2
+
3
+ Status: accepted for v0.1.0.
4
+
5
+ The core must run with Ruby alone. The [ecosystem review](../research/existing-renderers.md) found capable JavaScript and TeX renderers, but using either at runtime would add another language or a TeX installation. The Ruby Jekyll plugin is coupled to Liquid and separate image files, and its regex parser does not enforce valid input.
6
+
7
+ Reuse [FluffyStuff's CC0 tile artwork](https://github.com/FluffyStuff/riichi-mahjong-tiles), pinned by commit and checksums. Implement only the missing strict compact-notation parser and a Ruby SVG compositor. Keep Asciidoctor in an optional adapter. Do not copy code from the rejected renderers.
@@ -0,0 +1,5 @@
1
+ # ADR 0002: License
2
+
3
+ Status: accepted for v0.1.0.
4
+
5
+ Use Apache-2.0 (`SPDX-License-Identifier: Apache-2.0`) for project code and documentation, attributed to Maejima Kenya. The bundled tile SVGs and `examples/hand.png`, which is generated from those SVGs, remain independently CC0-1.0. Gem metadata lists both identifiers; `THIRD_PARTY_NOTICES.md` explains which files each covers because a license list alone cannot express component boundaries. Preserve the upstream declaration, full CC0 legal text, attribution, source revision, and file hashes. Keep optional and development dependency licenses separate from bundled content. CI fails when the Apache license copy changes or a locked dependency has no approved license metadata.
@@ -0,0 +1,5 @@
1
+ # ADR 0003: Compact notation
2
+
3
+ Status: accepted for v0.1.0.
4
+
5
+ Use established MPSZ grouping: each digit before `m`, `p`, `s`, or `z` is one tile. `0` is a red five in suited groups; honors accept only `1` through `7`. `405m` means `4m 0m 5m`, and `10m` means `1m 0m`. The original request's `10m` invalid example conflicts with this rule and is corrected here. Reject malformed input without skipping characters, and report token, zero-based character position, and reason. Hand legality and tile-count checks are outside this rendering library.
@@ -0,0 +1,5 @@
1
+ # ADR 0004: Asset strategy
2
+
3
+ Status: accepted for v0.1.0.
4
+
5
+ Bundle the 37 normal and red-five SVG faces plus the tile-front SVG from the pinned CC0 source. Layer the front below each face. Each rendered SVG embeds the needed asset bytes as `data:image/svg+xml;base64` image references. This keeps repeated SVGs deterministic and independent of external files, network requests, document-wide IDs, and installed fonts. The tradeoff is a larger output for hands containing repeated tiles; v0.1 prioritizes offline portability. CI verifies source hashes, XML structure, and absence of active or external SVG content.
@@ -0,0 +1,5 @@
1
+ # ADR 0005: Package strategy
2
+
3
+ Status: accepted for v0.1.0.
4
+
5
+ Distribute one Ruby gem named `mahjong-render`. `require "mahjong_render"` exposes the renderer without loading Asciidoctor. `require "mahjong_render/asciidoctor"` registers the HTML5 block macro when the separate `asciidoctor` gem is installed. This meets Ruby-only operation without splitting the small project into several packages. GitHub and RubyGems publication are separate follow-up actions after the local package, documentation, examples, CI, and license checks pass.
data/docs/release.md ADDED
@@ -0,0 +1,32 @@
1
+ # Release process
2
+
3
+ 1. Confirm `CHANGELOG.md` and `lib/mahjong_render/version.rb` use the intended SemVer version. Recheck that `mahjong-render` is available on RubyGems; a prior missing-name response does not reserve it.
4
+ 2. In the devcontainer, run `bundle exec rake lint test assets:check licenses:check example:build`, `gem build mahjong-render.gemspec`, and `ruby script/check_package.rb mahjong-render-0.1.0.gem`. Confirm the build has no warnings.
5
+ 3. Install the built gem locally and reproduce the Ruby and AsciiDoc commands in both READMEs without network access. Inspect the gem contents and confirm the Apache-2.0 license, CC0 legal text, artwork, notices, preview image, examples, and linked documentation are present.
6
+ 4. For the first release, create the public `kjun1/mahjong-render` GitHub repository. Confirm the homepage and gem metadata URLs resolve, verify CI on the default branch, and enable GitHub private vulnerability reporting.
7
+ 5. Review and date the 0.1.0 changelog entry. Configure Trusted Publishing as described below, then choose one of the release triggers.
8
+ 6. After publication, confirm the RubyGems version and ownership, install the gem from RubyGems, and check that the pending publisher became a trusted publisher.
9
+
10
+ ## Trusted Publishing setup
11
+
12
+ 1. In the GitHub repository, create an environment named `release` under **Settings → Environments**. If deployment branch and tag restrictions are enabled, allow the `main` branch and `v*` tags.
13
+ 2. Enable MFA for the RubyGems account. The gemspec sets `rubygems_mfa_required` to `true`.
14
+ 3. Sign in to RubyGems and open [Pending trusted publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers). Create a pending publisher with these exact values:
15
+
16
+ | Field | Value |
17
+ | --- | --- |
18
+ | Gem name | `mahjong-render` |
19
+ | Repository owner | `kjun1` |
20
+ | Repository name | `mahjong-render` |
21
+ | Workflow filename | `release.yml` |
22
+ | Environment | `release` |
23
+ | Workflow repository owner/name | Leave blank |
24
+
25
+ No RubyGems API key or GitHub secret is needed. RubyGems converts the pending publisher after the first successful push.
26
+
27
+ ## Choose a release trigger
28
+
29
+ - **Manual event:** Open GitHub **Actions → Publish gem → Run workflow**, select the `main` branch, and enter version `0.1.0`. The workflow publishes the version in `main` without creating a Git tag.
30
+ - **Tag push:** Push a tag matching the gem version, for example `git tag v0.1.0` followed by `git push origin v0.1.0`. The tag push publishes that version automatically.
31
+
32
+ Both routes validate the version, run the release checks, build the gem, and use the `release` environment. If a version from this repository is already on RubyGems, a later run checks its source metadata and skips the duplicate push. The RubyGems version cannot be overwritten; bump the gem version for any subsequent release.
@@ -0,0 +1,21 @@
1
+ # Existing ecosystem review
2
+
3
+ Reviewed for v0.1.0 on 2026-09-24. `yes/no/unclear` describes documented capability, not a promise of API compatibility. Maintenance is based on upstream activity visible at review time. Browser and Node columns concern direct use without a translation layer.
4
+
5
+ | Project / repository | Language | Code / asset license | Notation / parser | SVG / red five / meld | Browser / Node | Maintenance | Reusable / integration cost / decision |
6
+ | --- | --- | --- | --- | --- | --- | --- | --- |
7
+ | [mahjong-font](https://github.com/rutopio/mahjong-font) | TypeScript | MIT / SIL OFL 1.1 font | tile strings; parser tied to application | yes / yes / unclear | yes / unclear | active in 2026 | Font is reusable in principle; hosted image API violates offline runtime, application is not a Ruby gem. Do not depend on it. |
8
+ | [mjimage](https://github.com/konoui/mjimage) | TypeScript | MIT / tile-image provenance not established in repository docs | compact notation; parser yes | yes / yes / yes | yes / yes | active in 2026 | npm package exists, but requires a JS runtime and separately hosted tile images by default. High integration cost for Ruby-only operation; do not depend on it. |
9
+ | [mahjong-tiles](https://github.com/DrCMWither/mahjong-tiles) | LaTeX3 | MIT / CC0 art stated upstream | extended MPSZF; parser internal | PDF asset composition, not direct SVG / yes / yes | no / no | active in 2026 | Requires TeX and PDF assets. Good notation reference; not a suitable runtime renderer. |
10
+ | [mahjong-tex](https://github.com/Schmytzi/mahjong-tex) | LaTeX3 | MIT / README credits CC BY art | MPSZ; parser internal | PDF asset composition, not direct SVG / yes / yes | no / no | maintenance unclear | Requires TeX and cannot provide a small Ruby-only SVG path. Do not depend on it. |
11
+ | [tilekit](https://github.com/csimi/tilekit) | JavaScript | ISC / Arphic Public License glyph data | compact notation; parser yes | yes / yes / no | yes / yes | 2026 release, short history | Small direct SVG library, but Ruby-only runtime excludes it; transitive glyph-data terms would need separate attribution. Do not depend on it. |
12
+ | [jekyll-mahjong](https://github.com/peter1357908/jekyll-mahjong) | Ruby | MIT / Uzaku font-derived art credited; asset terms need separate review | compact notation; regex in Liquid tag | individual SVG files in HTML / unclear / sideways tile | no / no | last repository activity in 2023 | Ruby-native, but coupled to Jekyll/Liquid, external CSS and separate image files. Regex skips invalid text. Do not adapt it for the public renderer. |
13
+ | [riichi-mahjong-tiles](https://github.com/FluffyStuff/riichi-mahjong-tiles) | SVG assets | CC0-1.0 | not applicable | yes / yes / not applicable | yes / yes | stable art; last pinned revision 2024 | Reuse 37 tile faces and the tile front. Low integration cost and clear redistribution terms. |
14
+
15
+ ## AsciiDoc integration
16
+
17
+ [Ruby Asciidoctor's block macro extension](https://docs.asciidoctor.org/asciidoctor/latest/extensions/block-macro-processor/) already handles macro syntax and registration. The adapter uses that API and delegates only tile rendering to `MahjongRender.render`. No custom AsciiDoc parser is needed.
18
+
19
+ ## Decision summary
20
+
21
+ The public runtime must work with Ruby alone. Reusing the CC0 tile vectors avoids recreating artwork. A small parser is needed because the available Ruby/Jekyll candidate silently skips invalid notation, while the JS and TeX parsers cannot be used without extra runtimes. The SVG composer only places those tile vectors into one offline image. See the [ADR](../adr/0001-renderer-selection.md).
@@ -0,0 +1,13 @@
1
+ = Mahjong Render Example
2
+
3
+ == Basic hand
4
+
5
+ mahjong::123m456p789s12344z[]
6
+
7
+ == Red five
8
+
9
+ mahjong::405m456p789s12344z[]
10
+
11
+ == Groups with extra space
12
+
13
+ mahjong::123m|456p|(0.5)789s[]
data/examples/hand.png ADDED
Binary file
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Maejima Kenya
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require "asciidoctor"
7
+ require "asciidoctor/extensions"
8
+ require_relative "../mahjong_render"
9
+
10
+ module MahjongRender
11
+ class UnsupportedBackendError < StandardError; end
12
+ class UnsupportedAttributeError < StandardError; end
13
+
14
+ module Asciidoctor
15
+ class BlockMacro < ::Asciidoctor::Extensions::BlockMacroProcessor
16
+ use_dsl
17
+ named :mahjong
18
+
19
+ def process(parent, target, attrs)
20
+ raise UnsupportedBackendError, "mahjong block macro supports HTML output only" unless parent.document.basebackend?("html")
21
+ raise UnsupportedAttributeError, "mahjong block macro does not support attributes: #{attrs.keys.join(', ')}" unless attrs.empty?
22
+
23
+ svg = MahjongRender.render(target)
24
+ create_pass_block(parent, %(<div class="mahjong-render">#{svg}</div>), {}, subs: nil)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ Asciidoctor::Extensions.register do
31
+ block_macro MahjongRender::Asciidoctor::BlockMacro
32
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Maejima Kenya
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module MahjongRender
7
+ class NotationError < StandardError
8
+ attr_reader :token, :position, :reason
9
+
10
+ def initialize(token:, position:, reason:)
11
+ @token = token
12
+ @position = position
13
+ @reason = reason
14
+ super("#{reason} at character #{position}: #{token.inspect}")
15
+ end
16
+ end
17
+
18
+ module Parser
19
+ SUITS = %w[m p s z].freeze
20
+ MULTIPLIER = /\A[0-9]+(?:\.[0-9]+)?\z/
21
+ DEFAULT_GAP_MULTIPLIER = Rational(1, 4)
22
+ Layout = Struct.new(:tiles, :gaps, keyword_init: true)
23
+
24
+ module_function
25
+
26
+ def parse(notation)
27
+ parse_layout(notation).tiles
28
+ end
29
+
30
+ def parse_layout(notation)
31
+ raise TypeError, "notation must be a String" unless notation.is_a?(String)
32
+
33
+ layout = Layout.new(tiles: [], gaps: [])
34
+ digits = +""
35
+ start = nil
36
+ pending_gap = nil
37
+ separator_position = nil
38
+ chars = notation.chars
39
+ position = 0
40
+
41
+ while position < chars.length
42
+ char = chars[position]
43
+ if char.match?(/[0-9]/)
44
+ start ||= position
45
+ digits << char
46
+ elsif SUITS.include?(char)
47
+ error(char, position, "suit needs at least one digit") if digits.empty?
48
+ append_tiles(layout, digits, char, start, pending_gap)
49
+ pending_gap = nil
50
+ digits.clear
51
+ start = nil
52
+ elsif char.match?(/\s/)
53
+ error(digits, start, "missing suit before whitespace") unless digits.empty?
54
+ elsif char == "|"
55
+ validate_separator(layout.tiles, digits, start, pending_gap, position)
56
+ separator_position = position
57
+ pending_gap, position = parse_gap(chars, position)
58
+ else
59
+ error(char, position, "unknown notation character")
60
+ end
61
+ position += 1
62
+ end
63
+
64
+ error(digits, start, "missing suit") unless digits.empty?
65
+ error("|", separator_position, "separator needs a following group") unless pending_gap.nil?
66
+ error("", 0, "notation is empty") if layout.tiles.empty?
67
+ freeze_layout(layout)
68
+ end
69
+
70
+ def append_tiles(layout, digits, suit, start, gap)
71
+ digits.each_char.with_index do |digit, offset|
72
+ error("#{digit}z", start + offset, "honor rank must be 1–7") if suit == "z" && !digit.match?(/[1-7]/)
73
+ layout.gaps << (gap || 0) unless layout.tiles.empty?
74
+ layout.tiles << "#{digit}#{suit}"
75
+ gap = nil
76
+ end
77
+ end
78
+ private_class_method :append_tiles
79
+
80
+ def freeze_layout(layout)
81
+ layout.tiles.freeze
82
+ layout.gaps.freeze
83
+ layout.freeze
84
+ end
85
+ private_class_method :freeze_layout
86
+
87
+ def validate_separator(tiles, digits, start, pending_gap, position)
88
+ error(digits, start, "missing suit before separator") unless digits.empty?
89
+ error("|", position, "separator needs a preceding group") if tiles.empty?
90
+ error("|", position, "consecutive separators") unless pending_gap.nil?
91
+ end
92
+ private_class_method :validate_separator
93
+
94
+ def parse_gap(chars, position)
95
+ return [DEFAULT_GAP_MULTIPLIER, position] unless chars[position + 1] == "("
96
+
97
+ closing = position + 2
98
+ closing += 1 while closing < chars.length && chars[closing] != ")"
99
+ error("|(", position, "missing closing parenthesis for gap multiplier") if closing == chars.length
100
+
101
+ token = chars[(position + 2)...closing].join
102
+ error("|(#{token})", position, "invalid gap multiplier") unless MULTIPLIER.match?(token)
103
+
104
+ [Rational(token), closing]
105
+ end
106
+ private_class_method :parse_gap
107
+
108
+ def error(token, position, reason)
109
+ raise NotationError.new(token: token.dup, position: position, reason: reason)
110
+ end
111
+ private_class_method :error
112
+ end
113
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Maejima Kenya
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module MahjongRender
7
+ module Renderer
8
+ TILE_WIDTH = 300
9
+ TILE_HEIGHT = 400
10
+ GAP = 12
11
+ ASSET_DIRECTORY = File.expand_path("../../assets/tiles", __dir__)
12
+ XML_ESCAPE = { "&" => "&amp;", '"' => "&quot;", "<" => "&lt;", ">" => "&gt;" }.freeze
13
+ SUIT_NAMES = { "m" => "characters", "p" => "circles", "s" => "bamboo" }.freeze
14
+ ASSET_SUITS = { "m" => "Man", "p" => "Pin", "s" => "Sou" }.freeze
15
+ HONORS = {
16
+ "1" => ["Ton.svg", "east wind"],
17
+ "2" => ["Nan.svg", "south wind"],
18
+ "3" => ["Shaa.svg", "west wind"],
19
+ "4" => ["Pei.svg", "north wind"],
20
+ "5" => ["Haku.svg", "white dragon"],
21
+ "6" => ["Hatsu.svg", "green dragon"],
22
+ "7" => ["Chun.svg", "red dragon"]
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ def render(layout)
28
+ tiles = layout.tiles
29
+ width = (tiles.length * TILE_WIDTH) + ((tiles.length - 1) * GAP) + layout.gaps.sum { |gap| gap * TILE_WIDTH }
30
+ %(<svg #{svg_attributes(tiles, width)}>#{image_elements(layout).join}</svg>)
31
+ end
32
+
33
+ def svg_attributes(tiles, width)
34
+ label = "Mahjong tiles: #{tiles.map { |tile| tile_label(tile) }.join(', ')}".gsub(/[&"<>]/, XML_ESCAPE)
35
+ %(xmlns="http://www.w3.org/2000/svg" width="#{svg_number(width)}" height="#{TILE_HEIGHT}" ) +
36
+ %(viewBox="0 0 #{svg_number(width)} #{TILE_HEIGHT}" role="img" aria-label="#{label}" style="max-width:100%;height:auto")
37
+ end
38
+ private_class_method :svg_attributes
39
+
40
+ def image_elements(layout)
41
+ uris = {}
42
+ front = encode_asset("Front.svg")
43
+ layout.tiles.zip(tile_positions(layout.gaps)).map do |tile, x|
44
+ uri = uris[tile] ||= data_uri(tile)
45
+ image_element(x, front) + image_element(x, uri)
46
+ end
47
+ end
48
+ private_class_method :image_elements
49
+
50
+ def tile_positions(gaps)
51
+ positions = [0]
52
+ gaps.each { |gap| positions << (positions.last + TILE_WIDTH + GAP + (gap * TILE_WIDTH)) }
53
+ positions
54
+ end
55
+ private_class_method :tile_positions
56
+
57
+ def image_element(left, uri)
58
+ %(<image x="#{svg_number(left)}" y="0" width="#{TILE_WIDTH}" height="#{TILE_HEIGHT}" href="#{uri}" />)
59
+ end
60
+ private_class_method :image_element
61
+
62
+ def svg_number(value)
63
+ return value.numerator.to_s if value.denominator == 1
64
+
65
+ whole, remainder = value.numerator.divmod(value.denominator)
66
+ fraction = +""
67
+ until remainder.zero?
68
+ digit, remainder = (remainder * 10).divmod(value.denominator)
69
+ fraction << digit.to_s
70
+ end
71
+ "#{whole}.#{fraction}"
72
+ end
73
+ private_class_method :svg_number
74
+
75
+ def tile_label(tile)
76
+ rank = tile[0]
77
+ suit = tile[1]
78
+ return HONORS.fetch(rank).last if suit == "z"
79
+ return "red 5 of #{SUIT_NAMES.fetch(suit)}" if rank == "0"
80
+
81
+ "#{rank} of #{SUIT_NAMES.fetch(suit)}"
82
+ end
83
+ private_class_method :tile_label
84
+
85
+ def data_uri(tile)
86
+ rank = tile[0]
87
+ suit = tile[1]
88
+ filename = if suit == "z"
89
+ HONORS.fetch(rank).first
90
+ elsif rank == "0"
91
+ "#{ASSET_SUITS.fetch(suit)}5-Dora.svg"
92
+ else
93
+ "#{ASSET_SUITS.fetch(suit)}#{rank}.svg"
94
+ end
95
+ encode_asset(filename)
96
+ end
97
+ private_class_method :data_uri
98
+
99
+ def encode_asset(filename)
100
+ bytes = File.binread(File.join(ASSET_DIRECTORY, filename))
101
+ "data:image/svg+xml;base64,#{[bytes].pack('m0')}"
102
+ end
103
+ private_class_method :encode_asset
104
+ end
105
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Maejima Kenya
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module MahjongRender
7
+ VERSION = "0.1.0"
8
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Maejima Kenya
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require_relative "mahjong_render/version"
7
+ require_relative "mahjong_render/parser"
8
+ require_relative "mahjong_render/renderer"
9
+
10
+ module MahjongRender
11
+ def self.render(notation)
12
+ Renderer.render(Parser.parse_layout(notation))
13
+ end
14
+ end