pymdownx-mahjong 1.0.0__py3-none-any.whl
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.
- pymdownx_mahjong/__init__.py +23 -0
- pymdownx_mahjong/assets/dark/0m.svg +333 -0
- pymdownx_mahjong/assets/dark/0p.svg +491 -0
- pymdownx_mahjong/assets/dark/0s.svg +678 -0
- pymdownx_mahjong/assets/dark/1m.svg +273 -0
- pymdownx_mahjong/assets/dark/1p.svg +551 -0
- pymdownx_mahjong/assets/dark/1s.svg +764 -0
- pymdownx_mahjong/assets/dark/1z.svg +242 -0
- pymdownx_mahjong/assets/dark/2m.svg +283 -0
- pymdownx_mahjong/assets/dark/2p.svg +396 -0
- pymdownx_mahjong/assets/dark/2s.svg +369 -0
- pymdownx_mahjong/assets/dark/2z.svg +270 -0
- pymdownx_mahjong/assets/dark/3m.svg +289 -0
- pymdownx_mahjong/assets/dark/3p.svg +356 -0
- pymdownx_mahjong/assets/dark/3s.svg +462 -0
- pymdownx_mahjong/assets/dark/3z.svg +248 -0
- pymdownx_mahjong/assets/dark/4m.svg +289 -0
- pymdownx_mahjong/assets/dark/4p.svg +403 -0
- pymdownx_mahjong/assets/dark/4s.svg +570 -0
- pymdownx_mahjong/assets/dark/4z.svg +236 -0
- pymdownx_mahjong/assets/dark/5m.svg +313 -0
- pymdownx_mahjong/assets/dark/5p.svg +450 -0
- pymdownx_mahjong/assets/dark/5s.svg +608 -0
- pymdownx_mahjong/assets/dark/5z.svg +214 -0
- pymdownx_mahjong/assets/dark/6m.svg +294 -0
- pymdownx_mahjong/assets/dark/6p.svg +473 -0
- pymdownx_mahjong/assets/dark/6s.svg +695 -0
- pymdownx_mahjong/assets/dark/6z.svg +324 -0
- pymdownx_mahjong/assets/dark/7m.svg +283 -0
- pymdownx_mahjong/assets/dark/7p.svg +516 -0
- pymdownx_mahjong/assets/dark/7s.svg +628 -0
- pymdownx_mahjong/assets/dark/7z.svg +232 -0
- pymdownx_mahjong/assets/dark/8m.svg +283 -0
- pymdownx_mahjong/assets/dark/8p.svg +566 -0
- pymdownx_mahjong/assets/dark/8s.svg +712 -0
- pymdownx_mahjong/assets/dark/9m.svg +289 -0
- pymdownx_mahjong/assets/dark/9p.svg +609 -0
- pymdownx_mahjong/assets/dark/9s.svg +762 -0
- pymdownx_mahjong/assets/dark/back.svg +285 -0
- pymdownx_mahjong/assets/dark/blank.svg +225 -0
- pymdownx_mahjong/assets/dark/front.svg +334 -0
- pymdownx_mahjong/assets/light/0m.svg +319 -0
- pymdownx_mahjong/assets/light/0p.svg +460 -0
- pymdownx_mahjong/assets/light/0s.svg +614 -0
- pymdownx_mahjong/assets/light/1m.svg +276 -0
- pymdownx_mahjong/assets/light/1p.svg +544 -0
- pymdownx_mahjong/assets/light/1s.svg +764 -0
- pymdownx_mahjong/assets/light/1z.svg +242 -0
- pymdownx_mahjong/assets/light/2m.svg +286 -0
- pymdownx_mahjong/assets/light/2p.svg +400 -0
- pymdownx_mahjong/assets/light/2s.svg +372 -0
- pymdownx_mahjong/assets/light/2z.svg +270 -0
- pymdownx_mahjong/assets/light/3m.svg +292 -0
- pymdownx_mahjong/assets/light/3p.svg +362 -0
- pymdownx_mahjong/assets/light/3s.svg +462 -0
- pymdownx_mahjong/assets/light/3z.svg +248 -0
- pymdownx_mahjong/assets/light/4m.svg +292 -0
- pymdownx_mahjong/assets/light/4p.svg +407 -0
- pymdownx_mahjong/assets/light/4s.svg +573 -0
- pymdownx_mahjong/assets/light/4z.svg +236 -0
- pymdownx_mahjong/assets/light/5m.svg +313 -0
- pymdownx_mahjong/assets/light/5p.svg +454 -0
- pymdownx_mahjong/assets/light/5s.svg +608 -0
- pymdownx_mahjong/assets/light/5z.svg +214 -0
- pymdownx_mahjong/assets/light/6m.svg +297 -0
- pymdownx_mahjong/assets/light/6p.svg +477 -0
- pymdownx_mahjong/assets/light/6s.svg +688 -0
- pymdownx_mahjong/assets/light/6z.svg +309 -0
- pymdownx_mahjong/assets/light/7m.svg +283 -0
- pymdownx_mahjong/assets/light/7p.svg +567 -0
- pymdownx_mahjong/assets/light/7s.svg +628 -0
- pymdownx_mahjong/assets/light/7z.svg +232 -0
- pymdownx_mahjong/assets/light/8m.svg +286 -0
- pymdownx_mahjong/assets/light/8p.svg +570 -0
- pymdownx_mahjong/assets/light/8s.svg +712 -0
- pymdownx_mahjong/assets/light/9m.svg +292 -0
- pymdownx_mahjong/assets/light/9p.svg +623 -0
- pymdownx_mahjong/assets/light/9s.svg +748 -0
- pymdownx_mahjong/assets/light/back.svg +285 -0
- pymdownx_mahjong/assets/light/blank.svg +224 -0
- pymdownx_mahjong/assets/light/front.svg +334 -0
- pymdownx_mahjong/css/mahjong.css +416 -0
- pymdownx_mahjong/extension.py +206 -0
- pymdownx_mahjong/inline.py +78 -0
- pymdownx_mahjong/parser.py +363 -0
- pymdownx_mahjong/renderer.py +468 -0
- pymdownx_mahjong/superfences.py +126 -0
- pymdownx_mahjong/tiles.py +85 -0
- pymdownx_mahjong/utils.py +90 -0
- pymdownx_mahjong-1.0.0.dist-info/METADATA +44 -0
- pymdownx_mahjong-1.0.0.dist-info/RECORD +94 -0
- pymdownx_mahjong-1.0.0.dist-info/WHEEL +4 -0
- pymdownx_mahjong-1.0.0.dist-info/entry_points.txt +2 -0
- pymdownx_mahjong-1.0.0.dist-info/licenses/LICENSE +352 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Renderer for Mahjong hands"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .parser import Hand, Meld, MeldType, Tile
|
|
11
|
+
from .tiles import TileInfo, get_special_tile
|
|
12
|
+
|
|
13
|
+
# Pre-compiled regex patterns for SVG processing
|
|
14
|
+
_RE_XML_DECL = re.compile(r"<\?xml[^?]*\?>")
|
|
15
|
+
_RE_SODIPODI_SELF = re.compile(r"<sodipodi:namedview[^>]*/>")
|
|
16
|
+
_RE_SODIPODI_FULL = re.compile(r"<sodipodi:namedview[^>]*>.*?</sodipodi:namedview>", re.DOTALL)
|
|
17
|
+
_RE_METADATA = re.compile(r"<metadata[^>]*>.*?</metadata>", re.DOTALL)
|
|
18
|
+
_RE_WIDTH = re.compile(r'width="[^"]*"')
|
|
19
|
+
_RE_HEIGHT = re.compile(r'height="[^"]*"')
|
|
20
|
+
# Pattern to find IDs in SVGs
|
|
21
|
+
_RE_ID = re.compile(r'id="([^"]+)"')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MahjongRenderer:
|
|
25
|
+
"""Renders mahjong hands as HTML with inline SVG tiles.
|
|
26
|
+
|
|
27
|
+
Configuration options:
|
|
28
|
+
theme: 'light', 'dark', or 'auto'
|
|
29
|
+
css_class: CSS class for the container
|
|
30
|
+
show_labels: Whether to show tile names as titles
|
|
31
|
+
inline_svg: Whether to inline SVG or use img tags
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
DEFAULT_TILE_WIDTH = 45
|
|
35
|
+
DEFAULT_TILE_HEIGHT = 60
|
|
36
|
+
DEFAULT_TILE_GAP = 2
|
|
37
|
+
DEFAULT_MELD_GAP = 12
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
theme: str = "light",
|
|
42
|
+
css_class: str = "mahjong-hand",
|
|
43
|
+
show_labels: bool = True,
|
|
44
|
+
inline_svg: bool = True,
|
|
45
|
+
assets_path: str | Path | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize the renderer.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
theme: Color theme ('light', 'dark', or 'auto')
|
|
51
|
+
css_class: CSS class for the container element
|
|
52
|
+
show_labels: Show tile names as title attributes
|
|
53
|
+
inline_svg: Inline SVG content vs img tags
|
|
54
|
+
assets_path: Custom path to SVG assets
|
|
55
|
+
"""
|
|
56
|
+
self.theme = theme
|
|
57
|
+
self.tile_width = self.DEFAULT_TILE_WIDTH
|
|
58
|
+
self.tile_height = self.DEFAULT_TILE_HEIGHT
|
|
59
|
+
self.tile_gap = self.DEFAULT_TILE_GAP
|
|
60
|
+
self.meld_gap = self.DEFAULT_MELD_GAP
|
|
61
|
+
self.css_class = css_class
|
|
62
|
+
self.show_labels = show_labels
|
|
63
|
+
self.inline_svg = inline_svg
|
|
64
|
+
self.assets_path = Path(assets_path) if assets_path else None
|
|
65
|
+
self._svg_cache: dict[tuple[str, str], str] = {}
|
|
66
|
+
self._svg_id_counter = 0
|
|
67
|
+
|
|
68
|
+
def render(
|
|
69
|
+
self,
|
|
70
|
+
hand: Hand,
|
|
71
|
+
title: str | None = None,
|
|
72
|
+
notation: str | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Render a hand as HTML.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
hand: Parsed Hand object
|
|
78
|
+
title: Optional caption/title for the hand
|
|
79
|
+
notation: Original notation string for data attribute
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
HTML string with rendered tiles
|
|
83
|
+
"""
|
|
84
|
+
parts: list[str] = []
|
|
85
|
+
|
|
86
|
+
# Container opening
|
|
87
|
+
data_attr = f' data-notation="{self._escape_html(notation)}"' if notation else ""
|
|
88
|
+
parts.append(f'<figure class="{self.css_class}"{data_attr}>')
|
|
89
|
+
|
|
90
|
+
# Dora indicators section (above hand, left-aligned, on same line)
|
|
91
|
+
if hand.dora_indicators or hand.uradora_indicators:
|
|
92
|
+
parts.append('<div class="mahjong-dora-row">')
|
|
93
|
+
|
|
94
|
+
if hand.dora_indicators:
|
|
95
|
+
parts.append('<div class="mahjong-dora">')
|
|
96
|
+
parts.append('<span class="mahjong-dora-label">Dora:</span>')
|
|
97
|
+
parts.append('<span class="mahjong-dora-tiles">')
|
|
98
|
+
for tile in hand.dora_indicators:
|
|
99
|
+
parts.append(self._render_tile(tile))
|
|
100
|
+
parts.append("</span>")
|
|
101
|
+
parts.append("</div>")
|
|
102
|
+
|
|
103
|
+
if hand.uradora_indicators:
|
|
104
|
+
parts.append('<div class="mahjong-dora mahjong-uradora">')
|
|
105
|
+
parts.append('<span class="mahjong-dora-label">Uradora:</span>')
|
|
106
|
+
parts.append('<span class="mahjong-dora-tiles">')
|
|
107
|
+
for tile in hand.uradora_indicators:
|
|
108
|
+
parts.append(self._render_tile(tile))
|
|
109
|
+
parts.append("</span>")
|
|
110
|
+
parts.append("</div>")
|
|
111
|
+
|
|
112
|
+
parts.append("</div>")
|
|
113
|
+
|
|
114
|
+
parts.append('<div class="mahjong-hand-row">')
|
|
115
|
+
|
|
116
|
+
# Left section: closed tiles
|
|
117
|
+
parts.append('<div class="mahjong-hand-left">')
|
|
118
|
+
parts.append('<div class="mahjong-tiles">')
|
|
119
|
+
|
|
120
|
+
# Render all closed tiles
|
|
121
|
+
for tile in hand.closed_tiles:
|
|
122
|
+
parts.append(self._render_tile(tile))
|
|
123
|
+
|
|
124
|
+
parts.append("</div>")
|
|
125
|
+
parts.append("</div>")
|
|
126
|
+
|
|
127
|
+
# Draw tile section (only if explicitly specified)
|
|
128
|
+
if hand.draw_tile:
|
|
129
|
+
parts.append('<div class="mahjong-hand-draw">')
|
|
130
|
+
parts.append('<div class="mahjong-tiles">')
|
|
131
|
+
parts.append(self._render_tile(hand.draw_tile))
|
|
132
|
+
parts.append("</div>")
|
|
133
|
+
parts.append("</div>")
|
|
134
|
+
|
|
135
|
+
# Melds section (after draw tile)
|
|
136
|
+
if hand.melds:
|
|
137
|
+
parts.append('<div class="mahjong-hand-melds">')
|
|
138
|
+
parts.append('<div class="mahjong-tiles">')
|
|
139
|
+
for meld in hand.melds:
|
|
140
|
+
parts.append(self._render_meld(meld))
|
|
141
|
+
parts.append("</div>")
|
|
142
|
+
parts.append("</div>")
|
|
143
|
+
|
|
144
|
+
parts.append("</div>")
|
|
145
|
+
|
|
146
|
+
# Caption
|
|
147
|
+
if title:
|
|
148
|
+
parts.append(f'<figcaption class="mahjong-caption">{self._escape_html(title)}</figcaption>')
|
|
149
|
+
|
|
150
|
+
parts.append("</figure>")
|
|
151
|
+
|
|
152
|
+
return "".join(parts)
|
|
153
|
+
|
|
154
|
+
def render_tiles(self, tiles: list[Tile]) -> str:
|
|
155
|
+
"""Render a simple list of tiles.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
tiles: List of Tile objects
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
HTML string with rendered tiles
|
|
162
|
+
"""
|
|
163
|
+
parts = [f'<span class="{self.css_class}">']
|
|
164
|
+
for tile in tiles:
|
|
165
|
+
parts.append(self._render_tile(tile))
|
|
166
|
+
parts.append("</span>")
|
|
167
|
+
return "".join(parts)
|
|
168
|
+
|
|
169
|
+
def _render_tile(self, tile: Tile) -> str:
|
|
170
|
+
"""Render a single tile.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
tile: Tile to render
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
HTML string for the tile
|
|
177
|
+
"""
|
|
178
|
+
info = tile.info
|
|
179
|
+
if not info:
|
|
180
|
+
return f'<span class="mahjong-tile mahjong-tile-unknown" data-tile="{tile.notation}">?</span>'
|
|
181
|
+
|
|
182
|
+
classes = ["mahjong-tile"]
|
|
183
|
+
if tile.is_rotated:
|
|
184
|
+
classes.append("mahjong-tile-rotated")
|
|
185
|
+
if tile.is_added:
|
|
186
|
+
classes.append("mahjong-tile-added")
|
|
187
|
+
|
|
188
|
+
class_str = " ".join(classes)
|
|
189
|
+
title_attr = f' title="{info.display_name}"' if self.show_labels else ""
|
|
190
|
+
|
|
191
|
+
if self.inline_svg:
|
|
192
|
+
svg_content = self._get_themed_svg_content(info)
|
|
193
|
+
return f'<span class="{class_str}" data-tile="{tile.notation}"{title_attr}>{svg_content}</span>'
|
|
194
|
+
else:
|
|
195
|
+
asset_path = self._get_asset_url(info)
|
|
196
|
+
return (
|
|
197
|
+
f'<span class="{class_str}" data-tile="{tile.notation}"{title_attr}>'
|
|
198
|
+
f'<img src="{asset_path}" alt="{info.display_name}" '
|
|
199
|
+
f'width="{self.tile_width}" height="{self.tile_height}">'
|
|
200
|
+
f"</span>"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _render_meld(self, meld: Meld) -> str:
|
|
204
|
+
"""Render a meld (called tile group).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
meld: Meld to render
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
HTML string for the meld
|
|
211
|
+
"""
|
|
212
|
+
classes = ["mahjong-meld"]
|
|
213
|
+
|
|
214
|
+
if meld.is_open:
|
|
215
|
+
classes.append("mahjong-meld-open")
|
|
216
|
+
else:
|
|
217
|
+
classes.append("mahjong-meld-closed")
|
|
218
|
+
|
|
219
|
+
class_str = " ".join(classes)
|
|
220
|
+
parts = [f'<span class="{class_str}">']
|
|
221
|
+
|
|
222
|
+
if meld.meld_type == MeldType.KAN_ADDED:
|
|
223
|
+
# Added kan (shouminkan): stacked pair position depends on source
|
|
224
|
+
# Find which tile (0, 1, or 2) is rotated to determine stack position
|
|
225
|
+
if meld.tiles[0].is_rotated:
|
|
226
|
+
# Stack on left: [stack], upright, upright
|
|
227
|
+
parts.append('<span class="mahjong-tile-stack">')
|
|
228
|
+
parts.append(self._render_tile(meld.tiles[0]))
|
|
229
|
+
parts.append(self._render_tile(meld.tiles[3]))
|
|
230
|
+
parts.append("</span>")
|
|
231
|
+
parts.append(self._render_tile(meld.tiles[1]))
|
|
232
|
+
parts.append(self._render_tile(meld.tiles[2]))
|
|
233
|
+
elif meld.tiles[2].is_rotated:
|
|
234
|
+
# Stack on right: upright, upright, [stack]
|
|
235
|
+
parts.append(self._render_tile(meld.tiles[0]))
|
|
236
|
+
parts.append(self._render_tile(meld.tiles[1]))
|
|
237
|
+
parts.append('<span class="mahjong-tile-stack">')
|
|
238
|
+
parts.append(self._render_tile(meld.tiles[2]))
|
|
239
|
+
parts.append(self._render_tile(meld.tiles[3]))
|
|
240
|
+
parts.append("</span>")
|
|
241
|
+
else:
|
|
242
|
+
# Stack in middle (default): upright, [stack], upright
|
|
243
|
+
parts.append(self._render_tile(meld.tiles[0]))
|
|
244
|
+
parts.append('<span class="mahjong-tile-stack">')
|
|
245
|
+
parts.append(self._render_tile(meld.tiles[1]))
|
|
246
|
+
parts.append(self._render_tile(meld.tiles[3]))
|
|
247
|
+
parts.append("</span>")
|
|
248
|
+
parts.append(self._render_tile(meld.tiles[2]))
|
|
249
|
+
else:
|
|
250
|
+
for i, tile in enumerate(meld.tiles):
|
|
251
|
+
# For closed kan, show back tiles for middle two
|
|
252
|
+
if meld.meld_type == MeldType.KAN_CLOSED and i in (1, 2):
|
|
253
|
+
parts.append(self._render_back_tile())
|
|
254
|
+
else:
|
|
255
|
+
parts.append(self._render_tile(tile))
|
|
256
|
+
|
|
257
|
+
parts.append("</span>")
|
|
258
|
+
return "".join(parts)
|
|
259
|
+
|
|
260
|
+
def _render_back_tile(self) -> str:
|
|
261
|
+
"""Render a face-down tile.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
HTML string for a back tile
|
|
265
|
+
"""
|
|
266
|
+
info = get_special_tile("back")
|
|
267
|
+
if not info:
|
|
268
|
+
return '<span class="mahjong-tile mahjong-tile-back">?</span>'
|
|
269
|
+
|
|
270
|
+
if self.inline_svg:
|
|
271
|
+
svg_content = self._get_themed_svg_content(info)
|
|
272
|
+
return f'<span class="mahjong-tile mahjong-tile-back">{svg_content}</span>'
|
|
273
|
+
else:
|
|
274
|
+
asset_path = self._get_asset_url(info)
|
|
275
|
+
return (
|
|
276
|
+
f'<span class="mahjong-tile mahjong-tile-back">'
|
|
277
|
+
f'<img src="{asset_path}" alt="Face Down" '
|
|
278
|
+
f'width="{self.tile_width}" height="{self.tile_height}">'
|
|
279
|
+
f"</span>"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _get_svg_content(self, info: TileInfo, theme: str | None = None) -> str:
|
|
283
|
+
"""Get the SVG content for a tile, with unique IDs.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
info: Tile information
|
|
287
|
+
theme: Optional theme override ('light' or 'dark')
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
SVG content string with unique IDs
|
|
291
|
+
"""
|
|
292
|
+
theme = theme or (self.theme if self.theme != "auto" else "light")
|
|
293
|
+
cache_key = (theme, info.asset_name)
|
|
294
|
+
|
|
295
|
+
# Cache raw loaded SVG content (before ID uniquification)
|
|
296
|
+
if cache_key not in self._svg_cache:
|
|
297
|
+
svg_content = self._load_svg(info, theme)
|
|
298
|
+
# Process but don't add unique IDs yet - cache the base processed version
|
|
299
|
+
svg_content = self._process_svg(svg_content, unique_prefix=None)
|
|
300
|
+
self._svg_cache[cache_key] = svg_content
|
|
301
|
+
|
|
302
|
+
# Get cached SVG and make IDs unique for this instance
|
|
303
|
+
svg_content = self._svg_cache[cache_key]
|
|
304
|
+
self._svg_id_counter += 1
|
|
305
|
+
return self._make_ids_unique(svg_content, f"mj{self._svg_id_counter}_")
|
|
306
|
+
|
|
307
|
+
def _get_themed_svg_content(self, info: TileInfo) -> str:
|
|
308
|
+
"""Get SVG content that respects theme switching.
|
|
309
|
+
|
|
310
|
+
For 'auto' theme, embeds both light and dark SVGs with CSS to toggle.
|
|
311
|
+
For specific themes, returns a single SVG.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
info: Tile information
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
SVG content string (possibly wrapped in theme-aware containers)
|
|
318
|
+
"""
|
|
319
|
+
if self.theme == "auto":
|
|
320
|
+
# Load both light and dark SVGs
|
|
321
|
+
light_svg = self._get_svg_content(info, "light")
|
|
322
|
+
dark_svg = self._get_svg_content(info, "dark")
|
|
323
|
+
|
|
324
|
+
# Wrap each in a container with appropriate display logic
|
|
325
|
+
# CSS will handle the display switching based on theme
|
|
326
|
+
return (
|
|
327
|
+
f'<span class="mahjong-tile-light">{light_svg}</span><span class="mahjong-tile-dark">{dark_svg}</span>'
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
# Single theme - return appropriate SVG
|
|
331
|
+
return self._get_svg_content(info)
|
|
332
|
+
|
|
333
|
+
def _load_svg(self, info: TileInfo, theme: str | None = None) -> str:
|
|
334
|
+
"""Load SVG content from file.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
info: Tile information
|
|
338
|
+
theme: Theme to load ('light' or 'dark')
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Raw SVG content
|
|
342
|
+
"""
|
|
343
|
+
theme = theme or (self.theme if self.theme != "auto" else "light")
|
|
344
|
+
|
|
345
|
+
# Try custom assets path first
|
|
346
|
+
if self.assets_path:
|
|
347
|
+
svg_path = self.assets_path / theme / f"{info.asset_name}.svg"
|
|
348
|
+
if svg_path.exists():
|
|
349
|
+
return svg_path.read_text(encoding="utf-8")
|
|
350
|
+
|
|
351
|
+
# Fall back to package assets
|
|
352
|
+
try:
|
|
353
|
+
assets = importlib.resources.files("pymdownx_mahjong") / "assets" / theme
|
|
354
|
+
svg_file = assets / f"{info.asset_name}.svg"
|
|
355
|
+
return svg_file.read_text(encoding="utf-8")
|
|
356
|
+
except (FileNotFoundError, TypeError):
|
|
357
|
+
# Return a placeholder SVG
|
|
358
|
+
return self._placeholder_svg(info)
|
|
359
|
+
|
|
360
|
+
def _process_svg(self, svg_content: str, unique_prefix: str | None = None) -> str:
|
|
361
|
+
"""Process SVG content for inline use.
|
|
362
|
+
|
|
363
|
+
- Removes XML declaration
|
|
364
|
+
- Removes unnecessary metadata
|
|
365
|
+
- Adds sizing attributes
|
|
366
|
+
- Makes IDs unique to avoid conflicts when multiple SVGs are on the same page
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
svg_content: Raw SVG content
|
|
370
|
+
unique_prefix: Optional prefix to make IDs unique
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Processed SVG content
|
|
374
|
+
"""
|
|
375
|
+
# Remove XML declaration
|
|
376
|
+
svg_content = _RE_XML_DECL.sub("", svg_content)
|
|
377
|
+
|
|
378
|
+
# Remove Inkscape/Sodipodi metadata namespaces from content
|
|
379
|
+
svg_content = _RE_SODIPODI_SELF.sub("", svg_content)
|
|
380
|
+
svg_content = _RE_SODIPODI_FULL.sub("", svg_content)
|
|
381
|
+
svg_content = _RE_METADATA.sub("", svg_content)
|
|
382
|
+
|
|
383
|
+
# Update width/height to our tile size while preserving viewBox
|
|
384
|
+
svg_content = _RE_WIDTH.sub(f'width="{self.tile_width}"', svg_content, count=1)
|
|
385
|
+
svg_content = _RE_HEIGHT.sub(f'height="{self.tile_height}"', svg_content, count=1)
|
|
386
|
+
|
|
387
|
+
# Make IDs unique if prefix is provided
|
|
388
|
+
if unique_prefix:
|
|
389
|
+
svg_content = self._make_ids_unique(svg_content, unique_prefix)
|
|
390
|
+
|
|
391
|
+
return svg_content.strip()
|
|
392
|
+
|
|
393
|
+
def _make_ids_unique(self, svg_content: str, prefix: str) -> str:
|
|
394
|
+
"""Make all IDs in an SVG unique by adding a prefix.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
svg_content: SVG content
|
|
398
|
+
prefix: Prefix to add to all IDs
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
SVG content with unique IDs
|
|
402
|
+
"""
|
|
403
|
+
# Find all IDs in the SVG
|
|
404
|
+
ids = set(_RE_ID.findall(svg_content))
|
|
405
|
+
|
|
406
|
+
# Replace each ID and its references
|
|
407
|
+
for old_id in ids:
|
|
408
|
+
new_id = f"{prefix}{old_id}"
|
|
409
|
+
# Replace id="old_id"
|
|
410
|
+
svg_content = svg_content.replace(f'id="{old_id}"', f'id="{new_id}"')
|
|
411
|
+
# Replace href="#old_id" and xlink:href="#old_id"
|
|
412
|
+
svg_content = svg_content.replace(f'href="#{old_id}"', f'href="#{new_id}"')
|
|
413
|
+
# Replace url(#old_id)
|
|
414
|
+
svg_content = svg_content.replace(f"url(#{old_id})", f"url(#{new_id})")
|
|
415
|
+
|
|
416
|
+
return svg_content
|
|
417
|
+
|
|
418
|
+
def _placeholder_svg(self, info: TileInfo) -> str:
|
|
419
|
+
"""Generate a placeholder SVG for missing assets.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
info: Tile information
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Placeholder SVG content
|
|
426
|
+
"""
|
|
427
|
+
w, h = self.tile_width, self.tile_height
|
|
428
|
+
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 300 400">
|
|
429
|
+
<rect width="300" height="400" fill="#f0f0f0" stroke="#ccc" stroke-width="4" rx="20"/>
|
|
430
|
+
<text x="150" y="220" text-anchor="middle" font-size="48" fill="#999">{info.display_name}</text>
|
|
431
|
+
</svg>"""
|
|
432
|
+
|
|
433
|
+
def _get_asset_url(self, info: TileInfo) -> str:
|
|
434
|
+
"""Get the URL for a tile asset.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
info: Tile information
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Asset URL string
|
|
441
|
+
"""
|
|
442
|
+
theme = self.theme if self.theme != "auto" else "light"
|
|
443
|
+
return f"assets/{theme}/{info.asset_name}.svg"
|
|
444
|
+
|
|
445
|
+
def _escape_html(self, text: str) -> str:
|
|
446
|
+
"""Escape HTML special characters.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
text: Text to escape
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Escaped text
|
|
453
|
+
"""
|
|
454
|
+
return html.escape(text, quote=True)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def render_hand(hand: Hand, **kwargs) -> str:
|
|
458
|
+
"""Convenience function to render a hand.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
hand: Parsed Hand object
|
|
462
|
+
**kwargs: Renderer options
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
HTML string
|
|
466
|
+
"""
|
|
467
|
+
renderer = MahjongRenderer(**kwargs)
|
|
468
|
+
return renderer.render(hand)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Superfences integration for pymdownx-mahjong.
|
|
2
|
+
|
|
3
|
+
Provides custom fence formatter and validator for use with pymdownx.superfences.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from html import escape
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .parser import MahjongParser, ParseError
|
|
12
|
+
from .renderer import MahjongRenderer
|
|
13
|
+
from .utils import apply_hand_options, parse_block_content
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _SuperfencesState:
|
|
17
|
+
"""Encapsulates global state for superfences integration.
|
|
18
|
+
|
|
19
|
+
Uses lazy initialization to create parser/renderer on first use.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._renderer: MahjongRenderer | None = None
|
|
24
|
+
self._parser: MahjongParser | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def renderer(self) -> MahjongRenderer:
|
|
28
|
+
"""Get or create the renderer instance."""
|
|
29
|
+
if self._renderer is None:
|
|
30
|
+
self._renderer = MahjongRenderer(
|
|
31
|
+
theme="auto",
|
|
32
|
+
show_labels=True,
|
|
33
|
+
inline_svg=True,
|
|
34
|
+
)
|
|
35
|
+
return self._renderer
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def parser(self) -> MahjongParser:
|
|
39
|
+
"""Get or create the parser instance."""
|
|
40
|
+
if self._parser is None:
|
|
41
|
+
self._parser = MahjongParser()
|
|
42
|
+
return self._parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Single state instance
|
|
46
|
+
_state = _SuperfencesState()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def superfences_validator(
|
|
50
|
+
language: str,
|
|
51
|
+
inputs: dict[str, str],
|
|
52
|
+
options: dict[str, Any],
|
|
53
|
+
attrs: dict[str, Any],
|
|
54
|
+
md: Any,
|
|
55
|
+
) -> bool:
|
|
56
|
+
"""Validator for superfences custom fence.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
language: The language specified (should be 'mahjong')
|
|
60
|
+
inputs: Input attributes from the fence
|
|
61
|
+
options: Options dict to populate
|
|
62
|
+
attrs: Attributes dict
|
|
63
|
+
md: Markdown instance
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if this is a valid mahjong fence
|
|
67
|
+
"""
|
|
68
|
+
return language == "mahjong"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def superfences_formatter(
|
|
72
|
+
source: str,
|
|
73
|
+
language: str,
|
|
74
|
+
class_name: str,
|
|
75
|
+
options: dict[str, Any],
|
|
76
|
+
md: Any,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Formatter for superfences custom fence.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
source: The content inside the fence
|
|
83
|
+
language: The language specified
|
|
84
|
+
class_name: CSS class name
|
|
85
|
+
options: Options from the fence
|
|
86
|
+
md: Markdown instance
|
|
87
|
+
**kwargs: Additional arguments
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Rendered HTML string
|
|
91
|
+
"""
|
|
92
|
+
parser = _state.parser
|
|
93
|
+
renderer = _state.renderer
|
|
94
|
+
|
|
95
|
+
# Parse the content
|
|
96
|
+
content = source.strip()
|
|
97
|
+
notation, block_options = parse_block_content(content)
|
|
98
|
+
|
|
99
|
+
if not notation:
|
|
100
|
+
return _error_block("No hand notation provided")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
hand = parser.parse(notation)
|
|
104
|
+
except ParseError as e:
|
|
105
|
+
return _error_block(str(e))
|
|
106
|
+
|
|
107
|
+
apply_hand_options(hand, parser, block_options)
|
|
108
|
+
|
|
109
|
+
# Render the hand
|
|
110
|
+
return renderer.render(
|
|
111
|
+
hand,
|
|
112
|
+
title=block_options.get("title"),
|
|
113
|
+
notation=notation,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _error_block(message: str) -> str:
|
|
118
|
+
"""Generate an error block.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
message: Error message
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
HTML error block
|
|
125
|
+
"""
|
|
126
|
+
return f'<div class="mahjong-error"><strong>Mahjong Error:</strong> {escape(message)}</div>'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tile database and asset mapping for Riichi Mahjong tiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TileInfo(NamedTuple):
|
|
9
|
+
"""Information about a tile type."""
|
|
10
|
+
|
|
11
|
+
asset_name: str
|
|
12
|
+
display_name: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TILE_DATABASE: dict[tuple[str, int], TileInfo] = {
|
|
16
|
+
# Manzu
|
|
17
|
+
("m", 1): TileInfo("1m", "1 Man"),
|
|
18
|
+
("m", 2): TileInfo("2m", "2 Man"),
|
|
19
|
+
("m", 3): TileInfo("3m", "3 Man"),
|
|
20
|
+
("m", 4): TileInfo("4m", "4 Man"),
|
|
21
|
+
("m", 5): TileInfo("5m", "5 Man"),
|
|
22
|
+
("m", 6): TileInfo("6m", "6 Man"),
|
|
23
|
+
("m", 7): TileInfo("7m", "7 Man"),
|
|
24
|
+
("m", 8): TileInfo("8m", "8 Man"),
|
|
25
|
+
("m", 9): TileInfo("9m", "9 Man"),
|
|
26
|
+
("m", 0): TileInfo("0m", "Red 5 Man"),
|
|
27
|
+
# Pinzu
|
|
28
|
+
("p", 1): TileInfo("1p", "1 Pin"),
|
|
29
|
+
("p", 2): TileInfo("2p", "2 Pin"),
|
|
30
|
+
("p", 3): TileInfo("3p", "3 Pin"),
|
|
31
|
+
("p", 4): TileInfo("4p", "4 Pin"),
|
|
32
|
+
("p", 5): TileInfo("5p", "5 Pin"),
|
|
33
|
+
("p", 6): TileInfo("6p", "6 Pin"),
|
|
34
|
+
("p", 7): TileInfo("7p", "7 Pin"),
|
|
35
|
+
("p", 8): TileInfo("8p", "8 Pin"),
|
|
36
|
+
("p", 9): TileInfo("9p", "9 Pin"),
|
|
37
|
+
("p", 0): TileInfo("0p", "Red 5 Pin"),
|
|
38
|
+
# Souzu
|
|
39
|
+
("s", 1): TileInfo("1s", "1 Sou"),
|
|
40
|
+
("s", 2): TileInfo("2s", "2 Sou"),
|
|
41
|
+
("s", 3): TileInfo("3s", "3 Sou"),
|
|
42
|
+
("s", 4): TileInfo("4s", "4 Sou"),
|
|
43
|
+
("s", 5): TileInfo("5s", "5 Sou"),
|
|
44
|
+
("s", 6): TileInfo("6s", "6 Sou"),
|
|
45
|
+
("s", 7): TileInfo("7s", "7 Sou"),
|
|
46
|
+
("s", 8): TileInfo("8s", "8 Sou"),
|
|
47
|
+
("s", 9): TileInfo("9s", "9 Sou"),
|
|
48
|
+
("s", 0): TileInfo("0s", "Red 5 Sou"),
|
|
49
|
+
# Winds
|
|
50
|
+
("z", 1): TileInfo("1z", "East"),
|
|
51
|
+
("z", 2): TileInfo("2z", "South"),
|
|
52
|
+
("z", 3): TileInfo("3z", "West"),
|
|
53
|
+
("z", 4): TileInfo("4z", "North"),
|
|
54
|
+
# Dragons
|
|
55
|
+
("z", 5): TileInfo("5z", "White Dragon"),
|
|
56
|
+
("z", 6): TileInfo("6z", "Green Dragon"),
|
|
57
|
+
("z", 7): TileInfo("7z", "Red Dragon"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
SPECIAL_TILES: dict[str, TileInfo] = {
|
|
61
|
+
"back": TileInfo("back", "Face Down"),
|
|
62
|
+
"blank": TileInfo("blank", "Blank"),
|
|
63
|
+
"front": TileInfo("front", "Front"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_tile_info(suit: str, number: int) -> TileInfo | None:
|
|
68
|
+
"""Get tile information for a given suit and number."""
|
|
69
|
+
return TILE_DATABASE.get((suit, number))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_special_tile(name: str) -> TileInfo | None:
|
|
73
|
+
"""Get special tile information."""
|
|
74
|
+
return SPECIAL_TILES.get(name.lower())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_valid_tile(suit: str, number: int) -> bool:
|
|
78
|
+
"""Check if a tile notation is valid."""
|
|
79
|
+
if suit not in ("m", "p", "s", "z"):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
if suit == "z":
|
|
83
|
+
return 1 <= number <= 7
|
|
84
|
+
else:
|
|
85
|
+
return 0 <= number <= 9
|