Files
mcm-26-b/scripts/figkit.py
T
2026-09-13 08:29:30 +08:00

373 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""论文级图表工具:统一配色、场景图 DSL、SVG/PNG 双出与来源清单。
设计约束(对应 docs/figures/README.md 的"可复现"承诺):
- **零新增运行时依赖**:几何/连线由本模块直接写 SVG,PNG 由 ``rsvg-convert``
光栅化(与项目既有可视化链路一致);中文与西文文字层用 Pillow 以论文自带
的 ``docs/paper/fonts/`` 字体单独合成,保证与 ``main.tex`` 同字体、同字号;
- **一份场景图两处消费**:SVG 用于 WebUI/文档内联预览(走系统字体栈),
PNG 用于论文插图(走 bundled 字体);两者坐标完全一致;
- **来源可追溯**:每张图强制携带 ``sources``(文件路径 + sha256 前 12 位 +
行数)与生成命令,写入 ``manifest.json``,避免"图与数据对不上"。
文字排版约定:**主风格只提供 SVG 精度的文字**,Pillow 侧复用同一套
``(x, y, anchor, font, size, dy)`` 参数重画一遍;``dy`` 表示"文字视觉中心相对
基线的偏移比例",由 font-size 缩放,用来让框内文字纵向居中。
"""
from __future__ import annotations
import hashlib
import json
import math
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Sequence
ROOT = Path(__file__).resolve().parents[1]
FONT_DIR = ROOT / "docs" / "paper" / "fonts"
DEFAULT_RASTERIZER = "rsvg-convert"
# ---------------------------------------------------------------------------
# 论文配色:低饱和、单色可打印,正文/强调/真值/成功/失败五类语义色 + 顺序色阶
# ---------------------------------------------------------------------------
PALETTE: dict[str, str] = {
"ink": "#1c2430", # 主文字/坐标轴
"muted": "#6b7684", # 次要文字与网格
"faint": "#dfe4ea", # 分隔线
"paper": "#ffffff",
"primary": "#1f4e79", # 主线:协议/流程
"primary_soft": "#dce6f1",
"accent": "#b03a2e", # 强调:真值源/关键结论
"accent_soft": "#f6dcd8",
"success": "#1e7a5f", # 成功:清除/全清
"success_soft": "#d8ece5",
"warn": "#b8860b", # 警示:后备/未达标
"warn_soft": "#f6ecd2",
"series": ("#1f4e79", "#b03a2e", "#1e7a5f", "#b8860b", "#5b4b8a", "#3d7ea6"),
}
FONTS: dict[str, str] = {
"song": "SongtiSC-Regular.ttf", # 中文正文:宋体
"song_bold": "SongtiSC-Bold.ttf",
"hei": "HeitiSC-Light.ttf", # 中文标题:黑体
"hei_bold": "HeitiSC-Medium.ttf",
"times": "Times New Roman.ttf", # 西文与数字
"times_bold": "Times New Roman Bold.ttf",
}
_SVG_FAMILY = {
"song": "'Songti SC','SimSun',serif",
"song_bold": "'Songti SC','SimSun',serif",
"hei": "'Heiti SC','Microsoft YaHei',sans-serif",
"hei_bold": "'Heiti SC','Microsoft YaHei',sans-serif",
"times": "'Times New Roman',Times,serif",
"times_bold": "'Times New Roman',Times,serif",
}
# ---------------------------------------------------------------------------
# 场景图 DSL
# ---------------------------------------------------------------------------
@dataclass
class Figure:
"""一张图的场景描述:宽高以 CSS 像素(≈1/96 inch)计。"""
width: float
height: float
title: str = ""
caption: str = ""
background: str = PALETTE["paper"]
elements: list[dict[str, Any]] = field(default_factory=list)
sources: list[dict[str, Any]] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
# ---------------------------------------------------------------- 图元
def rect(self, x: float, y: float, w: float, h: float, **kw: Any) -> "Figure":
self.elements.append(dict(kind="rect", x=x, y=y, w=w, h=h, **kw))
return self
def circle(self, cx: float, cy: float, r: float, **kw: Any) -> "Figure":
self.elements.append(dict(kind="circle", cx=cx, cy=cy, r=r, **kw))
return self
def line(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure":
self.elements.append(dict(kind="line", x1=x1, y1=y1, x2=x2, y2=y2, **kw))
return self
def polyline(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure":
self.elements.append(dict(kind="polyline", points=[list(p) for p in points], **kw))
return self
def polygon(self, points: Sequence[Sequence[float]], **kw: Any) -> "Figure":
self.elements.append(dict(kind="polygon", points=[list(p) for p in points], **kw))
return self
def path(self, d: str, **kw: Any) -> "Figure":
self.elements.append(dict(kind="path", d=d, **kw))
return self
def arrow(self, x1: float, y1: float, x2: float, y2: float, **kw: Any) -> "Figure":
"""直线箭头:``head`` 为箭头边长(像素),``dash`` 支持虚线。"""
self.elements.append(dict(kind="arrow", x1=x1, y1=y1, x2=x2, y2=y2, **kw))
return self
def text(self, x: float, y: float, s: str, *, size: float = 12.0,
font: str = "song", color: str | None = None, anchor: str = "start",
align: str = "top", bold: bool = False, rotate: float | None = None,
dy: float | None = None, opacity: float = 1.0) -> "Figure":
"""文字。``(x, y)`` 为参考点;``align`` ∈ {top, middle, bottom} 决定
参考点落在文字块的哪条水平线上(``anchor`` 决定左右)。
"""
self.elements.append(dict(
kind="text", x=x, y=y, s=s, size=size, font=font,
color=color or PALETTE["ink"], anchor=anchor, align=align,
bold=bold, rotate=rotate, dy=dy, opacity=opacity))
return self
def legend_chips(self, x: float, y: float, items: Iterable[tuple[str, str]],
*, size: float = 11.0, gap: float = 16.0, box: float = 10.0,
font: str = "song") -> float:
"""一行"色块 + 文字"图例,返回结束时的 x 坐标。"""
cur = x
for label, color in items:
self.rect(cur, y - box * 0.75, box, box, fill=color, stroke=None, radius=2.0)
self.text(cur + box + 5.0, y + box * 0.35, label, size=size, font=font,
align="middle")
cur += box + 5.0 + _estimate_width(label, size, font) + gap
return cur
def source(self, path: str | Path, **extra: Any) -> "Figure":
"""登记本图的数据来源(相对仓库根),含 sha256 前 12 位与行数。"""
p = Path(path)
p = p if p.is_absolute() else ROOT / p
digest, lines = "-", 0
if p.is_file():
data = p.read_bytes()
digest = hashlib.sha256(data).hexdigest()[:12]
lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
self.sources.append(dict(path=str(p.relative_to(ROOT)) if p.is_relative_to(ROOT) else str(p),
sha256_12=digest, lines=lines, **extra))
return self
# ---------------------------------------------------------------- 输出
def to_svg(self) -> str:
body: list[str] = [_svg_element(el) for el in self.elements]
head = (f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_n(self.width)}" height="{_n(self.height)}" '
f'viewBox="0 0 {_n(self.width)} {_n(self.height)}" '
f'font-family="{_SVG_FAMILY["song"]}">')
meta = f"<title>{_esc(self.title)}</title>\n"
if self.caption:
meta += f"<desc>{_esc(self.caption)}</desc>\n"
return "\n".join([head, meta, f'<rect width="{_n(self.width)}" '
f'height="{_n(self.height)}" '
f'fill="{self.background}"/>', *body, "</svg>", ""])
def write(self, out_dir: str | Path, stem: str, *, scale: float = 2.0,
rasterizer: str = DEFAULT_RASTERIZER, png: bool = True) -> dict[str, Any]:
"""写出 ``<stem>.svg``(+ ``<stem>.png``),返回产出清单。"""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
svg_path = out / f"{stem}.svg"
svg_path.write_text(self.to_svg(), encoding="utf-8")
produced: dict[str, Any] = {"stem": stem, "title": self.title,
"caption": self.caption,
"svg": str(svg_path.relative_to(ROOT)),
"size": [self.width, self.height],
"sources": self.sources, "notes": self.notes}
if png:
png_path = out / f"{stem}.png"
_rasterize(svg_path, png_path, scale=scale, rasterizer=rasterizer)
_compose_text(self, png_path, scale=scale)
produced["png"] = str(png_path.relative_to(ROOT))
produced["png_scale"] = scale
return produced
# ---------------------------------------------------------------------------
# SVG 生成
# ---------------------------------------------------------------------------
def _n(value: float) -> str:
"""紧凑数字:去掉多余小数位,避免 SVG 体积膨胀。"""
if abs(value - round(value)) < 1e-9:
return str(int(round(value)))
return f"{value:.3f}".rstrip("0").rstrip(".")
def _esc(text: str) -> str:
return (str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
def _paint(kw: dict[str, Any]) -> str:
parts: list[str] = []
fill = kw.get("fill", "none")
if fill is None:
fill = "none"
parts.append(f'fill="{fill}"')
stroke = kw.get("stroke")
if stroke:
parts.append(f'stroke="{stroke}" stroke-width="{_n(kw.get("sw", 1.0))}"')
if kw.get("dash"):
dash = kw["dash"] if isinstance(kw["dash"], str) else \
" ".join(_n(v) for v in kw["dash"])
parts.append(f'stroke-dasharray="{dash}"')
parts.append(f'stroke-linejoin="{kw.get("linejoin", "miter")}"')
if kw.get("opacity") is not None:
parts.append(f'opacity="{_n(kw["opacity"])}"')
if kw.get("radius"):
parts.append(f'rx="{_n(kw["radius"])}" ry="{_n(kw["radius"])}"')
return " ".join(parts)
def _text_svg(el: dict[str, Any]) -> str:
size = el["size"]
dy = _baseline_offset(el)
anchor = {"start": "start", "middle": "middle", "end": "end"}[el["anchor"]]
family = _SVG_FAMILY[el["font"]]
weight = ' font-weight="bold"' if el["bold"] or el["font"].endswith("_bold") else ""
style = ' font-style="italic"' if el.get("italic") else ""
extra = f' opacity="{_n(el["opacity"])}"' if el.get("opacity", 1.0) < 1 else ""
rotate = f' transform="rotate({_n(el["rotate"])} {_n(el["x"])} {_n(el["y"])})"' \
if el.get("rotate") else ""
return (f'<text x="{_n(el["x"])}" y="{_n(el["y"] + dy)}" font-size="{_n(size)}" '
f'font-family="{family}"{weight}{style} fill="{el["color"]}" '
f'text-anchor="{anchor}"{extra}{rotate}>{_esc(el["s"])}</text>')
def _baseline_offset(el: dict[str, Any]) -> float:
"""参考点 → SVG 基线(alphabetic)的 y 偏移。"""
if el.get("dy") is not None:
return float(el["dy"])
if el["align"] == "middle":
return el["size"] * 0.35
if el["align"] == "bottom":
return 0.0
return el["size"] * 0.82
def _svg_element(el: dict[str, Any]) -> str:
kind = el["kind"]
if kind == "text":
return _text_svg(el)
if kind == "rect":
return (f'<rect x="{_n(el["x"])}" y="{_n(el["y"])}" width="{_n(el["w"])}" '
f'height="{_n(el["h"])}" {_paint(el)}/>')
if kind == "circle":
return f'<circle cx="{_n(el["cx"])}" cy="{_n(el["cy"])}" r="{_n(el["r"])}" {_paint(el)}/>'
if kind == "line":
return (f'<line x1="{_n(el["x1"])}" y1="{_n(el["y1"])}" x2="{_n(el["x2"])}" '
f'y2="{_n(el["y2"])}" {_paint(el)}/>')
if kind in ("polyline", "polygon"):
pts = " ".join(f"{_n(px)},{_n(py)}" for px, py in el["points"])
return f'<{kind} points="{pts}" {_paint(el)}/>'
if kind == "path":
return f'<path d="{el["d"]}" {_paint(el)}/>'
if kind == "arrow":
return _arrow_svg(el)
raise ValueError(f"未知图元:{kind}")
def _arrow_svg(el: dict[str, Any]) -> str:
x1, y1, x2, y2 = el["x1"], el["y1"], el["x2"], el["y2"]
color = el.get("stroke") or PALETTE["ink"]
sw = el.get("sw", 1.4)
head = el.get("head", 8.0)
ang = math.atan2(y2 - y1, x2 - x1)
bx, by = x2 - head * math.cos(ang), y2 - head * math.sin(ang)
left = (bx - head * 0.5 * math.sin(ang), by + head * 0.5 * math.cos(ang))
right = (bx + head * 0.5 * math.sin(ang), by - head * 0.5 * math.cos(ang))
dash = f' stroke-dasharray="{el["dash"]}"' if el.get("dash") else ""
return "\n".join([
f'<line x1="{_n(x1)}" y1="{_n(y1)}" x2="{_n(bx)}" y2="{_n(by)}" '
f'stroke="{color}" stroke-width="{_n(sw)}"{dash}/>',
f'<polygon points="{_n(x2)},{_n(y2)} {_n(left[0])},{_n(left[1])} '
f'{_n(right[0])},{_n(right[1])}" fill="{color}"/>',
])
# ---------------------------------------------------------------------------
# PNG:rsvg-convert 出几何层 + Pillow 出文字层(论文同款字体)
# ---------------------------------------------------------------------------
def _rasterize(svg_path: Path, png_path: Path, *, scale: float,
rasterizer: str) -> None:
exe = shutil.which(rasterizer)
if not exe:
raise RuntimeError(f"未找到 {rasterizer}:PNG 输出需要它(brew install librsvg)")
cmd = [exe, "-f", "png", "-o", str(png_path), "--zoom", str(scale), str(svg_path)]
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
raise RuntimeError(f"{rasterizer} 失败:{proc.stderr.strip()}")
def _compose_text(fig: Figure, png_path: Path, *, scale: float) -> None:
"""把文字层用 Pillow 重画在光栅图上:与基础几何同色同坐标,字体来自
``docs/paper/fonts/``(缺失时回退系统字体)。"""
from PIL import Image, ImageDraw, ImageFont # 可选依赖:仅生成图时导入
texts = [el for el in fig.elements if el["kind"] == "text"]
if not texts:
return
img = Image.open(png_path).convert("RGBA")
layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cache: dict[tuple[str, float], Any] = {}
for el in texts:
key = (el["font"], round(el["size"] * scale, 2))
if key not in cache:
cache[key] = _load_font(el["font"], el["size"] * scale, ImageFont)
font = cache[key]
x, y = el["x"] * scale, (el["y"] + _baseline_offset(el)) * scale
anchor = {"start": "ls", "middle": "ms", "end": "rs"}[el["anchor"]]
draw.text((x, y), el["s"], font=font, fill=_rgba(el["color"], el.get("opacity", 1.0)),
anchor=anchor)
Image.alpha_composite(img, layer).convert("RGB").save(png_path, optimize=True)
def _load_font(name: str, size: float, image_font: Any) -> Any:
path = FONT_DIR / FONTS[name]
if path.is_file():
return image_font.truetype(str(path), max(6, int(round(size))))
fallback = {
"song": "/System/Library/Fonts/Supplemental/Songti.ttc",
"song_bold": "/System/Library/Fonts/Supplemental/Songti.ttc",
"hei": "/System/Library/Fonts/Supplemental/Songti.ttc",
"hei_bold": "/System/Library/Fonts/Supplemental/Songti.ttc",
"times": "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
"times_bold": "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
}[name]
return image_font.truetype(fallback, max(6, int(round(size))))
def _rgba(color: str, opacity: float) -> tuple[int, int, int, int]:
value = color.lstrip("#")
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16),
max(0, min(255, int(round(opacity * 255)))))
def _estimate_width(text: str, size: float, font: str) -> float:
"""粗略宽度估计(仅用于图例排版留白):CJK 按 1.0 em,西文按 0.5 em。"""
wide = sum(1 for ch in text if ord(ch) > 0x2E80)
narrow = len(text) - wide
factor = 1.0 if font.startswith(("song", "hei")) else 0.98
return (wide * size + narrow * size * 0.52) * factor
def write_manifest(out_dir: str | Path, entries: Sequence[dict[str, Any]],
command: str) -> Path:
"""落盘 manifest.json:图清单 + 数据来源 + 复现命令。"""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
doc = {
"generator": "scripts/build_docs_figures.py",
"command": command,
"palette": {k: v for k, v in PALETTE.items() if k != "series"},
"fonts": {"dir": "docs/paper/fonts", "map": FONTS},
"figures": list(entries),
}
path = out / "manifest.json"
path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path