mkdocs-chess-plugin 0.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.
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2025, Jeff Hlywa (jhlywa@gmail.com)
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
16
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
17
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
23
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,122 @@
1
+ /** Board indices run from a9 (0) to i0 (89), independent of display orientation. */
2
+ export const START = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1';
3
+ export const names = { K:'帅', A:'仕', B:'相', N:'马', R:'车', C:'炮', P:'兵', k:'将', a:'士', b:'象', n:'马', r:'车', c:'炮', p:'卒' };
4
+ export const side = p => p ? (p === p.toUpperCase() ? 'w' : 'b') : null;
5
+ export const opposite = s => s === 'w' ? 'b' : 'w';
6
+ export const coord = i => String.fromCharCode(97 + i % 9) + (9 - Math.floor(i / 9));
7
+ export const index = s => /^[a-i][0-9]$/.test(s) ? (9 - Number(s[1])) * 9 + s.charCodeAt(0) - 97 : -1;
8
+ const palace = (x,y,s) => x >= 3 && x <= 5 && (s === 'w' ? y >= 7 && y <= 9 : y >= 0 && y <= 2);
9
+ const elephants = new Set(['2,0','6,0','0,2','4,2','8,2','2,4','6,4']);
10
+ const advisors = new Set(['3,0','5,0','4,1','3,2','5,2']);
11
+
12
+ export function parseFen(input) {
13
+ const f = input.trim().split(/\s+/);
14
+ if (![2,6].includes(f.length) || !/^[wb]$/.test(f[1])) throw Error('FEN 需包含十行棋盘及行棋方 w(红)或 b(黑)。');
15
+ if (f.length === 6 && (f[2] !== '-' || f[3] !== '-' || !/^\d+$/.test(f[4]) || !/^[1-9]\d*$/.test(f[5]) || !Number.isSafeInteger(+f[4]) || !Number.isSafeInteger(+f[5]))) throw Error('FEN 计数或占位字段无效。');
16
+ const rows = f[0].split('/'), board = [];
17
+ if (rows.length !== 10) throw Error('棋盘必须有十行。');
18
+ for (const row of rows) {
19
+ const cells = [];
20
+ for (let c of row) {
21
+ if (/^[1-9]$/.test(c)) cells.push(...Array(+c).fill(null));
22
+ else {
23
+ c = ({h:'n',H:'N',e:'b',E:'B'})[c] || c;
24
+ if (!names[c]) throw Error('FEN 包含未知棋子。');
25
+ cells.push(c);
26
+ }
27
+ }
28
+ if (cells.length !== 9) throw Error('棋盘每行必须有九个位置。');
29
+ board.push(...cells);
30
+ }
31
+ const counts = {};
32
+ board.forEach((p,i) => {
33
+ if (!p) return;
34
+ counts[p] = (counts[p] || 0) + 1;
35
+ const s = side(p), t = p.toLowerCase(), x = i % 9, y = Math.floor(i/9), homeY = s === 'b' ? y : 9-y;
36
+ if (t === 'k' && !palace(x,y,s)) throw Error('将帅必须位于九宫。');
37
+ if (t === 'a' && !advisors.has(`${x},${homeY}`)) throw Error('仕士位置无效。');
38
+ if (t === 'b' && !elephants.has(`${x},${homeY}`)) throw Error('相象位置无效。');
39
+ if (t === 'p' && (homeY < 3 || (homeY < 5 && x % 2))) throw Error('兵卒位置无效。');
40
+ });
41
+ for (const s of ['w','b']) {
42
+ for (const [type,limit] of Object.entries({k:1,a:2,b:2,n:2,r:2,c:2,p:5})) {
43
+ const key = s === 'w' ? type.toUpperCase() : type;
44
+ if ((counts[key] || 0) > limit || (type === 'k' && counts[key] !== 1)) throw Error('棋子数量无效,双方须各有一枚将帅。');
45
+ }
46
+ }
47
+ const state = {board, turn:f[1], halfmove:+(f[4] || 0), fullmove:+(f[5] || 1)};
48
+ if (inCheck(state, opposite(state.turn))) throw Error('非行棋方处于被将军状态,或将帅照面;请检查行棋方和局面。');
49
+ return state;
50
+ }
51
+ export function fen(state) {
52
+ const rows = [];
53
+ for (let y=0;y<10;y++) {
54
+ let row='', empty=0;
55
+ for (const p of state.board.slice(y*9,y*9+9)) {
56
+ if (!p) empty++;
57
+ else { if (empty) row+=empty; empty=0; row+=p; }
58
+ }
59
+ if (empty) row+=empty;
60
+ rows.push(row);
61
+ }
62
+ return `${rows.join('/')} ${state.turn} - - ${state.halfmove} ${state.fullmove}`;
63
+ }
64
+ export function pseudo(board, from, to) {
65
+ const p=board[from], target=board[to];
66
+ if (!p || from===to || to<0 || to>=90 || side(p)===side(target)) return false;
67
+ const x=from%9,y=Math.floor(from/9),tx=to%9,ty=Math.floor(to/9),dx=tx-x,dy=ty-y,ax=Math.abs(dx),ay=Math.abs(dy),s=side(p);
68
+ const type=p.toLowerCase();
69
+ if (type==='n') return ax===2 && ay===1 ? !board[from+Math.sign(dx)] : ax===1 && ay===2 && !board[from+9*Math.sign(dy)];
70
+ if (type==='b') return ax===2 && ay===2 && (s==='w'?ty>=5:ty<=4) && !board[from+dx/2+9*dy/2];
71
+ if (type==='a') return ax===1 && ay===1 && palace(tx,ty,s);
72
+ if (type==='p') return (dx===0 && dy===(s==='w'?-1:1)) || (ay===0 && ax===1 && (s==='w'?y<=4:y>=5));
73
+ if (type==='k' && ax+ay===1 && palace(tx,ty,s)) return true;
74
+ if (dx!==0 && dy!==0) return false;
75
+ const step=dx===0?9*Math.sign(dy):Math.sign(dx);
76
+ let screens=0;
77
+ for(let i=from+step;i!==to;i+=step) if(board[i]) screens++;
78
+ if(type==='r') return screens===0;
79
+ if(type==='c') return screens===(target?1:0);
80
+ return type==='k' && target?.toLowerCase()==='k' && dx===0 && screens===0;
81
+ }
82
+ export function inCheck(state, who=state.turn) {
83
+ const king=state.board.indexOf(who==='w'?'K':'k');
84
+ return king<0 || state.board.some((p,i)=>side(p)===opposite(who) && pseudo(state.board,i,king));
85
+ }
86
+ export function legal(state, from, to) {
87
+ if (side(state.board[from])!==state.turn || state.board[to]?.toLowerCase()==='k' || !pseudo(state.board,from,to)) return false;
88
+ const board=state.board.slice(); board[to]=board[from]; board[from]=null;
89
+ return !inCheck({...state,board});
90
+ }
91
+ export function moves(state, from=null) {
92
+ const result=[];
93
+ for(let i=0;i<90;i++) if ((from===null || i===from) && side(state.board[i])===state.turn)
94
+ for(let j=0;j<90;j++) if(legal(state,i,j)) result.push(coord(i)+coord(j));
95
+ return result;
96
+ }
97
+ export function play(state, move) {
98
+ if (!/^[a-i][0-9][a-i][0-9]$/.test(move)) throw Error('着法坐标无效。');
99
+ const from=index(move.slice(0,2)),to=index(move.slice(2));
100
+ if (!legal(state,from,to)) throw Error('这步棋不合法,请重新选择。');
101
+ const board=state.board.slice(),p=board[from],capture=!!board[to]; board[to]=p; board[from]=null;
102
+ return {board,turn:opposite(state.turn),halfmove:capture||p.toLowerCase()==='p'?0:state.halfmove+1,fullmove:state.fullmove+(state.turn==='b'?1:0)};
103
+ }
104
+ export function notation(state, move) {
105
+ const from=index(move.slice(0,2)),to=index(move.slice(2)),p=state.board[from];
106
+ if(!p || to<0) return move;
107
+ const red=side(p)==='w',x=from%9,y=Math.floor(from/9),tx=to%9,ty=Math.floor(to/9);
108
+ const number=n=>(red?'〇一二三四五六七八九':'0123456789')[n];
109
+ const file=n=>number(red?9-n:n+1),type=p.toLowerCase();
110
+ let prefix=names[p]+file(x);
111
+ let peers=state.board.map((q,i)=>q===p&&i%9===x?i:-1).filter(i=>i>=0).sort((a,b)=>red?a-b:b-a);
112
+ if (type==='p') {
113
+ const groups=Array.from({length:9},(_,col)=>state.board.map((q,i)=>q===p&&i%9===col?i:-1).filter(i=>i>=0)).filter(g=>g.length>1);
114
+ if(groups.length>1 && peers.length>1) {
115
+ const all=groups.sort((a,b)=>red?b[0]%9-a[0]%9:a[0]%9-b[0]%9).flatMap(g=>g.sort((a,b)=>red?a-b:b-a));
116
+ prefix='一二三四五'[all.indexOf(from)]+names[p];
117
+ } else if(peers.length>1) prefix=(peers.length===2?'前后':peers.length===3?'前中后':'一二三四五')[peers.indexOf(from)]+names[p];
118
+ } else if(peers.length>1 && !['a','b','k'].includes(type)) prefix=(peers[0]===from?'前':'后')+names[p];
119
+ if(ty===y) return prefix+'平'+file(tx);
120
+ const direction=(red?ty<y:ty>y)?'进':'退';
121
+ return prefix+direction+(['n','b','a'].includes(type)?file(tx):number(Math.abs(ty-y)));
122
+ }
@@ -0,0 +1,301 @@
1
+ """Turn FEN-titled admonitions into SVG boards with a no-script fallback."""
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+ from typing import Callable
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from markdown.extensions import Extension
9
+ from markdown.treeprocessors import Treeprocessor
10
+ from mkdocs.exceptions import PluginError
11
+
12
+ from .themes import Geometry, ThemeCatalog
13
+
14
+
15
+ XIANG_NAMES = dict(zip("KABNRCPkabnrcp", "帅仕相马车炮兵将士象马车炮卒"))
16
+ XIANG_ALIASES = str.maketrans("HhEe", "NnBb")
17
+ MAX_COUNTER = str(2**53 - 1)
18
+
19
+ # Original flat silhouettes on a 48 × 48 canvas, shared by both colors.
20
+ CHESS_SHAPES = {
21
+ "k": ("M22 4H26V10H32V14H26V20H22V14H16V10H22Z",
22
+ "M14 33C13 27 10 23 10 20C10 15 18 14 24 22C30 14 38 15 38 20C38 23 35 27 34 33L36 41H12Z"),
23
+ "q": ("M8 16L17 24L24 13L31 24L40 16L34 34L36 41H12L14 34Z",
24
+ "M14 34H34"),
25
+ "r": ("M9 8H16V14H20V8H28V14H32V8H39V20L33 25V34L37 40H11L15 34V25L9 20Z",
26
+ "M15 25H33M15 34H33"),
27
+ "b": ("M24 8C27 12 35 18 35 24C35 29 31 32 27 34H34L37 41H11L14 34H21C17 32 13 29 13 24C13 18 21 12 24 8Z",
28
+ "M26 17L21 24M21 34H27"),
29
+ "n": ("M12 41H36C37 28 34 16 27 10L19 6L18 14L9 25L12 30L22 25C21 29 15 32 12 35Z",
30
+ "M21 17H22"),
31
+ "p": ("M24 8A8 8 0 0 1 28 23C29 28 31 33 36 37V41H12V37C17 33 19 28 20 23A8 8 0 0 1 24 8Z",),
32
+ }
33
+
34
+
35
+ def xiang_piece(node, piece):
36
+ node.set("class", "fen-board-xiang-light" if piece.isupper() else "fen-board-xiang-dark")
37
+ ET.SubElement(node, "circle", {"r": "22", "class": "fen-board-disc"})
38
+ ET.SubElement(node, "circle", {"r": "18.5", "class": "fen-board-ring"})
39
+ ET.SubElement(node, "text", {"y": "1", "class": "fen-board-character"}).text = XIANG_NAMES[piece]
40
+
41
+
42
+ def chess_piece(node, piece):
43
+ color = "light" if piece.isupper() else "dark"
44
+ drawing = ET.SubElement(node, "g", {"class": f"fen-board-chess-{color}", "transform": "translate(-24 -24)"})
45
+ for path in CHESS_SHAPES[piece.lower()]:
46
+ ET.SubElement(drawing, "path", {"d": path})
47
+ if piece.lower() == "q":
48
+ for x, y in ((8, 13), (24, 10), (40, 13)):
49
+ ET.SubElement(drawing, "circle", {"cx": str(x), "cy": str(y), "r": "2.6"})
50
+ if piece.lower() == "b":
51
+ ET.SubElement(drawing, "circle", {"cx": "24", "cy": "6", "r": "2.5"})
52
+
53
+
54
+ def xiang_grid(svg):
55
+ grid = ET.SubElement(svg, "g", {"class": "fen-board-xiang-grid"})
56
+ for y in range(10):
57
+ ET.SubElement(grid, "path", {"d": f"M40 {40 + y * 50} H440"})
58
+ for x in range(9):
59
+ file = 40 + x * 50
60
+ path = f"M{file} 40 V490" if x in (0, 8) else f"M{file} 40 V240 M{file} 290 V490"
61
+ ET.SubElement(grid, "path", {"d": path})
62
+ ET.SubElement(grid, "path", {"d": "M190 40 L290 140 M290 40 L190 140 M190 390 L290 490 M290 390 L190 490"})
63
+ ET.SubElement(grid, "rect", {"x": "34", "y": "34", "width": "412", "height": "462"})
64
+ river = ET.SubElement(svg, "g", {"class": "fen-board-river"})
65
+ for x, label in ((140, "楚 河"), (340, "汉 界")):
66
+ ET.SubElement(river, "text", {"x": str(x), "y": "273"}).text = label
67
+ files = ET.SubElement(svg, "g", {"class": "fen-board-coords"})
68
+ for x, label in enumerate("九八七六五四三二一"):
69
+ file = str(40 + x * 50)
70
+ ET.SubElement(files, "text", {"x": file, "y": "526", "class": "fen-board-xiang-light"}).text = label
71
+ ET.SubElement(files, "text", {"x": file, "y": "15"}).text = str(x + 1)
72
+
73
+
74
+ def chess_grid(svg):
75
+ grid = ET.SubElement(svg, "g")
76
+ for row in range(8):
77
+ for col in range(8):
78
+ color = "dark" if (row + col) % 2 else "light"
79
+ ET.SubElement(grid, "rect", {
80
+ "x": str(24 + col * 54), "y": str(24 + row * 54),
81
+ "width": "54", "height": "54", "class": f"fen-board-tile-{color}",
82
+ })
83
+ labels = ET.SubElement(svg, "g", {"class": "fen-board-coords"})
84
+ for index in range(8):
85
+ center = 51 + index * 54
86
+ ET.SubElement(labels, "text", {"x": str(center), "y": "473"}).text = chr(97 + index)
87
+ ET.SubElement(labels, "text", {"x": "12", "y": str(center + 5)}).text = str(8 - index)
88
+
89
+
90
+ def xiang_fields(castling, en_passant, _turn):
91
+ if (castling, en_passant) != ("-", "-"):
92
+ raise ValueError("中国象棋的两个占位字段应为 - -")
93
+
94
+
95
+ def chess_fields(castling, en_passant, turn):
96
+ if castling != "-" and not re.fullmatch(r"K?Q?k?q?", castling):
97
+ raise ValueError("国际象棋的易位权应为 KQkq 的子集或 -")
98
+ rank = "6" if turn == "w" else "3"
99
+ if en_passant != "-" and not re.fullmatch(f"[a-h]{rank}", en_passant):
100
+ raise ValueError("国际象棋的过路兵目标格与行棋方不符")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class BoardType:
105
+ label: str
106
+ light: str
107
+ columns: int
108
+ rows: int
109
+ origin: int
110
+ step: int
111
+ height: int
112
+ pieces: str
113
+ aliases: dict
114
+ validate_fields: Callable
115
+ draw_grid: Callable
116
+ draw_piece: Callable
117
+
118
+
119
+ BOARD_TYPES = {
120
+ "xiang": BoardType("局面图", "红", 9, 10, 40, 50, 530, "".join(XIANG_NAMES),
121
+ XIANG_ALIASES, xiang_fields, xiang_grid, xiang_piece),
122
+ "chess": BoardType("国际象棋局面", "白", 8, 8, 51, 54, 480, "KQRBNPkqrbnp",
123
+ {}, chess_fields, chess_grid, chess_piece),
124
+ }
125
+
126
+
127
+ def infer_game(value):
128
+ fields = value.split()
129
+ ranks = len(fields[0].split("/")) if fields else 0
130
+ matches = [key for key, style in BOARD_TYPES.items() if style.rows == ranks]
131
+ if len(matches) != 1:
132
+ raise ValueError("无法识别棋盘行数,请使用 chess 或 xiang 指定棋种")
133
+ return matches[0]
134
+
135
+
136
+ def parse_fen(value, game=None):
137
+ """Validate FEN syntax while allowing illustrative, non-playable studies."""
138
+ style = BOARD_TYPES[game or infer_game(value)]
139
+ fields = value.split()
140
+ if len(fields) not in (2, 6) or fields[1] not in ("w", "b"):
141
+ raise ValueError(f"需包含棋盘和行棋方 w({style.light})或 b(黑)")
142
+ if len(fields) == 2:
143
+ fields.extend(["-", "-", "0", "1"])
144
+ style.validate_fields(fields[2], fields[3], fields[1])
145
+ if (
146
+ not re.fullmatch(r"[0-9]+", fields[4])
147
+ or not re.fullmatch(r"[1-9][0-9]*", fields[5])
148
+ or any((len(n), n) > (len(MAX_COUNTER), MAX_COUNTER)
149
+ for n in (value.lstrip("0") for value in fields[4:]))
150
+ ):
151
+ raise ValueError("FEN 计数字段无效")
152
+
153
+ rows = fields[0].translate(style.aliases).split("/")
154
+ if len(rows) != style.rows:
155
+ raise ValueError(f"此棋种的棋盘必须有 {style.rows} 行")
156
+ board = []
157
+ for rank, row in enumerate(rows, 1):
158
+ cells = []
159
+ for char in row:
160
+ if "1" <= char <= str(style.columns):
161
+ cells.extend([None] * int(char))
162
+ elif char in style.pieces:
163
+ cells.append(char)
164
+ else:
165
+ raise ValueError(f"第 {rank} 行包含未知棋子或字符 {char!r}")
166
+ if len(cells) != style.columns:
167
+ raise ValueError(f"第 {rank} 行必须有 {style.columns} 个位置,实际为 {len(cells)} 个")
168
+ board.extend(cells)
169
+ fields[0] = "/".join(rows)
170
+ return board, fields[1], " ".join(fields)
171
+
172
+
173
+ def artwork_image(parent, uri, x, y, width, height):
174
+ return ET.SubElement(parent, "image", {
175
+ "href": f"fen-board-asset:{uri}", "x": f"{x:g}", "y": f"{y:g}",
176
+ "width": f"{width:g}", "height": f"{height:g}",
177
+ })
178
+
179
+
180
+ def king_icons(title, style, pieces):
181
+ for side, king in (("w", "K"), ("b", "k")):
182
+ icon = ET.SubElement(title, "svg", {
183
+ "class": "fen-board-icon", "data-color": side, "viewBox": "-24 -24 48 48",
184
+ "aria-hidden": "true", "focusable": "false", "xmlns": "http://www.w3.org/2000/svg",
185
+ })
186
+ if pieces:
187
+ artwork_image(icon, pieces.images[king], -24, -24, 48, 48)
188
+ else:
189
+ style.draw_piece(ET.SubElement(icon, "g"), king)
190
+
191
+
192
+ def diagram(board, position, game, prefix, label, board_image, piece_images):
193
+ style = BOARD_TYPES[game]
194
+ geometry = board_image.geometry if board_image else Geometry(
195
+ 480, style.height, style.origin, style.origin, style.step, style.step,
196
+ )
197
+ width, height = geometry.width, geometry.height
198
+ x, y, dx, dy = geometry.x, geometry.y, geometry.dx, geometry.dy
199
+ grid = (style.columns, style.rows, x, y, dx, dy, width, height)
200
+ figure = ET.Element("figure", {
201
+ "class": "fen-board-figure", "data-fen": position, "data-game": game, "data-prefix": prefix,
202
+ "data-grid": " ".join(f"{n:g}" for n in grid),
203
+ "data-label": style.label, "data-light": style.light,
204
+ })
205
+ if board_image:
206
+ figure.set("data-board-art", "")
207
+ surface = ET.SubElement(figure, "div", {"class": "fen-board-surface"})
208
+ svg = ET.SubElement(surface, "svg", {
209
+ "xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {width:g} {height:g}",
210
+ "role": "img", "aria-label": label, "focusable": "false",
211
+ })
212
+ ET.SubElement(svg, "desc").text = f"FEN: {position}"
213
+ defs = ET.SubElement(svg, "defs")
214
+ for piece in style.pieces:
215
+ shape = ET.SubElement(defs, "g", {"id": f"{prefix}-{piece}"})
216
+ if piece_images:
217
+ w, h = dx * piece_images.scale, dy * piece_images.scale
218
+ ox, oy = piece_images.offset
219
+ artwork_image(shape, piece_images.images[piece], dx * ox - w / 2, dy * oy - h / 2, w, h)
220
+ else:
221
+ if board_image:
222
+ ratio = min(dx, dy) / style.step
223
+ shape.set("transform", f"scale({ratio:g})")
224
+ style.draw_piece(shape, piece)
225
+ if board_image:
226
+ artwork_image(svg, board_image.uri, 0, 0, width, height)
227
+ else:
228
+ style.draw_grid(svg)
229
+ layer = ET.SubElement(svg, "g", {"class": "fen-board-pieces"})
230
+ for index, piece in enumerate(board):
231
+ if piece is None:
232
+ continue
233
+ row, col = divmod(index, style.columns)
234
+ rank = style.rows - row - (1 if game == "xiang" else 0)
235
+ group = ET.SubElement(layer, "g", {
236
+ "transform": f"translate({x + col * dx:g} {y + row * dy:g})",
237
+ "data-square": f"{chr(97 + col)}{rank}", "data-piece": piece,
238
+ })
239
+ ET.SubElement(group, "use", {"href": f"#{prefix}-{piece}"})
240
+ return figure
241
+
242
+
243
+ class BoardTreeprocessor(Treeprocessor):
244
+ def __init__(self, md, catalog, sounds):
245
+ super().__init__(md)
246
+ self.catalog = catalog
247
+ self.sounds = sounds
248
+
249
+ def run(self, root):
250
+ count = 0
251
+ for block in list(root.iter()):
252
+ classes = block.get("class", "").split()
253
+ if block.tag == "details":
254
+ title_tag = "summary"
255
+ elif block.tag == "div" and "admonition" in classes:
256
+ title_tag = "p"
257
+ else:
258
+ continue
259
+ choices = [name for name in classes if name in BOARD_TYPES or name == "board"]
260
+ if not choices:
261
+ continue
262
+ if len(choices) != 1:
263
+ raise PluginError("Each board block must specify exactly one board type")
264
+ syntax = choices[0]
265
+ title = block[0] if len(block) and block[0].tag == title_tag else None
266
+ value = "" if title is None else "".join(title.itertext()).strip()
267
+ try:
268
+ game = infer_game(value) if syntax == "board" else syntax
269
+ board, turn, position = parse_fen(value, game)
270
+ except ValueError as error:
271
+ raise PluginError(f'{syntax} 局面图 FEN 无效({value}):{error}') from error
272
+ style = BOARD_TYPES[game]
273
+ board_image, piece_images = self.catalog.select(game, classes)
274
+ label = f"{style.label} · {style.light if turn == 'w' else '黑'}方走棋"
275
+ for child in list(title):
276
+ title.remove(child)
277
+ title.text = None
278
+ king_icons(title, style, piece_images)
279
+ heading = ET.SubElement(title, "span", {"class": "fen-board-title"})
280
+ if title_tag == "summary":
281
+ heading.text = "Position" if game == "chess" else "局面"
282
+ else:
283
+ heading.text = label
284
+ block.set("class", " ".join([*classes, "fen-board"]))
285
+ block.set("data-turn", turn)
286
+ count += 1
287
+ figure = diagram(board, position, game, f"fen-board-{count}", label, board_image, piece_images)
288
+ for event, filename in self.sounds.items():
289
+ figure.set(f"data-sound-{event}", filename)
290
+ block.insert(1, figure)
291
+
292
+
293
+ class BoardExtension(Extension):
294
+ def __init__(self, catalog=None, sounds=None):
295
+ super().__init__()
296
+ self.catalog = catalog if catalog is not None else ThemeCatalog()
297
+ self.sounds = sounds or {}
298
+
299
+ def extendMarkdown(self, md):
300
+ # Process parsed admonitions. Fenced and inline examples stay literal.
301
+ md.treeprocessors.register(BoardTreeprocessor(md, self.catalog, self.sounds), "fen_board", 15)
@@ -0,0 +1,147 @@
1
+ """Load opt-in artwork packs from the site author's filesystem."""
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Optional
9
+
10
+ from mkdocs.exceptions import PluginError
11
+
12
+
13
+ THEME_NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
14
+ IMAGE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\.(?:svg|png|jpg|jpeg|gif|webp)", re.IGNORECASE)
15
+ DEFAULT = "default"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Geometry:
20
+ width: float
21
+ height: float
22
+ x: float
23
+ y: float
24
+ dx: float
25
+ dy: float
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BoardImage:
30
+ uri: str
31
+ geometry: Geometry
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PieceImages:
36
+ images: dict
37
+ scale: float
38
+ offset: tuple
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Theme:
43
+ game: str
44
+ board: Optional[BoardImage]
45
+ pieces: Optional[PieceImages]
46
+
47
+
48
+ def number(value, field):
49
+ if type(value) not in (float, int) or not math.isfinite(value):
50
+ raise ValueError(f"{field} must be a finite number")
51
+ return value
52
+
53
+
54
+ def pair(value, field):
55
+ if not isinstance(value, list) or len(value) != 2:
56
+ raise ValueError(f"{field} must have two numbers")
57
+ return tuple(number(n, field) for n in value)
58
+
59
+
60
+ class ThemeCatalog:
61
+ def __init__(self):
62
+ self.themes = {}
63
+ self.defaults = {}
64
+ self.assets = {}
65
+
66
+ def load(self, base, manifests, defaults, games):
67
+ for name, location in manifests.items():
68
+ if not isinstance(name, str) or not THEME_NAME.fullmatch(name) or name == DEFAULT:
69
+ raise PluginError(f"chess invalid theme name: {name!r}")
70
+ try:
71
+ if not isinstance(location, str):
72
+ raise ValueError("manifest path must be a string")
73
+ manifest = (base / location).resolve(strict=True)
74
+ self.themes[name] = self.read_theme(name, manifest, games)
75
+ except (OSError, ValueError, TypeError, KeyError, OverflowError) as error:
76
+ raise PluginError(f"chess theme {name!r}: {error}") from error
77
+ for game, options in defaults.items():
78
+ if game not in games or not isinstance(options, dict) or options.keys() - {"board", "pieces"}:
79
+ raise PluginError(f"chess invalid defaults for {game!r}")
80
+ for kind, name in options.items():
81
+ self.resolve(game, kind, name)
82
+ self.defaults = defaults
83
+
84
+ def read_theme(self, name, manifest, games):
85
+ data = json.loads(manifest.read_text(encoding="utf-8-sig"))
86
+ if not isinstance(data, dict) or data.get("game") not in games:
87
+ raise ValueError("game must be a registered chess or xiang board type")
88
+ game = data["game"]
89
+ variant = games[game]
90
+ root = manifest.parent
91
+
92
+ def image(filename):
93
+ if not isinstance(filename, str) or not IMAGE_NAME.fullmatch(filename):
94
+ raise ValueError(f"invalid artwork filename: {filename!r}")
95
+ path = (root / filename).resolve(strict=True)
96
+ if path.parent != root or not path.is_file():
97
+ raise ValueError(f"artwork must be a file next to the manifest: {filename}")
98
+ uri = f"themes/{name}/{filename}"
99
+ self.assets[uri] = path
100
+ return uri
101
+
102
+ board = None
103
+ if "board" in data:
104
+ spec = data["board"]
105
+ width, height = pair(spec["size"], "board.size")
106
+ x, y = pair(spec["origin"], "board.origin")
107
+ dx, dy = pair(spec["step"], "board.step")
108
+ if not (dx > 0 and dy > 0 and
109
+ 0 <= x < x + (variant.columns - 1) * dx < width and
110
+ 0 <= y < y + (variant.rows - 1) * dy < height):
111
+ raise ValueError("board grid must fit inside the image with positive steps")
112
+ board = BoardImage(image(spec["image"]), Geometry(width, height, x, y, dx, dy))
113
+
114
+ pieces = None
115
+ if "pieces" in data:
116
+ spec = data["pieces"]
117
+ images = spec["images"]
118
+ if not isinstance(images, dict) or images.keys() != set(variant.pieces):
119
+ raise ValueError("pieces.images must map every piece letter in this game's FEN")
120
+ scale = number(spec.get("scale", 1), "pieces.scale")
121
+ if scale <= 0:
122
+ raise ValueError("pieces.scale must be positive")
123
+ offset = pair(spec.get("offset", [0, 0]), "pieces.offset")
124
+ pieces = PieceImages({piece: image(file) for piece, file in images.items()}, scale, offset)
125
+ if board is None and pieces is None:
126
+ raise ValueError("expected a board or pieces entry")
127
+ return Theme(game, board, pieces)
128
+
129
+ def resolve(self, game, kind, name):
130
+ if name == DEFAULT:
131
+ return None
132
+ theme = self.themes.get(name) if isinstance(name, str) else None
133
+ value = getattr(theme, kind) if theme and theme.game == game else None
134
+ if value is None:
135
+ raise PluginError(f"chess {game}: {name!r} has no compatible {kind} artwork")
136
+ return value
137
+
138
+ def select(self, game, classes):
139
+ result = []
140
+ for kind in ("board", "pieces"):
141
+ prefix = kind + "-"
142
+ overrides = [name[len(prefix):] for name in classes if name.startswith(prefix)]
143
+ if len(overrides) > 1:
144
+ raise PluginError(f"chess {game}: choose one {kind} theme per block")
145
+ name = overrides[0] if overrides else self.defaults.get(game, {}).get(kind, DEFAULT)
146
+ result.append(self.resolve(game, kind, name))
147
+ return result