@taloswork/ose-shared 0.1.9 → 0.1.11
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.
- package/mcp/gen/mcp.json +2 -2
- package/package.json +3 -3
- package/skills/diagram-design/SKILL.md +1 -1
- package/skills/document/SKILL.md +63 -75
- package/skills/document/bin/deck-build.cjs +51 -1
- package/skills/document/bin/deck-qa.cjs +18745 -279
- package/skills/document/bin/doc-convert.cjs +9280 -76489
- package/skills/document/bin/doc-fill.cjs +0 -0
- package/skills/document/bin/doc-gen.cjs +3043 -46825
- package/skills/document/bin/doc-run.cjs +65095 -1967
- package/skills/document/bin/doc-scan.cjs +0 -0
- package/skills/document/bin/doc-text.cjs +0 -0
- package/skills/document/bin/doc-validate.cjs +18067 -226
- package/skills/document/bin/doc-xml.cjs +1 -1
- package/skills/document/bin/docx-annotate.cjs +0 -0
- package/skills/document/bin/html2pptx.cjs +32498 -145280
- package/skills/document/bin/ocr-worker.cjs +0 -0
- package/skills/document/bin/ocr.cjs +0 -0
- package/skills/document/bin/pdf-form.cjs +0 -0
- package/skills/document/bin/pdf-images.cjs +0 -0
- package/skills/document/bin/pdf-ops.cjs +0 -0
- package/skills/document/bin/pdf-render.cjs +11 -9
- package/skills/document/bin/pdf-tables.cjs +0 -0
- package/skills/document/bin/pptx-clone.cjs +21 -25
- package/skills/document/bin/pptx-embed-fonts.cjs +8 -6
- package/skills/document/bin/pptx-set-image.cjs +0 -0
- package/skills/document/bin/pptx-slide.cjs +22 -32
- package/skills/document/bin/pptx-theme.cjs +1 -20
- package/skills/document/bin/pptx-tokenize.cjs +1 -1
- package/skills/document/bin/xlsx-query.cjs +0 -0
- package/skills/document/bin/xlsx-recalc.cjs +0 -0
- package/skills/document/bin-reference.md +35 -0
- package/skills/document/content-pipeline.md +19 -18
- package/skills/document/deck-design.md +110 -211
- package/skills/document/layouts/README.md +4 -33
- package/skills/document/motion/README.md +4 -2
- package/skills/document/office.md +5 -1
- package/skills/document/rewrite.md +43 -99
- package/skills/document/styles/README.md +0 -7
- package/skills/document/theme-system.md +1 -0
- package/skills/document/themes/README.md +5 -5
- package/skills/document/themes/anthropic-clay.json +1 -7
- package/skills/document/themes/apple-keynote.json +1 -7
- package/skills/document/themes/black-gold.json +1 -2
- package/skills/document/themes/cinereel.json +1 -2
- package/skills/document/themes/cool-tech.json +1 -2
- package/skills/document/themes/hand-drawn.json +1 -2
- package/skills/document/themes/ivory-gold.json +1 -2
- package/skills/document/themes/magazine-ink.json +1 -2
- package/skills/document/themes/mckinsey-consulting.json +1 -7
- package/skills/document/themes/mono.json +1 -2
- package/skills/document/themes/swiss-grid.json +1 -2
- package/skills/document/themes/warm-editorial.json +1 -2
- package/skills/image-layout/SKILL.md +1 -1
- package/skills/image-layout/bin/html2img.cjs +0 -0
- package/skills/cozy-static-svg/SKILL.md +0 -76
- package/skills/cozy-static-svg/references/approved-preview.png +0 -0
- package/skills/cozy-static-svg/references/cross-platform.md +0 -73
- package/skills/cozy-static-svg/references/moon_keeps_watch.svg +0 -229
- package/skills/cozy-static-svg/references/style-anchor.md +0 -271
- package/skills/cozy-static-svg/scripts/artkit.py +0 -321
- package/skills/cozy-static-svg/scripts/to_png.py +0 -333
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""静态 SVG 插画质检:结构校验 + 画风合规报告(秒级,直出 SVG,不转 PNG)。
|
|
3
|
-
|
|
4
|
-
跨平台:macOS / Linux / Windows 都能跑,只用 Python 标准库(需 Python 3.9+)。
|
|
5
|
-
|
|
6
|
-
macOS / Linux python3 scripts/artkit.py 作品.svg
|
|
7
|
-
Windows py -3 scripts\\artkit.py 作品.svg
|
|
8
|
-
|
|
9
|
-
批量质检(两个平台写法一致:目录和通配符都行,PowerShell 不必自己展开):
|
|
10
|
-
python3 scripts/artkit.py . # 当前目录下全部 .svg(递归)
|
|
11
|
-
python3 scripts/artkit.py "out/*.svg" # 通配符由脚本自己展开
|
|
12
|
-
py -3 scripts\\artkit.py 作品目录 --strict
|
|
13
|
-
|
|
14
|
-
- 结构:命名空间 / 尺寸 / viewBox / title·desc / role·aria / 唯一标识 /
|
|
15
|
-
内部引用 / 禁用动态与外链 / 数值合法。
|
|
16
|
-
- 画风:全部内容位于圆角裁切内(画布圆角约定)/ 字体栈 / 调色清单 /
|
|
17
|
-
文案清单 / 文案克制。
|
|
18
|
-
- 退出码:0 通过(可含提示);1 存在不合格项;--strict 时提示也算不合格。
|
|
19
|
-
"""
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import argparse
|
|
23
|
-
import glob
|
|
24
|
-
import re
|
|
25
|
-
import sys
|
|
26
|
-
import xml.etree.ElementTree as ET
|
|
27
|
-
from collections import Counter
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
if sys.version_info < (3, 9):
|
|
31
|
-
sys.exit(
|
|
32
|
-
"需要 Python 3.9 或更新版本(当前 %d.%d);"
|
|
33
|
-
"Windows 请用 py -3 运行,或到 python.org 安装新版。" % sys.version_info[:2]
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
# Windows 控制台默认是 GBK,直接 print 中文可能抛 UnicodeEncodeError → 强制 UTF-8 输出
|
|
37
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
38
|
-
try:
|
|
39
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
40
|
-
except (AttributeError, ValueError):
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
SVG_NS = "http://www.w3.org/2000/svg"
|
|
44
|
-
BLOCKED = {
|
|
45
|
-
"script", "animate", "animatetransform", "animatemotion", "animatecolor",
|
|
46
|
-
"set", "discard", "foreignobject", "image", "iframe", "audio", "video", "a",
|
|
47
|
-
}
|
|
48
|
-
URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.I)
|
|
49
|
-
CSS_MOTION = re.compile(
|
|
50
|
-
r"@keyframes\b|@import\b|(?:^|[;{\s])(?:-\w+-)?(?:animation|transition)(?:-[\w-]+)?\s*:",
|
|
51
|
-
re.I,
|
|
52
|
-
)
|
|
53
|
-
SIZE_RE = re.compile(r"(?:\d+(?:\.\d+)?|\.\d+)(?:px)?")
|
|
54
|
-
NAN_RE = re.compile(r"\bNaN\b|\bInfinity\b", re.I)
|
|
55
|
-
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
56
|
-
CJK_FACES = (
|
|
57
|
-
"pingfang sc", "noto sans cjk", "microsoft yahei", "heiti sc", "simhei",
|
|
58
|
-
"source han sans", "wenquanyi",
|
|
59
|
-
)
|
|
60
|
-
COUNT_ORDER = [
|
|
61
|
-
"g", "path", "circle", "ellipse", "rect", "line", "polygon", "polyline",
|
|
62
|
-
"text", "tspan", "use", "linearGradient", "radialGradient", "clipPath", "mask",
|
|
63
|
-
]
|
|
64
|
-
CLIP_REF_RE = re.compile(r"url\(\s*#([^)\s]+)\s*\)")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def local(tag: str) -> str:
|
|
68
|
-
return tag.rsplit("}", 1)[-1]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def local_attr(key: str) -> str:
|
|
72
|
-
return key.rsplit("}", 1)[-1].lower()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def shorten(text: str, limit: int = 26) -> str:
|
|
76
|
-
return text if len(text) <= limit else text[:limit] + "…"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def inspect(source: Path) -> dict:
|
|
80
|
-
"""质检一个 SVG;不合格时抛出 ValueError,通过时返回报告数据。"""
|
|
81
|
-
raw = source.read_text(encoding="utf-8")
|
|
82
|
-
if re.search(r"<!DOCTYPE|<!ENTITY|<\?xml-stylesheet", raw, re.I):
|
|
83
|
-
raise ValueError("不接受 DTD、实体声明或外部样式表。")
|
|
84
|
-
root = ET.fromstring(raw)
|
|
85
|
-
if root.tag != f"{{{SVG_NS}}}svg":
|
|
86
|
-
raise ValueError("根节点必须是含正确命名空间的 SVG。")
|
|
87
|
-
vb = [float(x) for x in re.split(r"[\s,]+", root.get("viewBox", "").strip()) if x]
|
|
88
|
-
if len(vb) != 4 or vb[2] <= 0 or vb[3] <= 0:
|
|
89
|
-
raise ValueError("viewBox 必须包含四个数,宽高须为正数。")
|
|
90
|
-
for attr in ("width", "height"):
|
|
91
|
-
value = root.get(attr, "")
|
|
92
|
-
if not SIZE_RE.fullmatch(value) or float(value.removesuffix("px")) <= 0:
|
|
93
|
-
raise ValueError(f"请显式填写正数 {attr}(数字或 px)。")
|
|
94
|
-
canvas_w = float(root.get("width").removesuffix("px"))
|
|
95
|
-
canvas_h = float(root.get("height").removesuffix("px"))
|
|
96
|
-
for tag in ("title", "desc"):
|
|
97
|
-
node = root.find(f"{{{SVG_NS}}}{tag}")
|
|
98
|
-
if node is None or not "".join(node.itertext()).strip():
|
|
99
|
-
raise ValueError(f"缺少非空的 {tag}。")
|
|
100
|
-
if root.get("role") != "img" or not root.get("aria-labelledby", "").strip():
|
|
101
|
-
raise ValueError("请填写 role=img 和 aria-labelledby。")
|
|
102
|
-
|
|
103
|
-
elements = list(root.iter())
|
|
104
|
-
ids = [e.get("id") for e in elements if e.get("id") is not None]
|
|
105
|
-
if any(not i for i in ids) or len(ids) != len(set(ids)):
|
|
106
|
-
raise ValueError("存在空标识或重复标识。")
|
|
107
|
-
refs = set(root.get("aria-labelledby", "").split())
|
|
108
|
-
colors = Counter()
|
|
109
|
-
for node in elements:
|
|
110
|
-
tag = local(node.tag).lower()
|
|
111
|
-
if tag in BLOCKED or not node.tag.startswith("{" + SVG_NS + "}"):
|
|
112
|
-
raise ValueError(f"不接受的节点:{tag}。")
|
|
113
|
-
for key, value in node.attrib.items():
|
|
114
|
-
name = local_attr(key)
|
|
115
|
-
if name.startswith("on") or name == "base":
|
|
116
|
-
raise ValueError(f"不接受事件处理器或基址属性:{name}。")
|
|
117
|
-
if NAN_RE.search(value):
|
|
118
|
-
raise ValueError(f"属性含非法数值(NaN/Infinity):{name}。")
|
|
119
|
-
if name == "href":
|
|
120
|
-
if not value.startswith("#") or len(value) < 2:
|
|
121
|
-
raise ValueError("href 只能使用非空的内部 #标识引用。")
|
|
122
|
-
refs.add(value[1:])
|
|
123
|
-
if name in {"aria-labelledby", "aria-describedby"}:
|
|
124
|
-
refs.update(value.split())
|
|
125
|
-
if name == "style" and CSS_MOTION.search(value):
|
|
126
|
-
raise ValueError("不接受 CSS 动画、过渡或导入规则。")
|
|
127
|
-
for _, target in URL_RE.findall(value):
|
|
128
|
-
if not target.startswith("#") or len(target) < 2:
|
|
129
|
-
raise ValueError("url() 只能使用内部 #标识引用。")
|
|
130
|
-
refs.add(target[1:])
|
|
131
|
-
if name in {"fill", "stroke", "stop-color"}:
|
|
132
|
-
plain = value.strip().lower()
|
|
133
|
-
if plain and not plain.startswith("url(") and plain not in {
|
|
134
|
-
"none", "currentcolor", "transparent", "inherit",
|
|
135
|
-
}:
|
|
136
|
-
colors[plain] += 1
|
|
137
|
-
if tag == "style":
|
|
138
|
-
css = "".join(node.itertext())
|
|
139
|
-
if CSS_MOTION.search(css):
|
|
140
|
-
raise ValueError("不接受 CSS 动画、过渡或导入规则。")
|
|
141
|
-
for _, target in URL_RE.findall(css):
|
|
142
|
-
if not target.startswith("#") or len(target) < 2:
|
|
143
|
-
raise ValueError("样式只能引用内部资源。")
|
|
144
|
-
refs.add(target[1:])
|
|
145
|
-
missing = refs - set(ids)
|
|
146
|
-
if missing:
|
|
147
|
-
raise ValueError("未定义的引用:" + "、".join(sorted(missing)))
|
|
148
|
-
|
|
149
|
-
# 画风约定:全部内容(含文字)位于圆角裁切分组内
|
|
150
|
-
rounded_clips = {}
|
|
151
|
-
for clip in root.iter(f"{{{SVG_NS}}}clipPath"):
|
|
152
|
-
for child in clip:
|
|
153
|
-
if local(child.tag) == "rect" and child.get("rx"):
|
|
154
|
-
try:
|
|
155
|
-
rx = float(child.get("rx"))
|
|
156
|
-
except ValueError:
|
|
157
|
-
continue
|
|
158
|
-
if rx >= 10:
|
|
159
|
-
rounded_clips[clip.get("id")] = (rx, child)
|
|
160
|
-
if not rounded_clips:
|
|
161
|
-
raise ValueError("缺少全幅圆角裁切:未找到含 rx≥10 矩形的 clipPath(画风约定:全幅统一圆角)。")
|
|
162
|
-
warns = []
|
|
163
|
-
frame_rx = None
|
|
164
|
-
for child in root:
|
|
165
|
-
tag = local(child.tag)
|
|
166
|
-
if tag in {"title", "desc", "defs", "metadata", "style"}:
|
|
167
|
-
continue
|
|
168
|
-
if tag == "g":
|
|
169
|
-
match = CLIP_REF_RE.fullmatch((child.get("clip-path") or "").strip())
|
|
170
|
-
target = match.group(1) if match else None
|
|
171
|
-
if target in rounded_clips:
|
|
172
|
-
rx, rect = rounded_clips[target]
|
|
173
|
-
if frame_rx is None:
|
|
174
|
-
frame_rx = rx
|
|
175
|
-
rw, rh = rect.get("width"), rect.get("height")
|
|
176
|
-
if rw and rh:
|
|
177
|
-
try:
|
|
178
|
-
if abs(float(rw) - canvas_w) > 0.5 or abs(float(rh) - canvas_h) > 0.5:
|
|
179
|
-
warns.append(
|
|
180
|
-
f"圆角裁切 {rw}×{rh} 与画布 {canvas_w:g}×{canvas_h:g} 不完全一致"
|
|
181
|
-
)
|
|
182
|
-
except ValueError:
|
|
183
|
-
pass
|
|
184
|
-
continue
|
|
185
|
-
raise ValueError(
|
|
186
|
-
f"最外层存在未纳入圆角裁切的 {tag}:全部内容(含文字)都应放在"
|
|
187
|
-
"引用圆角 clipPath 的 <g> 内。"
|
|
188
|
-
)
|
|
189
|
-
if frame_rx is None:
|
|
190
|
-
raise ValueError("找不到包住全部内容的圆角裁切分组。")
|
|
191
|
-
|
|
192
|
-
# 字体栈与文案
|
|
193
|
-
parents = {child: parent for parent in root.iter() for child in parent}
|
|
194
|
-
texts = []
|
|
195
|
-
for node in root.iter(f"{{{SVG_NS}}}text"):
|
|
196
|
-
content = "".join(node.itertext()).strip()
|
|
197
|
-
if content:
|
|
198
|
-
texts.append(content)
|
|
199
|
-
stack = node.get("font-family")
|
|
200
|
-
cursor = node
|
|
201
|
-
while stack is None and cursor in parents:
|
|
202
|
-
cursor = parents[cursor]
|
|
203
|
-
stack = cursor.get("font-family")
|
|
204
|
-
if not stack:
|
|
205
|
-
raise ValueError("有文字缺少 font-family(请在 <g> 或 <text> 上给出系统中文字体栈)。")
|
|
206
|
-
if CJK_RE.search(content) and not any(face in stack.lower() for face in CJK_FACES):
|
|
207
|
-
warns.append(f"字体栈可能缺少中文回退:「{shorten(content, 12)}」")
|
|
208
|
-
if len(texts) > 5:
|
|
209
|
-
warns.append(f"文案 {len(texts)} 处,注意克制(建议标题+一句注脚)")
|
|
210
|
-
|
|
211
|
-
counts = Counter(local(e.tag) for e in elements)
|
|
212
|
-
return {
|
|
213
|
-
"width": canvas_w,
|
|
214
|
-
"height": canvas_h,
|
|
215
|
-
"nodes": len(elements),
|
|
216
|
-
"ids": len(ids),
|
|
217
|
-
"refs": len(refs),
|
|
218
|
-
"rx": frame_rx,
|
|
219
|
-
"texts": texts,
|
|
220
|
-
"colors": colors,
|
|
221
|
-
"counts": counts,
|
|
222
|
-
"warns": warns,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def collect_sources(items: list[str]) -> list[Path]:
|
|
227
|
-
"""把命令行参数展开成 SVG 文件列表:文件 / 目录 / 通配符都接受。
|
|
228
|
-
|
|
229
|
-
Windows 的 PowerShell 与 cmd 不会替命令展开 *.svg,所以通配符在这里自己展开,
|
|
230
|
-
两个平台的命令写法才能真正一致。
|
|
231
|
-
"""
|
|
232
|
-
found: list[Path] = []
|
|
233
|
-
for item in items:
|
|
234
|
-
text = str(item)
|
|
235
|
-
path = Path(text)
|
|
236
|
-
if path.is_dir():
|
|
237
|
-
found.extend(
|
|
238
|
-
sorted(
|
|
239
|
-
p
|
|
240
|
-
for p in path.rglob("*")
|
|
241
|
-
if p.is_file() and p.suffix.lower() == ".svg"
|
|
242
|
-
)
|
|
243
|
-
)
|
|
244
|
-
continue
|
|
245
|
-
if any(ch in text for ch in "*?["):
|
|
246
|
-
found.extend(
|
|
247
|
-
sorted(
|
|
248
|
-
Path(m)
|
|
249
|
-
for m in glob.glob(text, recursive=True)
|
|
250
|
-
if Path(m).is_file() and Path(m).suffix.lower() == ".svg"
|
|
251
|
-
)
|
|
252
|
-
)
|
|
253
|
-
continue
|
|
254
|
-
found.append(path)
|
|
255
|
-
|
|
256
|
-
unique: list[Path] = []
|
|
257
|
-
seen: set[str] = set()
|
|
258
|
-
for p in found:
|
|
259
|
-
try:
|
|
260
|
-
key = str(p.resolve())
|
|
261
|
-
except OSError:
|
|
262
|
-
key = str(p)
|
|
263
|
-
if key not in seen:
|
|
264
|
-
seen.add(key)
|
|
265
|
-
unique.append(p)
|
|
266
|
-
return unique
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def main() -> int:
|
|
270
|
-
parser = argparse.ArgumentParser(
|
|
271
|
-
description="静态 SVG 质检:结构校验 + 画风合规报告(跨平台,纯标准库)",
|
|
272
|
-
epilog="参数可以是文件、目录或通配符;目录会递归查找全部 .svg。",
|
|
273
|
-
)
|
|
274
|
-
parser.add_argument("svgs", nargs="+", help="待质检的 SVG(文件 / 目录 / 通配符)")
|
|
275
|
-
parser.add_argument("--strict", action="store_true", help="提示也视为不合格")
|
|
276
|
-
args = parser.parse_args()
|
|
277
|
-
|
|
278
|
-
sources = collect_sources(args.svgs)
|
|
279
|
-
if not sources:
|
|
280
|
-
print("没有找到任何 .svg —— 检查一下路径,或改用目录 / 通配符批量指定。")
|
|
281
|
-
return 1
|
|
282
|
-
|
|
283
|
-
failed = 0
|
|
284
|
-
warned = 0
|
|
285
|
-
for index, svg in enumerate(sources, 1):
|
|
286
|
-
print(f"[{index}/{len(sources)}] {svg.name}")
|
|
287
|
-
if not svg.is_file():
|
|
288
|
-
print(f" ✗ 文件不存在或不是文件:{svg}")
|
|
289
|
-
failed += 1
|
|
290
|
-
continue
|
|
291
|
-
try:
|
|
292
|
-
report = inspect(svg)
|
|
293
|
-
except (OSError, ValueError, ET.ParseError) as exc:
|
|
294
|
-
print(f" ✗ 检查未通过:{exc}")
|
|
295
|
-
failed += 1
|
|
296
|
-
continue
|
|
297
|
-
counts = report["counts"]
|
|
298
|
-
parts = " · ".join(f"{tag} {counts[tag]}" for tag in COUNT_ORDER if counts.get(tag))
|
|
299
|
-
top = " · ".join(f"{color}×{n}" for color, n in report["colors"].most_common(10))
|
|
300
|
-
print(f" ✓ 结构:{report['width']:g}×{report['height']:g};title/desc/role 齐全;无动态与外链")
|
|
301
|
-
print(f" ✓ 引用:{report['ids']} 个标识 / {report['refs']} 处引用,全部有效")
|
|
302
|
-
print(f" ✓ 裁切:全部内容位于圆角裁切内(rx≈{report['rx']:g})")
|
|
303
|
-
print(f" ✓ 字体:{len(report['texts'])} 处文字字体栈完整")
|
|
304
|
-
print(f" 元素 {report['nodes']}|{parts}")
|
|
305
|
-
print(f" 调色 {len(report['colors'])} 种|{top}")
|
|
306
|
-
print(" 文案|" + ("|".join(shorten(t) for t in report["texts"]) or "无"))
|
|
307
|
-
if report["warns"]:
|
|
308
|
-
warned += 1
|
|
309
|
-
print(" ⚠ 提示|" + ";".join(report["warns"]))
|
|
310
|
-
else:
|
|
311
|
-
print(" 提示|无")
|
|
312
|
-
passed = len(sources) - failed
|
|
313
|
-
tail = ";存在提示" if warned and not failed else ""
|
|
314
|
-
print(f"总计:{passed}/{len(sources)} 通过质检{tail}。")
|
|
315
|
-
if failed or (args.strict and warned):
|
|
316
|
-
return 1
|
|
317
|
-
return 0
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if __name__ == "__main__":
|
|
321
|
-
raise SystemExit(main())
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""SVG → PNG(跨平台:macOS / Windows / Linux 同一条命令,纯标准库 + 系统已有工具)。
|
|
3
|
-
|
|
4
|
-
仅当用户明确要 PNG 时才用,默认工作流仍是「直出原生 SVG,不转 PNG」。
|
|
5
|
-
|
|
6
|
-
用法(两个平台写法一致):
|
|
7
|
-
macOS / Linux python3 scripts/to_png.py 作品.svg
|
|
8
|
-
Windows py -3 scripts\\to_png.py 作品.svg
|
|
9
|
-
python3 scripts/to_png.py --scale 2 . # 目录下全部 SVG,2 倍图
|
|
10
|
-
py -3 scripts\\to_png.py -o out --width 2400 作品目录
|
|
11
|
-
python3 scripts/to_png.py --backend inkscape 作品.svg
|
|
12
|
-
|
|
13
|
-
后端自动探测,找到哪个用哪个(也可用 --backend 指定):
|
|
14
|
-
1. rsvg-convert 与手绘流程同源,最推荐
|
|
15
|
-
macOS: brew install librsvg | Windows: MSYS2 / scoop 自行安装
|
|
16
|
-
2. inkscape Inkscape 1.x,装了图形界面就自带命令行
|
|
17
|
-
3. cairosvg pip install cairosvg(纯 Python 依赖)
|
|
18
|
-
4. chrome / edge 系统自带浏览器无头截图,零安装兜底
|
|
19
|
-
|
|
20
|
-
其它参数:
|
|
21
|
-
-o DIR 输出目录(默认与源文件同目录)
|
|
22
|
-
--scale N 相对 SVG 原始尺寸的倍数(默认 1)
|
|
23
|
-
--width / --height 指定像素尺寸(只给一边则按比例算另一边)
|
|
24
|
-
--background RRGGBB 铺不透明底色(默认保留透明,圆角外仍是透明的)
|
|
25
|
-
"""
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
import argparse
|
|
29
|
-
import glob
|
|
30
|
-
import os
|
|
31
|
-
import re
|
|
32
|
-
import shutil
|
|
33
|
-
import subprocess
|
|
34
|
-
import sys
|
|
35
|
-
import tempfile
|
|
36
|
-
import xml.etree.ElementTree as ET
|
|
37
|
-
from pathlib import Path
|
|
38
|
-
|
|
39
|
-
if sys.version_info < (3, 9):
|
|
40
|
-
sys.exit(
|
|
41
|
-
"需要 Python 3.9 或更新版本(当前 %d.%d);"
|
|
42
|
-
"Windows 请用 py -3 运行,或到 python.org 安装新版。" % sys.version_info[:2]
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
if hasattr(sys.stdout, "reconfigure"): # Windows 控制台默认 GBK,强制 UTF-8 输出
|
|
46
|
-
try:
|
|
47
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
48
|
-
except (AttributeError, ValueError):
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
CHROME_CANDIDATES = {
|
|
52
|
-
"darwin": [
|
|
53
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
54
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
55
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
56
|
-
],
|
|
57
|
-
"win32": [
|
|
58
|
-
r"%PROGRAMFILES%\Google\Chrome\Application\chrome.exe",
|
|
59
|
-
r"%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe",
|
|
60
|
-
r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe",
|
|
61
|
-
r"%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe",
|
|
62
|
-
r"%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe",
|
|
63
|
-
],
|
|
64
|
-
}
|
|
65
|
-
CHROME_ON_PATH = ["chrome", "google-chrome", "chromium", "chromium-browser", "msedge"]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# ---------- 输入展开(与 artkit.py 同规则:文件 / 目录 / 通配符) ----------
|
|
69
|
-
|
|
70
|
-
def collect_sources(items: list[str]) -> list[Path]:
|
|
71
|
-
found: list[Path] = []
|
|
72
|
-
for item in items:
|
|
73
|
-
text = str(item)
|
|
74
|
-
path = Path(text)
|
|
75
|
-
if path.is_dir():
|
|
76
|
-
found.extend(
|
|
77
|
-
sorted(
|
|
78
|
-
p
|
|
79
|
-
for p in path.rglob("*")
|
|
80
|
-
if p.is_file() and p.suffix.lower() == ".svg"
|
|
81
|
-
)
|
|
82
|
-
)
|
|
83
|
-
continue
|
|
84
|
-
if any(ch in text for ch in "*?["):
|
|
85
|
-
found.extend(
|
|
86
|
-
sorted(
|
|
87
|
-
Path(m)
|
|
88
|
-
for m in glob.glob(text, recursive=True)
|
|
89
|
-
if Path(m).is_file() and Path(m).suffix.lower() == ".svg"
|
|
90
|
-
)
|
|
91
|
-
)
|
|
92
|
-
continue
|
|
93
|
-
found.append(path)
|
|
94
|
-
|
|
95
|
-
unique: list[Path] = []
|
|
96
|
-
seen: set[str] = set()
|
|
97
|
-
for p in found:
|
|
98
|
-
try:
|
|
99
|
-
key = str(p.resolve())
|
|
100
|
-
except OSError:
|
|
101
|
-
key = str(p)
|
|
102
|
-
if key not in seen:
|
|
103
|
-
seen.add(key)
|
|
104
|
-
unique.append(p)
|
|
105
|
-
return unique
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# ---------- 后端探测 ----------
|
|
109
|
-
|
|
110
|
-
def _which(*names: str) -> str | None:
|
|
111
|
-
for name in names:
|
|
112
|
-
hit = shutil.which(name)
|
|
113
|
-
if hit:
|
|
114
|
-
return hit
|
|
115
|
-
return None
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def find_chrome() -> str | None:
|
|
119
|
-
for raw in CHROME_CANDIDATES.get(sys.platform, []):
|
|
120
|
-
path = os.path.expandvars(raw)
|
|
121
|
-
if Path(path).is_file():
|
|
122
|
-
return path
|
|
123
|
-
return _which(*CHROME_ON_PATH)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def find_backends() -> dict[str, str]:
|
|
127
|
-
"""返回 {后端名: 可执行路径或说明};cairosvg 用 'python' 表示模块可用。"""
|
|
128
|
-
backends: dict[str, str] = {}
|
|
129
|
-
if (rsvg := _which("rsvg-convert")):
|
|
130
|
-
backends["rsvg"] = rsvg
|
|
131
|
-
if (ink := _which("inkscape")) or Path(
|
|
132
|
-
"/Applications/Inkscape.app/Contents/MacOS/inkscape"
|
|
133
|
-
).is_file():
|
|
134
|
-
backends["inkscape"] = ink or "/Applications/Inkscape.app/Contents/MacOS/inkscape"
|
|
135
|
-
try:
|
|
136
|
-
import cairosvg # noqa: F401
|
|
137
|
-
|
|
138
|
-
backends["cairosvg"] = "python-module"
|
|
139
|
-
except ImportError:
|
|
140
|
-
pass
|
|
141
|
-
if (chrome := find_chrome()):
|
|
142
|
-
backends["chrome"] = chrome
|
|
143
|
-
return backends
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# ---------- 尺寸 ----------
|
|
147
|
-
|
|
148
|
-
def svg_size(src: Path) -> tuple[float, float] | None:
|
|
149
|
-
try:
|
|
150
|
-
root = ET.parse(src).getroot()
|
|
151
|
-
except (ET.ParseError, OSError):
|
|
152
|
-
return None
|
|
153
|
-
size = re.compile(r"^\s*(\d+(?:\.\d+)?)")
|
|
154
|
-
out = []
|
|
155
|
-
for attr in ("width", "height"):
|
|
156
|
-
match = size.match(root.get(attr) or "")
|
|
157
|
-
if not match:
|
|
158
|
-
return None
|
|
159
|
-
out.append(float(match.group(1)))
|
|
160
|
-
return out[0], out[1]
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def target_size(src: Path, scale: float, width: float | None, height: float | None):
|
|
164
|
-
base = svg_size(src) or (1200.0, 900.0)
|
|
165
|
-
w, h = base[0] * scale, base[1] * scale
|
|
166
|
-
if width and height:
|
|
167
|
-
return width, height
|
|
168
|
-
if width:
|
|
169
|
-
return width, round(w and h * width / w)
|
|
170
|
-
if height:
|
|
171
|
-
return round(w * height / h), height
|
|
172
|
-
return round(w), round(h)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# ---------- 各后端转换 ----------
|
|
176
|
-
|
|
177
|
-
def convert_rsvg(exe: str, src: Path, out: Path, size, background: str | None) -> None:
|
|
178
|
-
cmd = [exe, "-w", str(size[0]), "-h", str(size[1])]
|
|
179
|
-
if background:
|
|
180
|
-
cmd += ["-b", f"#{background.lstrip('#')}"]
|
|
181
|
-
cmd += [str(src), "-o", str(out)]
|
|
182
|
-
subprocess.run(cmd, check=True, capture_output=True)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def convert_inkscape(exe: str, src: Path, out: Path, size, background: str | None) -> None:
|
|
186
|
-
cmd = [
|
|
187
|
-
exe,
|
|
188
|
-
"--export-type=png",
|
|
189
|
-
f"--export-filename={out}",
|
|
190
|
-
f"--export-width={size[0]}",
|
|
191
|
-
f"--export-height={size[1]}",
|
|
192
|
-
]
|
|
193
|
-
if background:
|
|
194
|
-
cmd += [f"--export-background=#{background.lstrip('#')}", "--export-background-opacity=255"]
|
|
195
|
-
cmd.append(str(src))
|
|
196
|
-
subprocess.run(cmd, check=True, capture_output=True)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def convert_cairosvg(src: Path, out: Path, size, background: str | None) -> None:
|
|
200
|
-
import cairosvg
|
|
201
|
-
|
|
202
|
-
cairosvg.svg2png(
|
|
203
|
-
url=str(src),
|
|
204
|
-
write_to=str(out),
|
|
205
|
-
output_width=size[0],
|
|
206
|
-
output_height=size[1],
|
|
207
|
-
background_color=f"#{background.lstrip('#')}" if background else None,
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def convert_chrome(exe: str, src: Path, out: Path, size, background: str | None) -> None:
|
|
212
|
-
markup = src.read_text(encoding="utf-8")
|
|
213
|
-
markup = re.sub(r"<\?xml[^>]*\?>", "", markup, count=1).strip()
|
|
214
|
-
if background:
|
|
215
|
-
markup = markup.replace(
|
|
216
|
-
"<svg ", f'<svg style="background:#{background.lstrip("#")}" ', 1
|
|
217
|
-
)
|
|
218
|
-
html = (
|
|
219
|
-
"<!doctype html><html><head><meta charset=\"utf-8\"><style>"
|
|
220
|
-
"html,body{margin:0;padding:0;overflow:hidden;background:transparent}"
|
|
221
|
-
"svg{display:block;width:100vw;height:100vh}</style></head><body>"
|
|
222
|
-
+ markup
|
|
223
|
-
+ "</body></html>"
|
|
224
|
-
)
|
|
225
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
226
|
-
page = Path(tmp) / "page.html"
|
|
227
|
-
page.write_text(html, encoding="utf-8")
|
|
228
|
-
common = [
|
|
229
|
-
f"--screenshot={out}",
|
|
230
|
-
f"--window-size={size[0]},{size[1]}",
|
|
231
|
-
"--hide-scrollbars",
|
|
232
|
-
"--force-device-scale-factor=1",
|
|
233
|
-
"--virtual-time-budget=2000",
|
|
234
|
-
"--default-background-color=00000000",
|
|
235
|
-
page.resolve().as_uri(),
|
|
236
|
-
]
|
|
237
|
-
for headless in ("--headless=new", "--headless"):
|
|
238
|
-
try:
|
|
239
|
-
subprocess.run(
|
|
240
|
-
[exe, headless, *common], check=True, capture_output=True, timeout=120
|
|
241
|
-
)
|
|
242
|
-
if out.is_file():
|
|
243
|
-
return
|
|
244
|
-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
245
|
-
continue
|
|
246
|
-
raise RuntimeError("无头浏览器没有产出截图(可换 --backend inkscape 或 rsvg)")
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
# ---------- 主流程 ----------
|
|
250
|
-
|
|
251
|
-
def main() -> int:
|
|
252
|
-
parser = argparse.ArgumentParser(
|
|
253
|
-
description="SVG → PNG(跨平台,自动选择可用后端)",
|
|
254
|
-
epilog="参数可以是文件、目录或通配符。默认保留透明底,输出与源文件同目录。",
|
|
255
|
-
)
|
|
256
|
-
parser.add_argument("svgs", nargs="+", help="待转换的 SVG(文件 / 目录 / 通配符)")
|
|
257
|
-
parser.add_argument("-o", "--outdir", type=Path, help="输出目录")
|
|
258
|
-
parser.add_argument("--scale", type=float, default=1.0, help="相对原始尺寸的倍数(默认 1)")
|
|
259
|
-
parser.add_argument("--width", type=float, help="输出宽度(像素)")
|
|
260
|
-
parser.add_argument("--height", type=float, help="输出高度(像素)")
|
|
261
|
-
parser.add_argument("--background", help="铺底色,如 FFFFFF(默认透明)")
|
|
262
|
-
parser.add_argument(
|
|
263
|
-
"--backend",
|
|
264
|
-
choices=["auto", "rsvg", "inkscape", "cairosvg", "chrome"],
|
|
265
|
-
default="auto",
|
|
266
|
-
help="指定后端(默认 auto:rsvg → inkscape → cairosvg → chrome)",
|
|
267
|
-
)
|
|
268
|
-
args = parser.parse_args()
|
|
269
|
-
|
|
270
|
-
if args.outdir:
|
|
271
|
-
args.outdir.mkdir(parents=True, exist_ok=True)
|
|
272
|
-
|
|
273
|
-
sources = collect_sources(args.svgs)
|
|
274
|
-
if not sources:
|
|
275
|
-
print("没有找到任何 .svg —— 检查路径,或用目录 / 通配符批量指定。")
|
|
276
|
-
return 1
|
|
277
|
-
|
|
278
|
-
backends = find_backends()
|
|
279
|
-
order = ["rsvg", "inkscape", "cairosvg", "chrome"]
|
|
280
|
-
if args.backend != "auto":
|
|
281
|
-
if args.backend not in backends:
|
|
282
|
-
print(f"指定的后端 {args.backend} 在本机没找到。可用:{', '.join(backends) or '无'}")
|
|
283
|
-
print(_install_hint())
|
|
284
|
-
return 1
|
|
285
|
-
chosen = args.backend
|
|
286
|
-
else:
|
|
287
|
-
chosen = next((name for name in order if name in backends), None)
|
|
288
|
-
if not chosen:
|
|
289
|
-
print("没有找到任何可用的转换后端。")
|
|
290
|
-
print(_install_hint())
|
|
291
|
-
return 1
|
|
292
|
-
|
|
293
|
-
print(f"后端:{chosen}({backends[chosen]})")
|
|
294
|
-
failed = 0
|
|
295
|
-
for index, src in enumerate(sources, 1):
|
|
296
|
-
name = src.name
|
|
297
|
-
if not src.is_file():
|
|
298
|
-
print(f"[{index}/{len(sources)}] {name} ✗ 文件不存在")
|
|
299
|
-
failed += 1
|
|
300
|
-
continue
|
|
301
|
-
size = target_size(src, args.scale, args.width, args.height)
|
|
302
|
-
out = (args.outdir or src.parent) / (src.stem + ".png")
|
|
303
|
-
try:
|
|
304
|
-
if chosen == "rsvg":
|
|
305
|
-
convert_rsvg(backends["rsvg"], src, out, size, args.background)
|
|
306
|
-
elif chosen == "inkscape":
|
|
307
|
-
convert_inkscape(backends["inkscape"], src, out, size, args.background)
|
|
308
|
-
elif chosen == "cairosvg":
|
|
309
|
-
convert_cairosvg(src, out, size, args.background)
|
|
310
|
-
else:
|
|
311
|
-
convert_chrome(backends["chrome"], src, out, size, args.background)
|
|
312
|
-
print(f"[{index}/{len(sources)}] {name} → {out.name}({size[0]:g}×{size[1]:g})")
|
|
313
|
-
except (subprocess.CalledProcessError, RuntimeError, OSError, ValueError) as exc:
|
|
314
|
-
print(f"[{index}/{len(sources)}] {name} ✗ 转换失败:{exc}")
|
|
315
|
-
failed += 1
|
|
316
|
-
|
|
317
|
-
ok = len(sources) - failed
|
|
318
|
-
print(f"总计:{ok}/{len(sources)} 张转成 PNG。")
|
|
319
|
-
return 1 if failed else 0
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def _install_hint() -> str:
|
|
323
|
-
return (
|
|
324
|
-
"装一个就行:\n"
|
|
325
|
-
" macOS brew install librsvg (或 brew install --cask inkscape)\n"
|
|
326
|
-
" Windows choco/scoop 装 librsvg,或装 Inkscape,或 pip install cairosvg\n"
|
|
327
|
-
" —— 其实系统自带的 Edge 就能当后端,装不了工具时用 --backend chrome\n"
|
|
328
|
-
" Linux apt install librsvg2-bin / dnf install librsvg2-tools"
|
|
329
|
-
)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if __name__ == "__main__":
|
|
333
|
-
raise SystemExit(main())
|