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.
Files changed (94) hide show
  1. pymdownx_mahjong/__init__.py +23 -0
  2. pymdownx_mahjong/assets/dark/0m.svg +333 -0
  3. pymdownx_mahjong/assets/dark/0p.svg +491 -0
  4. pymdownx_mahjong/assets/dark/0s.svg +678 -0
  5. pymdownx_mahjong/assets/dark/1m.svg +273 -0
  6. pymdownx_mahjong/assets/dark/1p.svg +551 -0
  7. pymdownx_mahjong/assets/dark/1s.svg +764 -0
  8. pymdownx_mahjong/assets/dark/1z.svg +242 -0
  9. pymdownx_mahjong/assets/dark/2m.svg +283 -0
  10. pymdownx_mahjong/assets/dark/2p.svg +396 -0
  11. pymdownx_mahjong/assets/dark/2s.svg +369 -0
  12. pymdownx_mahjong/assets/dark/2z.svg +270 -0
  13. pymdownx_mahjong/assets/dark/3m.svg +289 -0
  14. pymdownx_mahjong/assets/dark/3p.svg +356 -0
  15. pymdownx_mahjong/assets/dark/3s.svg +462 -0
  16. pymdownx_mahjong/assets/dark/3z.svg +248 -0
  17. pymdownx_mahjong/assets/dark/4m.svg +289 -0
  18. pymdownx_mahjong/assets/dark/4p.svg +403 -0
  19. pymdownx_mahjong/assets/dark/4s.svg +570 -0
  20. pymdownx_mahjong/assets/dark/4z.svg +236 -0
  21. pymdownx_mahjong/assets/dark/5m.svg +313 -0
  22. pymdownx_mahjong/assets/dark/5p.svg +450 -0
  23. pymdownx_mahjong/assets/dark/5s.svg +608 -0
  24. pymdownx_mahjong/assets/dark/5z.svg +214 -0
  25. pymdownx_mahjong/assets/dark/6m.svg +294 -0
  26. pymdownx_mahjong/assets/dark/6p.svg +473 -0
  27. pymdownx_mahjong/assets/dark/6s.svg +695 -0
  28. pymdownx_mahjong/assets/dark/6z.svg +324 -0
  29. pymdownx_mahjong/assets/dark/7m.svg +283 -0
  30. pymdownx_mahjong/assets/dark/7p.svg +516 -0
  31. pymdownx_mahjong/assets/dark/7s.svg +628 -0
  32. pymdownx_mahjong/assets/dark/7z.svg +232 -0
  33. pymdownx_mahjong/assets/dark/8m.svg +283 -0
  34. pymdownx_mahjong/assets/dark/8p.svg +566 -0
  35. pymdownx_mahjong/assets/dark/8s.svg +712 -0
  36. pymdownx_mahjong/assets/dark/9m.svg +289 -0
  37. pymdownx_mahjong/assets/dark/9p.svg +609 -0
  38. pymdownx_mahjong/assets/dark/9s.svg +762 -0
  39. pymdownx_mahjong/assets/dark/back.svg +285 -0
  40. pymdownx_mahjong/assets/dark/blank.svg +225 -0
  41. pymdownx_mahjong/assets/dark/front.svg +334 -0
  42. pymdownx_mahjong/assets/light/0m.svg +319 -0
  43. pymdownx_mahjong/assets/light/0p.svg +460 -0
  44. pymdownx_mahjong/assets/light/0s.svg +614 -0
  45. pymdownx_mahjong/assets/light/1m.svg +276 -0
  46. pymdownx_mahjong/assets/light/1p.svg +544 -0
  47. pymdownx_mahjong/assets/light/1s.svg +764 -0
  48. pymdownx_mahjong/assets/light/1z.svg +242 -0
  49. pymdownx_mahjong/assets/light/2m.svg +286 -0
  50. pymdownx_mahjong/assets/light/2p.svg +400 -0
  51. pymdownx_mahjong/assets/light/2s.svg +372 -0
  52. pymdownx_mahjong/assets/light/2z.svg +270 -0
  53. pymdownx_mahjong/assets/light/3m.svg +292 -0
  54. pymdownx_mahjong/assets/light/3p.svg +362 -0
  55. pymdownx_mahjong/assets/light/3s.svg +462 -0
  56. pymdownx_mahjong/assets/light/3z.svg +248 -0
  57. pymdownx_mahjong/assets/light/4m.svg +292 -0
  58. pymdownx_mahjong/assets/light/4p.svg +407 -0
  59. pymdownx_mahjong/assets/light/4s.svg +573 -0
  60. pymdownx_mahjong/assets/light/4z.svg +236 -0
  61. pymdownx_mahjong/assets/light/5m.svg +313 -0
  62. pymdownx_mahjong/assets/light/5p.svg +454 -0
  63. pymdownx_mahjong/assets/light/5s.svg +608 -0
  64. pymdownx_mahjong/assets/light/5z.svg +214 -0
  65. pymdownx_mahjong/assets/light/6m.svg +297 -0
  66. pymdownx_mahjong/assets/light/6p.svg +477 -0
  67. pymdownx_mahjong/assets/light/6s.svg +688 -0
  68. pymdownx_mahjong/assets/light/6z.svg +309 -0
  69. pymdownx_mahjong/assets/light/7m.svg +283 -0
  70. pymdownx_mahjong/assets/light/7p.svg +567 -0
  71. pymdownx_mahjong/assets/light/7s.svg +628 -0
  72. pymdownx_mahjong/assets/light/7z.svg +232 -0
  73. pymdownx_mahjong/assets/light/8m.svg +286 -0
  74. pymdownx_mahjong/assets/light/8p.svg +570 -0
  75. pymdownx_mahjong/assets/light/8s.svg +712 -0
  76. pymdownx_mahjong/assets/light/9m.svg +292 -0
  77. pymdownx_mahjong/assets/light/9p.svg +623 -0
  78. pymdownx_mahjong/assets/light/9s.svg +748 -0
  79. pymdownx_mahjong/assets/light/back.svg +285 -0
  80. pymdownx_mahjong/assets/light/blank.svg +224 -0
  81. pymdownx_mahjong/assets/light/front.svg +334 -0
  82. pymdownx_mahjong/css/mahjong.css +416 -0
  83. pymdownx_mahjong/extension.py +206 -0
  84. pymdownx_mahjong/inline.py +78 -0
  85. pymdownx_mahjong/parser.py +363 -0
  86. pymdownx_mahjong/renderer.py +468 -0
  87. pymdownx_mahjong/superfences.py +126 -0
  88. pymdownx_mahjong/tiles.py +85 -0
  89. pymdownx_mahjong/utils.py +90 -0
  90. pymdownx_mahjong-1.0.0.dist-info/METADATA +44 -0
  91. pymdownx_mahjong-1.0.0.dist-info/RECORD +94 -0
  92. pymdownx_mahjong-1.0.0.dist-info/WHEEL +4 -0
  93. pymdownx_mahjong-1.0.0.dist-info/entry_points.txt +2 -0
  94. 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