This commit is contained in:
Horldsence
2026-09-13 08:29:30 +08:00
parent f71d7d9288
commit 71688b2ab2
10 changed files with 3064 additions and 702 deletions
+372
View File
@@ -0,0 +1,372 @@
"""论文级图表工具:统一配色、场景图 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