pymdownx-mahjong 1.0.0__py3-none-any.whl → 1.1.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.
@@ -20,4 +20,4 @@ __all__ = [
20
20
  "superfences_validator",
21
21
  ]
22
22
 
23
- __version__ = "1.0.0"
23
+ __version__ = "1.1.0"
@@ -0,0 +1,3 @@
1
+ Original assets by https://github.com/FluffyStuff/riichi-mahjong-tiles, available in the public domain.
2
+
3
+ Some assets have been recolored, tweaks, minified, or otherwise modified.
@@ -23,7 +23,7 @@
23
23
  --mahjong-error-border: #dc2626;
24
24
  --mahjong-error-color: #fca5a5;
25
25
  --mahjong-tile-bg: #1e1e1e;
26
- --mahjong-tile-border: #9a9a8a;
26
+ --mahjong-tile-border: #47473e;
27
27
  --mahjong-tile-shadow: rgba(0, 0, 0, 0.3);
28
28
  }
29
29
 
@@ -48,7 +48,7 @@
48
48
  display: contents;
49
49
  }
50
50
 
51
- /* Main container */
51
+ /* NOTE Main container */
52
52
  .mahjong-hand {
53
53
  display: block;
54
54
  margin: 1em 0;
@@ -58,24 +58,24 @@
58
58
  border-radius: 4px;
59
59
  }
60
60
 
61
- /* Override MkDocs Material theme figure margins */
61
+ /* NOTE Override MkDocs Material/Zensical theme figure margins */
62
62
  .md-typeset .mahjong-hand {
63
63
  margin-left: 1rem;
64
64
  }
65
65
 
66
- /* Hand row - contains left (tiles/melds), draw, and right (dora) sections */
66
+ /* NOTE Hand row - contains left (tiles/melds), draw, and right (dora) sections */
67
67
  .mahjong-hand-row {
68
68
  display: flex;
69
69
  flex-wrap: nowrap;
70
70
  align-items: flex-end;
71
71
  }
72
72
 
73
- /* Left section - closed tiles and melds */
73
+ /* NOTE Left section - closed tiles and melds */
74
74
  .mahjong-hand-left {
75
75
  flex: 0 0 auto;
76
76
  }
77
77
 
78
- /* Draw tile section - separated from main hand */
78
+ /* NOTE Draw tile section */
79
79
  .mahjong-hand-draw {
80
80
  flex: 0 0 auto;
81
81
  margin-left: calc(var(--mahjong-meld-gap) * 0.75);
@@ -96,13 +96,13 @@
96
96
  pointer-events: none;
97
97
  }
98
98
 
99
- /* Melds section - after draw tile */
99
+ /* NOTE Melds section - after draw tile */
100
100
  .mahjong-hand-melds {
101
101
  flex: 0 0 auto;
102
102
  margin-left: calc(var(--mahjong-meld-gap) * 0.75);
103
103
  }
104
104
 
105
- /* Tiles container - flexbox layout */
105
+ /* NOTE Tiles container - flexbox layout */
106
106
  .mahjong-tiles {
107
107
  display: flex;
108
108
  flex-wrap: wrap;
@@ -110,7 +110,7 @@
110
110
  gap: var(--mahjong-tile-gap);
111
111
  }
112
112
 
113
- /* Individual tile - styled like physical riichi tiles */
113
+ /* NOTE Individual tile - styled like physical riichi tiles */
114
114
  .mahjong-tile {
115
115
  display: inline-flex;
116
116
  align-items: center;
@@ -122,9 +122,6 @@
122
122
  background: var(--mahjong-tile-bg);
123
123
  border: 2px solid var(--mahjong-tile-border);
124
124
  border-radius: 6px;
125
- box-shadow:
126
- 1px 2px 3px var(--mahjong-tile-shadow),
127
- inset 0 1px 0 rgba(255, 255, 255, 0.3);
128
125
  padding: 2px;
129
126
  box-sizing: border-box;
130
127
  position: relative;
@@ -144,6 +141,8 @@
144
141
  /* Rotated tile (called from another player) */
145
142
  .mahjong-tile-rotated {
146
143
  transform: rotate(90deg);
144
+ will-change: transform;
145
+ backface-visibility: hidden;
147
146
  margin-left: calc(var(--mahjong-tile-gap) * 6);
148
147
  margin-right: calc(var(--mahjong-tile-gap) * 6);
149
148
  position: relative;
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, Final
6
6
 
7
7
  from markdown.inlinepatterns import InlineProcessor
8
8
 
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
17
17
  # Pattern matches :123m:, :1z:, :0m: (red dora), etc.
18
18
  # Must be valid MPSZ: one or more groups of digits followed by m/p/s/z
19
19
  # Examples: :1m:, :123p:, :5z:, :0s:, :123m456p:
20
- INLINE_TILE_PATTERN = r":([0-9]+[mpsz])+:"
20
+ INLINE_TILE_PATTERN: Final[str] = r":([0-9]+[mpsz])+:"
21
21
 
22
22
 
23
23
  class MahjongInlineProcessor(InlineProcessor):
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from collections import Counter
6
7
  from dataclasses import dataclass, field
7
8
  from enum import Enum
9
+ from typing import Final, Pattern
8
10
 
9
11
  from .tiles import TileInfo, get_tile_info, is_valid_tile
10
12
 
@@ -127,11 +129,11 @@ class MahjongParser:
127
129
  """
128
130
 
129
131
  # Pattern for a group of numbers followed by a suit
130
- TILE_GROUP_PATTERN = re.compile(r"([0-9]+)([mpsz])")
132
+ TILE_GROUP_PATTERN: Final[Pattern[str]] = re.compile(r"([0-9]+)([mpsz])")
131
133
 
132
134
  # Pattern for melds: (tiles<) or [tiles] with optional source marker inside brackets
133
135
  # For added kan, use + to mark the added tile: (111+1m<)
134
- MELD_PATTERN = re.compile(r"(\[|\()([0-9]+)(\+)?([0-9])?([mpsz])([<^>])?(\]|\))")
136
+ MELD_PATTERN: Final[Pattern[str]] = re.compile(r"(\[|\()([0-9]+)(\+)?([0-9])?([mpsz])([<^>])?(\]|\))")
135
137
 
136
138
  def __init__(self) -> None:
137
139
  self.errors: list[str] = []
@@ -163,6 +165,9 @@ class MahjongParser:
163
165
  # Parse closed tiles
164
166
  hand.closed_tiles = self._parse_tiles(closed_part)
165
167
 
168
+ # Validate tile counts (max 4 of each tile type)
169
+ self._validate_tile_counts(hand)
170
+
166
171
  if self.errors:
167
172
  raise ParseError("; ".join(self.errors))
168
173
 
@@ -336,6 +341,32 @@ class MahjongParser:
336
341
 
337
342
  return numbers[1] == numbers[0] + 1 and numbers[2] == numbers[1] + 1
338
343
 
344
+ def _validate_tile_counts(self, hand: Hand) -> None:
345
+ """Validate that no tile appears more than 4 times.
346
+
347
+ In Mahjong, there are exactly 4 copies of each tile type.
348
+ Having more than 4 of the same tile is invalid.
349
+
350
+ Args:
351
+ hand: The Hand object to validate
352
+ """
353
+ # Count all tiles including melds
354
+ # Note: Red 5 (0) and regular 5 are different tiles, so we count them separately
355
+ all_tiles = list(hand.closed_tiles)
356
+ for meld in hand.melds:
357
+ all_tiles.extend(meld.tiles)
358
+ if hand.draw_tile:
359
+ all_tiles.append(hand.draw_tile)
360
+
361
+ counts = Counter((t.suit, t.number) for t in all_tiles)
362
+
363
+ for (suit, number), count in counts.items():
364
+ if count > 4:
365
+ tile_notation = f"{number}{suit}"
366
+ self.errors.append(
367
+ f"Invalid tile count: {tile_notation} appears {count} times (max 4)"
368
+ )
369
+
339
370
 
340
371
  def parse_hand(notation: str) -> Hand:
341
372
  """Convenience function to parse a hand notation.
@@ -2,23 +2,47 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import functools
5
6
  import html
6
7
  import importlib.resources
7
8
  import re
8
9
  from pathlib import Path
10
+ from typing import Final, Pattern
9
11
 
10
12
  from .parser import Hand, Meld, MeldType, Tile
11
13
  from .tiles import TileInfo, get_special_tile
12
14
 
13
15
  # 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="[^"]*"')
16
+ _RE_XML_DECL: Final[Pattern[str]] = re.compile(r"<\?xml[^?]*\?>")
17
+ _RE_SODIPODI_SELF: Final[Pattern[str]] = re.compile(r"<sodipodi:namedview[^>]*/>")
18
+ _RE_SODIPODI_FULL: Final[Pattern[str]] = re.compile(r"<sodipodi:namedview[^>]*>.*?</sodipodi:namedview>", re.DOTALL)
19
+ _RE_METADATA: Final[Pattern[str]] = re.compile(r"<metadata[^>]*>.*?</metadata>", re.DOTALL)
20
+ _RE_WIDTH: Final[Pattern[str]] = re.compile(r'width="[^"]*"')
21
+ _RE_HEIGHT: Final[Pattern[str]] = re.compile(r'height="[^"]*"')
20
22
  # Pattern to find IDs in SVGs
21
- _RE_ID = re.compile(r'id="([^"]+)"')
23
+ _RE_ID: Final[Pattern[str]] = re.compile(r'id="([^"]+)"')
24
+
25
+
26
+ @functools.lru_cache(maxsize=128)
27
+ def _load_svg_from_package(asset_name: str, theme: str) -> str:
28
+ """Load SVG content from package resources with caching.
29
+
30
+ This is a module-level cached function to avoid repeated file I/O
31
+ for the same tile assets across multiple renderer instances.
32
+
33
+ Args:
34
+ asset_name: Name of the asset (e.g., '1m', 'back')
35
+ theme: Theme to load ('light' or 'dark')
36
+
37
+ Returns:
38
+ Raw SVG content string
39
+
40
+ Raises:
41
+ FileNotFoundError: If the asset doesn't exist
42
+ """
43
+ assets = importlib.resources.files("pymdownx_mahjong") / "assets" / theme
44
+ svg_file = assets / f"{asset_name}.svg"
45
+ return svg_file.read_text(encoding="utf-8")
22
46
 
23
47
 
24
48
  class MahjongRenderer:
@@ -62,7 +86,6 @@ class MahjongRenderer:
62
86
  self.show_labels = show_labels
63
87
  self.inline_svg = inline_svg
64
88
  self.assets_path = Path(assets_path) if assets_path else None
65
- self._svg_cache: dict[tuple[str, str], str] = {}
66
89
  self._svg_id_counter = 0
67
90
 
68
91
  def render(
@@ -290,17 +313,12 @@ class MahjongRenderer:
290
313
  SVG content string with unique IDs
291
314
  """
292
315
  theme = theme or (self.theme if self.theme != "auto" else "light")
293
- cache_key = (theme, info.asset_name)
294
316
 
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
317
+ # Load and process SVG (package assets use module-level LRU cache)
318
+ svg_content = self._load_svg(info, theme)
319
+ svg_content = self._process_svg(svg_content)
301
320
 
302
- # Get cached SVG and make IDs unique for this instance
303
- svg_content = self._svg_cache[cache_key]
321
+ # Make IDs unique for this instance
304
322
  self._svg_id_counter += 1
305
323
  return self._make_ids_unique(svg_content, f"mj{self._svg_id_counter}_")
306
324
 
@@ -342,32 +360,28 @@ class MahjongRenderer:
342
360
  """
343
361
  theme = theme or (self.theme if self.theme != "auto" else "light")
344
362
 
345
- # Try custom assets path first
363
+ # Try custom assets path first (not cached since it's user-specific)
346
364
  if self.assets_path:
347
365
  svg_path = self.assets_path / theme / f"{info.asset_name}.svg"
348
366
  if svg_path.exists():
349
367
  return svg_path.read_text(encoding="utf-8")
350
368
 
351
- # Fall back to package assets
369
+ # Fall back to package assets (uses module-level LRU cache)
352
370
  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")
371
+ return _load_svg_from_package(info.asset_name, theme)
356
372
  except (FileNotFoundError, TypeError):
357
373
  # Return a placeholder SVG
358
374
  return self._placeholder_svg(info)
359
375
 
360
- def _process_svg(self, svg_content: str, unique_prefix: str | None = None) -> str:
376
+ def _process_svg(self, svg_content: str) -> str:
361
377
  """Process SVG content for inline use.
362
378
 
363
379
  - Removes XML declaration
364
380
  - Removes unnecessary metadata
365
381
  - Adds sizing attributes
366
- - Makes IDs unique to avoid conflicts when multiple SVGs are on the same page
367
382
 
368
383
  Args:
369
384
  svg_content: Raw SVG content
370
- unique_prefix: Optional prefix to make IDs unique
371
385
 
372
386
  Returns:
373
387
  Processed SVG content
@@ -384,10 +398,6 @@ class MahjongRenderer:
384
398
  svg_content = _RE_WIDTH.sub(f'width="{self.tile_width}"', svg_content, count=1)
385
399
  svg_content = _RE_HEIGHT.sub(f'height="{self.tile_height}"', svg_content, count=1)
386
400
 
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
401
  return svg_content.strip()
392
402
 
393
403
  def _make_ids_unique(self, svg_content: str, prefix: str) -> str:
pymdownx_mahjong/tiles.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import NamedTuple
5
+ from typing import Final, NamedTuple
6
6
 
7
7
 
8
8
  class TileInfo(NamedTuple):
@@ -12,7 +12,7 @@ class TileInfo(NamedTuple):
12
12
  display_name: str
13
13
 
14
14
 
15
- TILE_DATABASE: dict[tuple[str, int], TileInfo] = {
15
+ TILE_DATABASE: Final[dict[tuple[str, int], TileInfo]] = {
16
16
  # Manzu
17
17
  ("m", 1): TileInfo("1m", "1 Man"),
18
18
  ("m", 2): TileInfo("2m", "2 Man"),
@@ -57,7 +57,7 @@ TILE_DATABASE: dict[tuple[str, int], TileInfo] = {
57
57
  ("z", 7): TileInfo("7z", "Red Dragon"),
58
58
  }
59
59
 
60
- SPECIAL_TILES: dict[str, TileInfo] = {
60
+ SPECIAL_TILES: Final[dict[str, TileInfo]] = {
61
61
  "back": TileInfo("back", "Face Down"),
62
62
  "blank": TileInfo("blank", "Blank"),
63
63
  "front": TileInfo("front", "Front"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdownx-mahjong
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Python Markdown extension to render and stylize Mahjong tiles.
5
5
  Project-URL: Homepage, https://github.com/tylernguyen/pymdownx-mahjong
6
6
  Project-URL: Documentation, https://github.com/tylernguyen/pymdownx-mahjong
@@ -35,9 +35,15 @@ Requires-Dist: mkdocs-material>=9.0; extra == 'mkdocs'
35
35
  Requires-Dist: mkdocs>=1.4; extra == 'mkdocs'
36
36
  Description-Content-Type: text/markdown
37
37
 
38
+ ![PyPI - Version](https://img.shields.io/pypi/v/pymdownx-mahjong)
39
+
38
40
  # PyMdown Mahjong
39
41
 
40
- Extensions for [Python Markdown](https://python-markdown.github.io) that aids writing Mahjong content
42
+ [Python Markdown](https://python-markdown.github.io) extension to render and stylize Mahjong tiles. Designed for use with [MkDocs](https://github.com/mkdocs/mkdocs) and [Zensical](https://github.com/zensical/zensical).
43
+
44
+ ## Documentation
45
+
46
+ Demo and documentation can be found at [https://tylernguyen.github.io/pymdownx-mahjong/](https://tylernguyen.github.io/pymdownx-mahjong/).
41
47
 
42
48
  ## License
43
49
 
@@ -1,11 +1,12 @@
1
- pymdownx_mahjong/__init__.py,sha256=3w2615_eJnY6sN3k_hBgEJue3dRFY13KhVZmHBHLPNk,644
1
+ pymdownx_mahjong/__init__.py,sha256=mYyokR5i1zy1YoN3kFGPfL-VwlQgMH0yPyS12AT-Ki4,644
2
2
  pymdownx_mahjong/extension.py,sha256=aCRMkMQ8c0eenpcyR5eyLdN_OND9JpYFeRxodOeJyGo,6688
3
- pymdownx_mahjong/inline.py,sha256=2yQ9mM1qLFIin_2UBI_Kml8DOAN6J2jsgGri1ImHc4g,2429
4
- pymdownx_mahjong/parser.py,sha256=gw4_ZQUHviWHAr0DmnTVFoKUID_dElCqxpMgl7m9cMA,11217
5
- pymdownx_mahjong/renderer.py,sha256=xFwzjVQbqFrMnXmaKMwa3-9tXbTKgXmpkIuEK3VMhmg,16814
3
+ pymdownx_mahjong/inline.py,sha256=QxnOmiZ-6cKjrLWsmNJwCCK3FVAjPvTgv97hfNnnnWg,2448
4
+ pymdownx_mahjong/parser.py,sha256=I8wDlZO6oPMhTCqC0NFmsZJOQZCCCXyc78mk3vmmwcQ,12396
5
+ pymdownx_mahjong/renderer.py,sha256=Lk52VCrULtwq9dGWr6J-E1_ZJAlO93nJDVSC0I_mhYI,16947
6
6
  pymdownx_mahjong/superfences.py,sha256=LXzG6P2EB8dU9A_5ncFbTJEA4Jy6RLwjjYXZS2wSjhs,3099
7
- pymdownx_mahjong/tiles.py,sha256=BD4XlnOfH0Z3_xMhyLDAvxi6tPzMf5j4okPSsw1nzh4,2562
7
+ pymdownx_mahjong/tiles.py,sha256=ltA1xJeS9fVcZQyUwaIjyl1mHEZ0Kr_QUKy1jhCapDM,2583
8
8
  pymdownx_mahjong/utils.py,sha256=aHKX4wDCGM_rvaOwFIbd9qVJHpzzUjXWMviLxdwZrdo,2637
9
+ pymdownx_mahjong/assets/README.md,sha256=HDNhZZlt8ffTER1fVNcTayUnFqOmB3tXoocT8iZgCz0,179
9
10
  pymdownx_mahjong/assets/dark/0m.svg,sha256=wTa3OxyZziCtsk5hqLpLJig-1IbQPo_XrMrox5wZFzU,18773
10
11
  pymdownx_mahjong/assets/dark/0p.svg,sha256=Ge4V_vOYTJtqSOI89YmxP451WsNOL3Pv_A8-apdYrI8,23308
11
12
  pymdownx_mahjong/assets/dark/0s.svg,sha256=mErmM8konoHN5XlNwuffYEJNczrawQo1800kQlAg1VA,32913
@@ -86,9 +87,9 @@ pymdownx_mahjong/assets/light/9s.svg,sha256=WjNpR3IUty3ccnEwq9CpcSbLr3A-ta0yJqWx
86
87
  pymdownx_mahjong/assets/light/back.svg,sha256=HPAJfchslm_1uZQzpY8FUNF2s8hmbARGkhxNMlfnkQk,11245
87
88
  pymdownx_mahjong/assets/light/blank.svg,sha256=uKGioQLfrCZsSbMr66iDXv4w9lKHEqGym1l098-q5DM,8499
88
89
  pymdownx_mahjong/assets/light/front.svg,sha256=63TB5953_-10TDRd7K_kJnHoqt-6wd05MEGV2AcNsks,11906
89
- pymdownx_mahjong/css/mahjong.css,sha256=z-AuTffSmu4_Rrh8VnoYjx8ZHL2DV-8ATbNKdoTg4Bc,9336
90
- pymdownx_mahjong-1.0.0.dist-info/METADATA,sha256=fS6tItQex0QDQakUjZa6n9skBTrhgIR8NTiBQGIOx7c,1871
91
- pymdownx_mahjong-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
92
- pymdownx_mahjong-1.0.0.dist-info/entry_points.txt,sha256=dEty6XpsYCWl45PeRYHiH9ORxwSZA64LQU8gWF1CuyU,72
93
- pymdownx_mahjong-1.0.0.dist-info/licenses/LICENSE,sha256=Mnpx_G3eVz7AX5uTHGPaZWHJYiJYu4Y2_l01PsksHdg,19921
94
- pymdownx_mahjong-1.0.0.dist-info/RECORD,,
90
+ pymdownx_mahjong/css/mahjong.css,sha256=OpO9RR-UPFAbPF6Q8Wuz0Ak_Pg9rOrOsMAtnP0oj5LE,9307
91
+ pymdownx_mahjong-1.1.0.dist-info/METADATA,sha256=8uXHaggPvIhroHgD2VdYNfDiJdtmM99XXAgIGjnlslc,2213
92
+ pymdownx_mahjong-1.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
93
+ pymdownx_mahjong-1.1.0.dist-info/entry_points.txt,sha256=dEty6XpsYCWl45PeRYHiH9ORxwSZA64LQU8gWF1CuyU,72
94
+ pymdownx_mahjong-1.1.0.dist-info/licenses/LICENSE,sha256=Mnpx_G3eVz7AX5uTHGPaZWHJYiJYu4Y2_l01PsksHdg,19921
95
+ pymdownx_mahjong-1.1.0.dist-info/RECORD,,