"""论文级图表工具:统一配色、场景图 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'') meta = f"{_esc(self.title)}\n" if self.caption: meta += f"{_esc(self.caption)}\n" return "\n".join([head, meta, f'', *body, "", ""]) def write(self, out_dir: str | Path, stem: str, *, scale: float = 2.0, rasterizer: str = DEFAULT_RASTERIZER, png: bool = True) -> dict[str, Any]: """写出 ``.svg``(+ ``.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("&", "&").replace("<", "<").replace(">", ">")) 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'{_esc(el["s"])}') 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'') if kind == "circle": return f'' if kind == "line": return (f'') 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'' 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'', f'', ]) # --------------------------------------------------------------------------- # 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