pymdownx-mahjong 1.0.0__tar.gz → 1.1.0__tar.gz
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-1.0.0 → pymdownx_mahjong-1.1.0}/PKG-INFO +8 -2
- pymdownx_mahjong-1.1.0/README.md +13 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/__init__.py +1 -1
- pymdownx_mahjong-1.1.0/pymdownx_mahjong/assets/README.md +3 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/css/mahjong.css +11 -12
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/inline.py +2 -2
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/parser.py +33 -2
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/renderer.py +39 -29
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/tiles.py +3 -3
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pyproject.toml +1 -1
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/tests/test_parser.py +75 -1
- pymdownx_mahjong-1.1.0/tests/test_renderer.py +230 -0
- pymdownx_mahjong-1.1.0/tests/test_superfences.py +147 -0
- pymdownx_mahjong-1.1.0/tests/test_utils.py +219 -0
- pymdownx_mahjong-1.0.0/README.md +0 -7
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/.gitignore +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/LICENSE +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/0m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/0p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/0s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/1m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/1p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/1s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/1z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/2m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/2p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/2s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/2z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/3m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/3p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/3s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/3z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/4m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/4p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/4s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/4z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/5m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/5p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/5s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/5z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/6m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/6p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/6s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/6z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/7m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/7p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/7s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/7z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/8m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/8p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/8s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/9m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/9p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/9s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/back.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/blank.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/dark/front.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/0m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/0p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/0s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/1m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/1p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/1s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/1z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/2m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/2p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/2s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/2z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/3m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/3p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/3s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/3z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/4m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/4p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/4s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/4z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/5m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/5p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/5s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/5z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/6m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/6p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/6s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/6z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/7m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/7p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/7s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/7z.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/8m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/8p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/8s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/9m.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/9p.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/9s.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/back.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/blank.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/assets/light/front.svg +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/extension.py +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/superfences.py +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/pymdownx_mahjong/utils.py +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/tests/__init__.py +0 -0
- {pymdownx_mahjong-1.0.0 → pymdownx_mahjong-1.1.0}/tests/test_extension.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymdownx-mahjong
|
|
3
|
-
Version: 1.
|
|
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
|
+

|
|
39
|
+
|
|
38
40
|
# PyMdown Mahjong
|
|
39
41
|
|
|
40
|
-
|
|
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
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# PyMdown Mahjong
|
|
4
|
+
|
|
5
|
+
[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).
|
|
6
|
+
|
|
7
|
+
## Documentation
|
|
8
|
+
|
|
9
|
+
Demo and documentation can be found at [https://tylernguyen.github.io/pymdownx-mahjong/](https://tylernguyen.github.io/pymdownx-mahjong/).
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
License is Creative Commons Attribution-NonCommercial 4.0 International. See [LICENSE](https://github.com/tylernguyen/pymdownx-mahjong/blob/main/LICENSE).
|
|
@@ -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: #
|
|
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
|
|
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
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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:
|
|
@@ -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"),
|
|
@@ -239,7 +239,8 @@ class TestHandProperties:
|
|
|
239
239
|
|
|
240
240
|
def test_hand_properties(self):
|
|
241
241
|
"""Test Hand helper properties."""
|
|
242
|
-
|
|
242
|
+
# Using 2222z in kan instead of 1111z to avoid conflict with closed 11z
|
|
243
|
+
hand = parse_hand("123m456p11z (789s<) [2222z]")
|
|
243
244
|
assert hand.meld_count == 2
|
|
244
245
|
# closed: 8, chi: 3, kan: 4 = 15 total tiles
|
|
245
246
|
assert hand.total_tile_count == 15
|
|
@@ -260,3 +261,76 @@ class TestHandProperties:
|
|
|
260
261
|
all_tiles = hand.all_tiles
|
|
261
262
|
assert len(all_tiles) == 14
|
|
262
263
|
assert all_tiles[-1].notation == "2z"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class TestTileCountValidation:
|
|
267
|
+
"""Tests for tile count validation (max 4 of each tile type)."""
|
|
268
|
+
|
|
269
|
+
def test_five_of_same_tile_raises_error(self):
|
|
270
|
+
"""Test that 5 copies of same tile raises ParseError."""
|
|
271
|
+
parser = MahjongParser()
|
|
272
|
+
with pytest.raises(ParseError) as exc_info:
|
|
273
|
+
parser.parse("11111m")
|
|
274
|
+
assert "1m appears 5 times" in str(exc_info.value)
|
|
275
|
+
|
|
276
|
+
def test_six_of_same_tile_raises_error(self):
|
|
277
|
+
"""Test that 6 copies of same tile raises ParseError."""
|
|
278
|
+
parser = MahjongParser()
|
|
279
|
+
with pytest.raises(ParseError) as exc_info:
|
|
280
|
+
parser.parse("111111z")
|
|
281
|
+
assert "1z appears 6 times" in str(exc_info.value)
|
|
282
|
+
|
|
283
|
+
def test_four_of_same_tile_valid(self):
|
|
284
|
+
"""Test that 4 copies of same tile is valid."""
|
|
285
|
+
hand = parse_hand("1111m")
|
|
286
|
+
assert len(hand.closed_tiles) == 4
|
|
287
|
+
|
|
288
|
+
def test_four_with_meld_total_five_raises_error(self):
|
|
289
|
+
"""Test that 4 closed + 1 in meld = 5 raises error."""
|
|
290
|
+
parser = MahjongParser()
|
|
291
|
+
with pytest.raises(ParseError) as exc_info:
|
|
292
|
+
# 1m appears in closed (11m = 2) and meld (111m = 3) = 5 total
|
|
293
|
+
parser.parse("111m (111m<)")
|
|
294
|
+
assert "1m appears 6 times" in str(exc_info.value)
|
|
295
|
+
|
|
296
|
+
def test_red_dora_counted_separately(self):
|
|
297
|
+
"""Test that red 5 (0) and regular 5 are counted separately."""
|
|
298
|
+
# 4 regular 5m + 1 red 5m (0m) should be valid
|
|
299
|
+
# because they are different tile types
|
|
300
|
+
hand = parse_hand("55550m")
|
|
301
|
+
assert len(hand.closed_tiles) == 5
|
|
302
|
+
|
|
303
|
+
def test_five_red_dora_raises_error(self):
|
|
304
|
+
"""Test that 5 red dora of same suit raises error."""
|
|
305
|
+
parser = MahjongParser()
|
|
306
|
+
with pytest.raises(ParseError) as exc_info:
|
|
307
|
+
parser.parse("00000m")
|
|
308
|
+
assert "0m appears 5 times" in str(exc_info.value)
|
|
309
|
+
|
|
310
|
+
def test_honor_tiles_validated(self):
|
|
311
|
+
"""Test that honor tiles are also validated."""
|
|
312
|
+
parser = MahjongParser()
|
|
313
|
+
with pytest.raises(ParseError) as exc_info:
|
|
314
|
+
parser.parse("77777z") # 5 red dragons
|
|
315
|
+
assert "7z appears 5 times" in str(exc_info.value)
|
|
316
|
+
|
|
317
|
+
def test_multiple_violations_reports_all(self):
|
|
318
|
+
"""Test that multiple violations are all reported."""
|
|
319
|
+
parser = MahjongParser()
|
|
320
|
+
with pytest.raises(ParseError) as exc_info:
|
|
321
|
+
parser.parse("11111m22222p") # 5 of two different tiles
|
|
322
|
+
error_msg = str(exc_info.value)
|
|
323
|
+
assert "1m appears 5 times" in error_msg
|
|
324
|
+
assert "2p appears 5 times" in error_msg
|
|
325
|
+
|
|
326
|
+
def test_valid_complete_hand(self):
|
|
327
|
+
"""Test that a normal valid hand passes validation."""
|
|
328
|
+
# Standard 14-tile hand should be valid
|
|
329
|
+
hand = parse_hand("123m456p789s11222z")
|
|
330
|
+
assert hand.total_tile_count == 14
|
|
331
|
+
|
|
332
|
+
def test_valid_hand_with_kan(self):
|
|
333
|
+
"""Test that hand with kan (4 of same) is valid."""
|
|
334
|
+
hand = parse_hand("123m456p [1111z]")
|
|
335
|
+
assert len(hand.melds) == 1
|
|
336
|
+
# 1z appears in kan only = 4 times, valid
|