feat: 新增 Markdown 转图片节点(阅读器级排版渲染)

- 纯 PIL 自研排版引擎(mdimg/ 子包:解析器+字体管理+渲染器),零新增依赖
- 视觉对标 GitHub/Typora:标题层级字号与 h1/h2 底线、引用左竖条、
  代码块圆角底色+等宽字体+语言标签、表格圆角外框/表头底色/斑马纹/
  列对齐/超宽自动压缩换行、任务清单勾选框、列表三级项目符号
- 彩色 Emoji(系统 seguiemj,正文与表格内均可);代码中的中文自动回退正文字体
- 尺寸:9 种常用预设快选 + custom 精确宽高;浅色/深色/米色三主题
- 各级字号(正文+H1~H6)、字间距、行间距、单行字数上限均支持 auto/手动,
  auto 字号二分搜索恰好优雅填满画布,超高裁剪并控制台提示
- 字体下拉扫描 font/ 目录,粗体真字重自动配对(msyh->msyhbd),
  空目录回退系统字体;字体二进制因版权与体积不入库(见 font/README.md)
- 修复解析器对单列表格的死循环,14 例边角输入鲁棒性测试通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-07-24 13:03:46 +08:00
co-authored by Claude Fable 5
parent e09930ea45
commit d419f9262c
9 changed files with 1369 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""mdimg —— Markdown 转图片渲染引擎(纯 PIL 实现,供 markdown_image_node 使用)。"""
from .fonts import scan_fonts, DEFAULT_FONT_LABEL
from .renderer import MarkdownImageRenderer
__all__ = ["scan_fonts", "DEFAULT_FONT_LABEL", "MarkdownImageRenderer"]
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""
字体扫描与加载
==============
- 正文字体来自 Ruinode/font 目录(用户自备,ttf/otf/ttc),目录为空时回退系统常用中文字体。
- 自动配对同族粗体/斜体文件(msyh -> msyhbd,arial -> arialbd/ariali/arialbi),
配不到粗体时渲染层用描边模拟。
- Emoji 用系统彩色字体(Windows: seguiemj.ttf),代码块用系统等宽字体(consola.ttf)。
"""
import os
from PIL import ImageFont
try: # Raqm 可用时启用复杂文本布局(ZWJ 组合 emoji 等),不可用则静默用基础布局
from PIL import features as _features
_HAS_RAQM = bool(_features.check("raqm"))
except Exception:
_HAS_RAQM = False
_HERE = os.path.dirname(os.path.abspath(__file__))
FONT_DIR = os.path.abspath(os.path.join(_HERE, "..", "font"))
_WIN_FONTS = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
_EXTS = (".ttf", ".otf", ".ttc", ".otc")
# 常见字体文件名 -> 友好显示名
_FRIENDLY = {
"msyh": "微软雅黑", "msyhbd": "微软雅黑 粗体", "msyhl": "微软雅黑 细体",
"deng": "等线", "dengb": "等线 粗体", "dengl": "等线 细体",
"simhei": "黑体", "simsun": "宋体", "simkai": "楷体", "simfang": "仿宋",
"simyou": "幼圆", "stxihei": "华文细黑", "stzhongs": "华文中宋",
"arial": "Arial", "arialbd": "Arial 粗体", "ariali": "Arial 斜体",
"arialbi": "Arial 粗斜体", "ariblk": "Arial Black", "arialn": "Arial Narrow",
"times": "Times New Roman", "consola": "Consolas",
}
# 下拉排序优先级(前缀匹配)
_PRIORITY = ["msyh", "deng", "simhei", "simsun", "simkai", "simfang",
"simyou", "stxihei", "arial", "times"]
# 这些文件名貌似粗体后缀实为其他用途,禁止配对为粗体(simsunb 是宋体生僻字扩展)
_BOLD_BLACKLIST = {"simsunb"}
DEFAULT_FONT_LABEL = "微软雅黑 (msyh)"
def _rank(item):
stem = os.path.splitext(os.path.basename(item[1]))[0].lower()
for i, p in enumerate(_PRIORITY):
if stem.startswith(p):
return (i, stem)
return (len(_PRIORITY), stem)
def scan_fonts():
"""扫描可选正文字体,返回 {显示名: 绝对路径}(有序)。"""
found = {}
if os.path.isdir(FONT_DIR):
for fn in sorted(os.listdir(FONT_DIR)):
if fn.lower().endswith(_EXTS):
stem = os.path.splitext(fn)[0]
friendly = _FRIENDLY.get(stem.lower())
disp = f"{friendly} ({stem})" if friendly else stem
found[disp] = os.path.join(FONT_DIR, fn)
if not found: # font 目录为空:回退系统常用中文字体
for fn in ("msyh.ttc", "simhei.ttf", "simsun.ttc", "arial.ttf"):
p = os.path.join(_WIN_FONTS, fn)
if os.path.isfile(p):
stem = os.path.splitext(fn)[0]
friendly = _FRIENDLY.get(stem, stem)
found[f"{friendly} ({stem}) [系统]"] = p
if not found: # 彻底没有:内置位图字体兜底(不支持中文,仅保证节点可用)
found["(内置默认字体,请向 Ruinode/font 放入 ttf)"] = ""
return dict(sorted(found.items(), key=_rank))
def _dir_lower_map(d):
try:
return {fn.lower(): fn for fn in os.listdir(d)}
except OSError:
return {}
def resolve_family(regular_path):
"""
由正文字体推导同族变体,返回
{"regular": path, "bold": path|None, "italic": path|None, "bold_italic": path|None}
"""
fam = {"regular": regular_path, "bold": None, "italic": None, "bold_italic": None}
if not regular_path:
return fam
d = os.path.dirname(regular_path)
stem = os.path.splitext(os.path.basename(regular_path))[0].lower()
lm = _dir_lower_map(d)
def find(stems):
for s in stems:
if s in _BOLD_BLACKLIST:
continue
for e in _EXTS:
real = lm.get(s + e)
if real:
return os.path.join(d, real)
return None
fam["bold"] = find([stem + "bd", stem + "b", stem + "-bold", stem + "_bold"])
fam["italic"] = find([stem + "i", stem + "-italic", stem + "_italic"])
fam["bold_italic"] = find([stem + "bi", stem + "z", stem + "-bolditalic"])
return fam
def emoji_path():
"""彩色 Emoji 字体路径;找不到返回 None(届时 emoji 按普通字形绘制)。"""
cands = [
os.path.join(FONT_DIR, "seguiemj.ttf"),
os.path.join(_WIN_FONTS, "seguiemj.ttf"),
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
"/System/Library/Fonts/Apple Color Emoji.ttc",
]
for p in cands:
if os.path.isfile(p):
return p
return None
def mono_path(fallback):
"""等宽代码字体路径;找不到时退回正文字体。"""
cands = [
os.path.join(FONT_DIR, "consola.ttf"),
os.path.join(_WIN_FONTS, "consola.ttf"),
os.path.join(_WIN_FONTS, "cour.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
]
for p in cands:
if os.path.isfile(p):
return p
return fallback
_cache = {}
def load(path, size):
"""加载字体(带缓存)。path 为空/失败时退回 PIL 内置字体。"""
size = max(4, int(round(size)))
key = (path, size)
if key in _cache:
return _cache[key]
font = None
if path:
layout = getattr(ImageFont, "Layout", None)
engines = ([layout.RAQM] if (_HAS_RAQM and layout) else []) + \
([layout.BASIC] if layout else []) + [None]
for engine in engines:
try:
if engine is None:
font = ImageFont.truetype(path, size, index=0)
else:
font = ImageFont.truetype(path, size, index=0, layout_engine=engine)
break
except Exception:
font = None
if font is None:
try:
font = ImageFont.load_default(size)
except TypeError: # 旧版 Pillow 无 size 参数
font = ImageFont.load_default()
_cache[key] = font
return font
+306
View File
@@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""
Markdown 解析器(为图片渲染定制的轻量实现,零第三方依赖)
=========================================================
块级:标题 / 段落 / 围栏代码块 / 引用(可嵌套)/ 有序·无序·任务列表(可嵌套)/
表格(含对齐)/ 分割线
内联:`code`、**粗**、*斜*、***粗斜***、~~删除~~、[链接](url)、![图片](url)
约定:段落内的单个换行按「硬换行」处理(贴长文的用户通常期望所见即所得),
空行分段。
块结构(dict):
{"type":"heading","level":1-6,"inline":[...]}
{"type":"paragraph","lines":[[inline...],...]}
{"type":"code","lang":str,"lines":[str,...]}
{"type":"quote","children":[block,...]}
{"type":"list","ordered":bool,"start":int,
"items":[{"inline":[...],"task":None|"todo"|"done","children":[block,...]},...]}
{"type":"table","align":["left|center|right",...],
"header":[[inline...],...],"rows":[[[inline...],...],...]}
{"type":"hr"}
内联 token(dict):
{"text":str,"bold":bool,"italic":bool,"code":bool,"strike":bool,
"link":str|None,"image":bool}
"""
import re
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
_HR_RE = re.compile(r"^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$")
_FENCE_RE = re.compile(r"^ {0,3}(```+|~~~+)\s*([^\s`]*)\s*$")
_LIST_RE = re.compile(r"^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$")
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
_QUOTE_RE = re.compile(r"^ {0,3}>\s?(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
# ────────────────────────── 内联解析 ──────────────────────────
_INLINE_RE = re.compile(
r"(?P<code>`+)(?P<code_t>.+?)(?P=code)"
r"|(?P<bi>\*\*\*|___)(?P<bi_t>[^*_]+?)(?P=bi)"
r"|(?P<b>\*\*|__)(?P<b_t>.+?)(?P=b)"
r"|(?P<i>\*)(?P<i_t>[^*]+?)\*"
r"|(?<![0-9A-Za-z_])_(?P<iu_t>[^_]+?)_(?![0-9A-Za-z_])"
r"|(?P<s>~~)(?P<s_t>.+?)~~"
r"|(?P<img>!)?\[(?P<lk_t>[^\]]*)\]\((?P<lk_u>[^)\s]*)[^)]*\)"
)
def _tok(text, base, **over):
t = dict(base)
t.update(over)
t["text"] = text
return t
_BASE_STYLE = {"text": "", "bold": False, "italic": False, "code": False,
"strike": False, "link": None, "image": False}
def parse_inline(text, base=None):
"""把一行文本解析为内联 token 列表。"""
base = dict(base or _BASE_STYLE)
out = []
pos = 0
for m in _INLINE_RE.finditer(text):
if m.start() > pos:
out.append(_tok(text[pos:m.start()], base))
g = m.groupdict()
if g["code"] is not None:
out.append(_tok(g["code_t"], base, code=True))
elif g["bi"] is not None:
out.extend(parse_inline(g["bi_t"], {**base, "bold": True, "italic": True}))
elif g["b"] is not None:
out.extend(parse_inline(g["b_t"], {**base, "bold": True}))
elif g["i_t"] is not None:
out.extend(parse_inline(g["i_t"], {**base, "italic": True}))
elif g["iu_t"] is not None:
out.extend(parse_inline(g["iu_t"], {**base, "italic": True}))
elif g["s"] is not None:
out.extend(parse_inline(g["s_t"], {**base, "strike": True}))
elif g["lk_t"] is not None:
if g["img"]:
out.append(_tok(g["lk_t"] or "图片", base, image=True, link=g["lk_u"]))
else:
out.extend(parse_inline(g["lk_t"], {**base, "link": g["lk_u"] or "#"}))
pos = m.end()
if pos < len(text):
out.append(_tok(text[pos:], base))
return [t for t in out if t["text"] != "" or t["image"]]
# ────────────────────────── 块级解析 ──────────────────────────
def _split_cells(line):
"""拆表格行的单元格(支持 \\| 转义)。"""
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith("\\|"):
s = s[:-1]
cells, cur, esc = [], [], False
for ch in s:
if esc:
cur.append(ch)
esc = False
elif ch == "\\":
esc = True
elif ch == "|":
cells.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
cells.append("".join(cur).strip())
return cells
def _parse_align(sep_line):
aligns = []
for c in _split_cells(sep_line):
c = c.strip()
left, right = c.startswith(":"), c.endswith(":")
aligns.append("center" if left and right else
"right" if right else "left")
return aligns
def _indent_width(s):
w = 0
for ch in s:
if ch == " ":
w += 1
elif ch == "\t":
w += 4
else:
break
return w
def _is_table_start(lines, i):
"""当前行 + 下一行是否构成表格开头(单列表格如 "| a |"+"|---|" 也成立;
纯 "---" 留给分割线)。表格分支与段落中断判定必须共用本函数,避免解析死循环。"""
if "|" not in lines[i] or i + 1 >= len(lines):
return False
nxt = lines[i + 1]
if not _TABLE_SEP_RE.match(nxt):
return False
return "|" in nxt or len(_split_cells(nxt)) >= 2
def _parse_list(lines, i):
"""从第 i 行开始收集列表,返回 (块列表, next_i)。
同级项的 marker 在有序/无序间切换时拆分为独立列表块。"""
entries = [] # (indent, marker, text)
while i < len(lines):
m = _LIST_RE.match(lines[i])
if m:
entries.append((_indent_width(m.group(1)), m.group(2), m.group(3)))
i += 1
elif lines[i].strip() == "":
# 空行后若仍是列表则继续(宽松处理)
if i + 1 < len(lines) and _LIST_RE.match(lines[i + 1]):
i += 1
else:
break
else:
break
def build_seq(seq):
"""同层条目 -> 列表块序列(marker 类型变化即切块)。"""
base = seq[0][0]
out, items, cur_ordered, cur_start = [], [], None, 1
def close():
nonlocal items
if items:
out.append({"type": "list", "ordered": cur_ordered,
"start": cur_start, "items": items})
items = []
j = 0
while j < len(seq):
ind, marker, text = seq[j]
ordered = marker[0].isdigit()
k = j + 1
sub = []
while k < len(seq) and seq[k][0] > base + 1:
sub.append(seq[k])
k += 1
if ordered != cur_ordered:
close()
cur_ordered = ordered
if ordered:
try:
cur_start = int(re.match(r"\d+", marker).group(0))
except Exception:
cur_start = 1
task = None
tm = _TASK_RE.match(text)
if tm and not ordered:
task = "done" if tm.group(1).lower() == "x" else "todo"
text = tm.group(2)
items.append({"inline": parse_inline(text), "task": task,
"children": build_seq(sub) if sub else []})
j = k
close()
return out
return build_seq(entries), i
def parse_blocks(text):
"""Markdown 全文 -> 块列表。"""
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
blocks = []
i, n = 0, len(lines)
while i < n:
line = lines[i]
stripped = line.strip()
if stripped == "":
i += 1
continue
# 围栏代码块
fm = _FENCE_RE.match(line)
if fm:
fence, lang = fm.group(1)[0] * 3, fm.group(2)
body = []
i += 1
while i < n and not _FENCE_RE.match(lines[i]):
body.append(lines[i].replace("\t", " "))
i += 1
i += 1 # 跳过闭合围栏
blocks.append({"type": "code", "lang": lang, "lines": body})
continue
# 标题
hm = _HEADING_RE.match(line)
if hm:
blocks.append({"type": "heading", "level": len(hm.group(1)),
"inline": parse_inline(hm.group(2))})
i += 1
continue
# 分割线
if _HR_RE.match(line):
blocks.append({"type": "hr"})
i += 1
continue
# 引用
qm = _QUOTE_RE.match(line)
if qm:
inner = []
while i < n:
q = _QUOTE_RE.match(lines[i])
if q:
inner.append(q.group(1))
i += 1
elif lines[i].strip() != "" and inner and not _LIST_RE.match(lines[i]):
inner.append(lines[i].strip()) # 懒接续
i += 1
else:
break
blocks.append({"type": "quote", "children": parse_blocks("\n".join(inner))})
continue
# 表格:当前行含 | 且下一行是分隔行
if _is_table_start(lines, i):
header = [parse_inline(c) for c in _split_cells(line)]
align = _parse_align(lines[i + 1])
ncol = len(header)
align = (align + ["left"] * ncol)[:ncol]
rows = []
i += 2
while i < n and "|" in lines[i] and lines[i].strip() != "":
cells = [parse_inline(c) for c in _split_cells(lines[i])]
cells = (cells + [[] for _ in range(ncol)])[:ncol]
rows.append(cells)
i += 1
blocks.append({"type": "table", "align": align,
"header": header, "rows": rows})
continue
# 列表(marker 类型切换会拆出多个块)
if _LIST_RE.match(line):
blks, i = _parse_list(lines, i)
blocks.extend(blks)
continue
# 段落:收集到空行或下一个特殊块,单换行=硬换行
para = []
while i < n:
cur = lines[i]
if (cur.strip() == "" or _HEADING_RE.match(cur) or _HR_RE.match(cur)
or _FENCE_RE.match(cur) or _QUOTE_RE.match(cur)
or _LIST_RE.match(cur) or _is_table_start(lines, i)):
break
para.append(parse_inline(cur.strip()))
i += 1
if para:
blocks.append({"type": "paragraph", "lines": para})
else:
i += 1 # 兜底:任何未被消费的行强制前进,杜绝解析死循环
return blocks
+666
View File
@@ -0,0 +1,666 @@
# -*- coding: utf-8 -*-
"""
Markdown 排版渲染器(纯 PIL)
============================
视觉规范对标 GitHub / Typora 阅读器:
- 标题层级字号比例 2.0 / 1.5 / 1.25 / 1.0 / 0.875 / 0.85,h1、h2 带底部分隔线
- 正文行高 1.65,段距 0.9em,页边距为宽度的 5.5%
- 引用块左竖条 + 弱化文字色;代码块圆角底色 + 等宽字体 + 语言标签
- 表格圆角外框、表头底色加粗、斑马纹、列宽自适应(超宽自动压缩换行)
- 列表实心/空心/方形三级项目符号,任务清单绘制勾选框
- Emoji 用系统彩色字体渲染,粗体优先真字重、无字重文件时描边模拟
排版流程:解析 -> (自动模式)二分基准字号 -> 生成绘制指令 -> 绘制。
"""
import re
from PIL import Image, ImageDraw
from . import fonts
from .parser import parse_blocks
THEMES = {
"light": dict(bg="#ffffff", fg="#1f2328", muted="#59636e", border="#d1d9e0",
code_bg="#f6f8fa", icode_bg="#eff1f3", quote_bar="#d1d9e0",
link="#0969da", hr="#d8dee4", head_bg="#f6f8fa", zebra="#f6f8fa",
accent="#0969da"),
"dark": dict(bg="#0d1117", fg="#e6edf3", muted="#9198a1", border="#3d444d",
code_bg="#161b22", icode_bg="#2a313c", quote_bar="#3d444d",
link="#4493f8", hr="#30363d", head_bg="#161b22", zebra="#151b23",
accent="#4493f8"),
"sepia": dict(bg="#f8f1e3", fg="#3d3226", muted="#7a6a55", border="#dccbb0",
code_bg="#efe5d0", icode_bg="#ece0c8", quote_bar="#d5c4a5",
link="#0f6674", hr="#dccbb0", head_bg="#efe5d0", zebra="#f2e9d8",
accent="#8a6a3a"),
}
HEAD_RATIO = {1: 2.0, 2: 1.5, 3: 1.25, 4: 1.0, 5: 0.875, 6: 0.85}
# Emoji 序列(含变体选择符、肤色、ZWJ 组合、旗帜)
_EMOJI_RE = re.compile(
"(?:[\U0001F1E6-\U0001F1FF]{2})"
"|(?:[©®‼⁉™ℹ↔-↪⌚-⏺"
"Ⓜ▪-◾☀-➿⤴⤵⬀-⯿〰〽"
"㊗㊙\U0001F000-\U0001FAFF]"
"[️]?[\U0001F3FB-\U0001F3FF]?"
"(?:‍[☀-➿\U0001F000-\U0001FAFF][️]?[\U0001F3FB-\U0001F3FF]?)*)"
)
_WORD_RE = re.compile(r"[A-Za-z0-9À-ɏ]+(?:['’\-_.][A-Za-z0-9À-ɏ]+)*")
def _clusters(text):
"""把文本切成排版簇:emoji 序列 / 拉丁词 / 空白 / 单个 CJK 或其他字符。"""
out = []
i, n = 0, len(text)
while i < n:
m = _EMOJI_RE.match(text, i)
if m:
out.append(("emoji", m.group(0)))
i = m.end()
continue
m = _WORD_RE.match(text, i)
if m:
out.append(("word", m.group(0)))
i = m.end()
continue
ch = text[i]
out.append(("space" if ch in " \t" else "char", ch))
i += 1
return out
class MarkdownImageRenderer:
"""把 Markdown 文本渲染为 PIL Image。"""
def __init__(self, width, height, font_path, theme="light",
sizes=None, letter_spacing=None, line_spacing=None,
max_chars=None):
"""
sizes: {"body":px|None, "h1":..~"h6":..},None 表示自动
letter_spacing: None 自动(0) 或像素值
line_spacing: None 自动 或行高倍数(覆盖正文/列表/引用/表格)
max_chars: None 自动(不限) 或单行字数上限
"""
self.W, self.H = int(width), int(height)
self.pal = THEMES.get(theme, THEMES["light"])
self.fam = fonts.resolve_family(font_path)
self.mono_fam = fonts.resolve_family(fonts.mono_path(font_path))
self.emoji_path = fonts.emoji_path()
self.manual = {k: v for k, v in (sizes or {}).items() if v}
self.lsp = float(letter_spacing or 0)
self.lmult = line_spacing
self.maxc = int(max_chars) if max_chars else 0
self.pad = int(min(max(self.W * 0.055, 24), 120))
self.avail_w = self.W - 2 * self.pad
self.avail_h = self.H - 2 * self.pad
self._mcache = {}
self.overflow = False
# ────────── 字体与测量 ──────────
def _path_of(self, fkey):
if fkey.startswith("mono"):
fam = self.mono_fam
key = {"mono": "regular", "mono_bold": "bold"}[fkey]
return fam[key] or fam["regular"]
p = self.fam.get(fkey)
if not p:
if fkey == "bold_italic":
p = self.fam.get("bold") or self.fam.get("italic")
p = p or self.fam["regular"]
return p
def _font(self, fkey, size):
return fonts.load(self._path_of(fkey), size)
def _tok_render(self, tok, base_size):
"""token -> (fkey, 绘制字号, 模拟粗体描边宽)。"""
size = base_size * (0.875 if tok["code"] else 1.0)
if tok["code"]:
fkey = "mono_bold" if tok["bold"] and self.mono_fam["bold"] else "mono"
stroke = 0 if (not tok["bold"] or self.mono_fam["bold"]) else max(1, round(size * 0.03))
return fkey, size, stroke
stroke = 0
if tok["bold"] and tok["italic"]:
fkey = "bold_italic" if self.fam["bold_italic"] else \
("bold" if self.fam["bold"] else "italic" if self.fam["italic"] else "regular")
if not (self.fam["bold_italic"] or self.fam["bold"]):
stroke = max(1, round(size * 0.03))
elif tok["bold"]:
fkey = "bold" if self.fam["bold"] else "regular"
if not self.fam["bold"]:
stroke = max(1, round(size * 0.03))
elif tok["italic"]:
fkey = "italic" if self.fam["italic"] else "regular"
else:
fkey = "regular"
return fkey, size, stroke
def _width(self, cluster, fkey, size):
key = (cluster, fkey, round(size, 1))
w = self._mcache.get(key)
if w is None:
if fkey == "emoji":
if self.emoji_path:
w = fonts.load(self.emoji_path, size).getlength(cluster)
else:
w = self._font("regular", size).getlength(cluster)
else:
w = self._font(fkey, size).getlength(cluster)
self._mcache[key] = w
return w
def _metrics(self, fkey, size):
key = ("__m__", fkey, round(size, 1))
m = self._mcache.get(key)
if m is None:
m = self._font(fkey, size).getmetrics()
self._mcache[key] = m
return m # (ascent, descent)
# ────────── 行内排版 ──────────
def _prep_tokens(self, tokens):
"""图片 token 转为占位文本。"""
out = []
for t in tokens:
if t.get("image"):
t = dict(t, text="🖼 " + (t["text"] or "图片"), image=False, muted=True)
out.append(t)
return out
def layout_inline(self, tokens, avail_w, size, mult):
"""
内联 token -> 行列表。每行 (segs, line_w),
seg = (cluster, kind, tok, fkey, draw_size, w, stroke)。
"""
segs_all = []
for tok in self._prep_tokens(tokens):
fkey, dsize, stroke = self._tok_render(tok, size)
for kind, cl in _clusters(tok["text"]):
if kind == "emoji":
fk = "emoji"
elif fkey.startswith("mono") and not cl.isascii():
# 等宽字体通常无中文字形:非 ASCII 簇回退正文字体
fk = "bold" if (tok["bold"] and self.fam["bold"]) else "regular"
else:
fk = fkey
w = self._width(cl, fk, dsize) + self.lsp
segs_all.append((cl, kind, tok, fk, dsize, w, stroke))
lines, cur, cur_w, cur_n = [], [], 0.0, 0
limit = self.maxc if self.maxc > 0 else 10 ** 9
def flush():
nonlocal cur, cur_w, cur_n
while cur and cur[-1][0][1] == "space": # 去行尾空白
cur.pop()
lines.append(([s for s, _ in cur], sum(s[5] for s, _ in cur)))
cur, cur_w, cur_n = [], 0.0, 0
for seg in segs_all:
cl, kind, tok, fk, dsize, w, stroke = seg
n_chars = 1 if kind == "emoji" else len(cl)
if kind == "space" and not cur:
continue # 行首空白丢弃
# 超宽长词字符级硬折
if kind == "word" and w > avail_w and len(cl) > 1:
for ch in cl:
cw = self._width(ch, fk, dsize) + self.lsp
if cur and (cur_w + cw > avail_w or cur_n + 1 > limit):
flush()
cur.append(((ch, "char", tok, fk, dsize, cw, stroke), None))
cur_w += cw
cur_n += 1
continue
if cur and (cur_w + w > avail_w or cur_n + n_chars > limit):
flush()
if kind == "space":
continue
cur.append((seg, None))
cur_w += w
cur_n += n_chars
if cur:
flush()
if not lines:
lines.append(([], 0.0))
return lines
def _emit_lines(self, ops, lines, x0, avail_w, size, mult, color,
main_fkey="regular", align="left"):
"""把行列表落为绘制指令,返回消耗的总高度。"""
asc, desc = self._metrics(main_fkey, size)
line_h = size * mult
y = self._y
for segs, line_w in lines:
if align == "center":
x = x0 + max(0, (avail_w - line_w) / 2)
elif align == "right":
x = x0 + max(0, avail_w - line_w)
else:
x = x0
baseline = y + asc + max(0, (line_h - (asc + desc)) / 2)
# 行内代码背景(合并相邻 code 簇)
run_s, run_e, run_size = None, None, size
xx = x
for cl, kind, tok, fk, dsize, w, stroke in segs:
if tok["code"]:
if run_s is None:
run_s = xx
run_e = xx + w
run_size = dsize
else:
if run_s is not None:
self._icode_bg(ops, run_s, run_e, baseline, run_size)
run_s = None
xx += w
if run_s is not None:
self._icode_bg(ops, run_s, run_e, baseline, run_size)
# 文字
xx = x
for cl, kind, tok, fk, dsize, w, stroke in segs:
fill = self.pal["link"] if tok.get("link") else \
(self.pal["muted"] if tok.get("muted") else color)
if kind == "emoji":
ops.append(("emoji", xx, baseline, cl, dsize))
elif kind != "space":
ops.append(("text", xx, baseline, cl, fk, dsize, fill, stroke))
if tok.get("strike") and kind != "space":
yy = baseline - dsize * 0.30
ops.append(("line", xx - self.lsp * 0.5, yy,
xx + w - self.lsp * 0.5, yy, fill,
max(1, round(dsize * 0.055))))
xx += w
y += line_h
used = y - self._y
self._y = y
return used
def _icode_bg(self, ops, x0, x1, baseline, size):
h = size * 1.32
y0 = baseline - size * 0.98
ops.append(("rect", x0 - size * 0.18, y0, x1 + size * 0.18, y0 + h,
self.pal["icode_bg"], None, 0, size * 0.24, None))
# ────────── 块布局 ──────────
def _mult(self, kind):
if self.lmult:
return self.lmult
return {"body": 1.65, "head": 1.3, "code": 1.55, "table": 1.45}[kind]
def _gap(self, px):
if not self._first_block:
self._y += max(self._pending, px)
self._pending = 0
self._first_block = False
def layout_doc(self, blocks, base):
"""给定基准字号,生成绘制指令与内容总高。"""
self.sizes = {"body": self.manual.get("body", base)}
b = self.sizes["body"]
for lv in range(1, 7):
self.sizes[f"h{lv}"] = self.manual.get(f"h{lv}", b * HEAD_RATIO[lv])
self._y = 0.0
self._pending = 0.0
self._first_block = True
ops = []
for blk in blocks:
self._layout_block(ops, blk, 0, self.avail_w, quote_depth=0)
return ops, self._y
def _layout_block(self, ops, blk, x, avail, quote_depth):
b = self.sizes["body"]
color = self.pal["muted"] if quote_depth else self.pal["fg"]
t = blk["type"]
if t == "heading":
size = self.sizes[f"h{blk['level']}"]
self._gap(b * 1.25)
fkey = "bold" if self.fam["bold"] else "regular"
toks = [dict(tk, bold=True) for tk in blk["inline"]]
col = self.pal["muted"] if blk["level"] == 6 else color
lines = self.layout_inline(toks, avail, size, self._mult("head"))
self._emit_lines(ops, lines, x, avail, size, self._mult("head"), col, fkey)
if blk["level"] in (1, 2):
self._y += b * 0.35
ops.append(("line", x, self._y, x + avail, self._y,
self.pal["hr"], max(1, round(b * 0.06))))
self._y += b * 0.15
self._pending = b * 0.55
elif t == "paragraph":
self._gap(b * 0.55)
for ln in blk["lines"]:
lines = self.layout_inline(ln, avail, b, self._mult("body"))
self._emit_lines(ops, lines, x, avail, b, self._mult("body"), color)
self._pending = b * 0.9
elif t == "hr":
self._gap(b * 1.3)
yy = self._y + b * 0.1
ops.append(("line", x, yy, x + avail, yy, self.pal["hr"],
max(2, round(b * 0.12))))
self._y = yy + b * 0.1
self._pending = b * 1.3
elif t == "code":
self._gap(b * 0.9)
self._layout_code(ops, blk, x, avail)
self._pending = b * 0.9
elif t == "quote":
self._gap(b * 0.9)
top = self._y
bar_w = max(3, round(b * 0.22))
inner_x = x + bar_w + b * 0.9
self._pending = 0
self._first_block = True
for child in blk["children"]:
self._layout_block(ops, child, inner_x, avail - (inner_x - x),
quote_depth + 1)
ops.append(("rect", x, top + b * 0.1, x + bar_w, self._y - b * 0.05,
self.pal["quote_bar"], None, 0, bar_w / 2, None))
self._pending = b * 0.9
elif t == "list":
self._gap(b * 0.55)
self._layout_list(ops, blk, x, avail, depth=0, color=color)
self._pending = b * 0.9
elif t == "table":
self._gap(b * 0.9)
self._layout_table(ops, blk, x, avail)
self._pending = b * 0.9
# ── 代码块 ──
def _layout_code(self, ops, blk, x, avail):
b = self.sizes["body"]
size = b * 0.875
mult = self._mult("code")
pad = b * 0.85
asc, desc = self._metrics("mono", size)
line_h = size * mult
inner_w = avail - 2 * pad
def ch_font(ch):
# Consolas 等等宽字体无中文字形:非 ASCII 用正文字体
return "mono" if ch.isascii() else "regular"
# 逐字符折行,行内按 (字体,字符串) 分段
vis_lines = [] # 每行: [(fkey, run_str, run_w), ...]
for raw in blk["lines"]:
cur, cur_w = [], 0.0 # cur: [(fkey, str, w)]
if not raw:
vis_lines.append([])
continue
for ch in raw:
fk = ch_font(ch)
cw = self._width(ch, fk, size)
if cur and cur_w + cw > inner_w:
vis_lines.append(cur)
cur, cur_w = [], 0.0
if cur and cur[-1][0] == fk:
cur[-1] = (fk, cur[-1][1] + ch, cur[-1][2] + cw)
else:
cur.append((fk, ch, cw))
cur_w += cw
vis_lines.append(cur)
if not vis_lines:
vis_lines = [[]]
box_h = len(vis_lines) * line_h + 2 * pad
top = self._y
ops.append(("rect", x, top, x + avail, top + box_h,
self.pal["code_bg"], self.pal["border"],
1, b * 0.5, None))
if blk["lang"]:
lw = self._width(blk["lang"], "mono", size * 0.8)
ops.append(("text", x + avail - pad - lw, top + pad * 0.55 + size * 0.8,
blk["lang"], "mono", size * 0.8, self.pal["muted"], 0))
yy = top + pad
for runs in vis_lines:
baseline = yy + asc + max(0, (line_h - (asc + desc)) / 2)
xx = x + pad
for fk, s, w in runs:
ops.append(("text", xx, baseline, s, fk, size, self.pal["fg"], 0))
xx += w
yy += line_h
self._y = top + box_h
# ── 列表 ──
def _layout_list(self, ops, blk, x, avail, depth, color):
b = self.sizes["body"]
indent = b * 1.7
mult = self._mult("body")
asc, desc = self._metrics("regular", b)
num = blk["start"]
for idx, item in enumerate(blk["items"]):
if idx > 0 or depth > 0:
self._y += b * 0.3
top = self._y
line_h = b * mult
first_baseline = top + asc + max(0, (line_h - (asc + desc)) / 2)
cx = x + indent * 0.42
if item["task"] is not None:
s = b * 0.95
y0 = first_baseline - asc * 0.86
r = (x + indent * 0.06, y0, x + indent * 0.06 + s, y0 + s)
if item["task"] == "done":
ops.append(("rect", *r, self.pal["accent"], None, 0, s * 0.22, None))
ops.append(("check", r[0], r[1], s))
else:
ops.append(("rect", *r, None, self.pal["border"],
max(1, round(b * 0.09)), s * 0.22, None))
elif blk["ordered"]:
label = f"{num}."
lw = self._width(label, "regular", b)
ops.append(("text", x + indent - lw - b * 0.45, first_baseline,
label, "regular", b, self.pal["muted"], 0))
else:
r = b * (0.14 if depth == 0 else 0.12)
cy = first_baseline - asc * 0.32
if depth == 0:
ops.append(("dot", cx, cy, r, color, True))
elif depth == 1:
ops.append(("dot", cx, cy, r, color, False))
else:
ops.append(("rect", cx - r * 0.9, cy - r * 0.9,
cx + r * 0.9, cy + r * 0.9, color, None, 0, 1, None))
num += 1
lines = self.layout_inline(item["inline"], avail - indent, b, mult)
self._emit_lines(ops, lines, x + indent, avail - indent, b, mult, color)
for child in item["children"]:
self._y += b * 0.25
self._layout_list(ops, child, x + indent, avail - indent,
depth + 1, color)
# ── 表格 ──
def _layout_table(self, ops, blk, x, avail):
b = self.sizes["body"]
size = b * 0.95
mult = self._mult("table")
padx, pady = b * 0.8, b * 0.55
ncol = len(blk["header"])
if ncol == 0:
return
rows_all = [blk["header"]] + blk["rows"]
def nat_min(cell):
segs = []
for tok in self._prep_tokens(cell):
fkey, dsize, _ = self._tok_render(tok, size)
for kind, cl in _clusters(tok["text"]):
fk = "emoji" if kind == "emoji" else fkey
segs.append(self._width(cl, fk, dsize))
return (sum(segs), max(segs) if segs else size)
nat = [0.0] * ncol
mn = [size * 1.5] * ncol
for row in rows_all:
for j in range(ncol):
w, m = nat_min(row[j] if j < len(row) else [])
nat[j] = max(nat[j], w)
mn[j] = max(mn[j], min(m, avail * 0.6))
col_w = [nat[j] + 2 * padx for j in range(ncol)]
total = sum(col_w)
if total > avail: # 压缩:先按可压缩空间比例,仍不足则按比例硬压
shrink = [max(0.0, nat[j] - mn[j]) for j in range(ncol)]
need = total - avail
pool = sum(shrink)
if pool > 0:
take = min(need, pool)
col_w = [col_w[j] - (shrink[j] / pool) * take for j in range(ncol)]
need -= take
if need > 0:
scale = (sum(col_w) - need) / sum(col_w)
col_w = [w * scale for w in col_w]
total = sum(col_w)
tab_w = total
# 逐单元格排版
asc, desc = self._metrics("regular", size)
line_h = size * mult
laid = []
row_hs = []
for ridx, row in enumerate(rows_all):
cells = []
maxlines = 1
for j in range(ncol):
toks = row[j] if j < len(row) else []
if ridx == 0: # 表头加粗(先转再排,保证测量与绘制一致)
toks = [dict(tk, bold=True) for tk in toks]
inner = max(size, col_w[j] - 2 * padx)
lines = self.layout_inline(toks, inner, size, mult)
cells.append(lines)
maxlines = max(maxlines, len(lines))
laid.append(cells)
row_hs.append(maxlines * line_h + 2 * pady)
tab_h = sum(row_hs)
top = self._y
radius = b * 0.5
# 表头底色 + 斑马纹
ops.append(("rect", x, top, x + tab_w, top + row_hs[0],
self.pal["head_bg"], None, 0, radius, (True, True, False, False)))
yy = top + row_hs[0]
for ridx in range(1, len(rows_all)):
if ridx % 2 == 0:
last = ridx == len(rows_all) - 1
ops.append(("rect", x, yy, x + tab_w, yy + row_hs[ridx],
self.pal["zebra"], None, 0, radius,
(False, False, last, last)))
yy += row_hs[ridx]
# 网格线
lw = 1
yy = top
for ridx in range(len(rows_all) - 1):
yy += row_hs[ridx]
ops.append(("line", x, yy, x + tab_w, yy, self.pal["border"], lw))
xx = x
for j in range(ncol - 1):
xx += col_w[j]
ops.append(("line", xx, top, xx, top + tab_h, self.pal["border"], lw))
ops.append(("rect", x, top, x + tab_w, top + tab_h, None,
self.pal["border"], lw, radius, None))
# 文字
for ridx, cells in enumerate(laid):
cy = top + sum(row_hs[:ridx]) + pady
for j in range(ncol):
cx = x + sum(col_w[:j]) + padx
inner = max(size, col_w[j] - 2 * padx)
align = blk["align"][j] if j < len(blk["align"]) else "left"
self._y = cy
self._emit_lines(ops, cells[j], cx, inner, size, mult,
self.pal["fg"], "regular", align)
self._y = top + tab_h
# ────────── 自动字号与总渲染 ──────────
def _auto_base(self, blocks):
if "body" in self.manual:
return self.manual["body"]
lo, hi = 10, int(min(max(self.W // 22, 18), 60))
best = lo
fit_any = False
while lo <= hi:
mid = (lo + hi) // 2
_, h = self.layout_doc(blocks, mid)
if h <= self.avail_h:
best, fit_any = mid, True
lo = mid + 1
else:
hi = mid - 1
if not fit_any:
best = 10
return best
def render(self, md_text):
blocks = parse_blocks(md_text or "")
if not blocks:
blocks = [{"type": "paragraph",
"lines": [[dict(text="(空文档)", bold=False, italic=False,
code=False, strike=False, link=None,
image=False)]]}]
base = self._auto_base(blocks)
ops, content_h = self.layout_doc(blocks, base)
self.overflow = content_h > self.avail_h + 1
if self.overflow:
print(f"[Rui-Node] Markdown 内容高度 {int(content_h)}px 超出画布可用高度 "
f"{self.avail_h}px,超出部分将被裁剪(可增大高度或调小字号)")
img = Image.new("RGB", (self.W, self.H), self.pal["bg"])
draw = ImageDraw.Draw(img, "RGBA")
ox, oy = self.pad, self.pad
for op in ops:
kind = op[0]
if kind == "rect":
_, x0, y0, x1, y1, fill, outline, w, radius, corners = op
if x1 - x0 < 1 or y1 - y0 < 1:
continue
kw = dict(radius=max(0, radius), fill=fill, outline=outline,
width=max(1, int(w)) if outline else 0)
if corners is not None:
kw["corners"] = corners
try:
draw.rounded_rectangle((x0 + ox, y0 + oy, x1 + ox, y1 + oy), **kw)
except TypeError: # 旧版 Pillow 无 corners
kw.pop("corners", None)
draw.rounded_rectangle((x0 + ox, y0 + oy, x1 + ox, y1 + oy), **kw)
elif kind == "line":
_, x0, y0, x1, y1, fill, w = op
draw.line((x0 + ox, y0 + oy, x1 + ox, y1 + oy), fill=fill,
width=max(1, int(round(w))))
elif kind == "text":
_, x, y, s, fk, size, fill, stroke = op
f = self._font(fk, size)
if stroke:
draw.text((x + ox, y + oy), s, font=f, fill=fill, anchor="ls",
stroke_width=int(stroke), stroke_fill=fill)
else:
draw.text((x + ox, y + oy), s, font=f, fill=fill, anchor="ls")
elif kind == "emoji":
_, x, y, s, size = op
self._draw_emoji(draw, x + ox, y + oy, s, size)
elif kind == "dot":
_, cx, cy, r, fill, solid = op
bb = (cx + ox - r, cy + oy - r, cx + ox + r, cy + oy + r)
if solid:
draw.ellipse(bb, fill=fill)
else:
draw.ellipse(bb, outline=fill, width=max(1, int(r * 0.45)))
elif kind == "check":
_, x0, y0, s = op
x0, y0 = x0 + ox, y0 + oy
w = max(2, round(s * 0.14))
pts = [(x0 + s * 0.24, y0 + s * 0.52), (x0 + s * 0.43, y0 + s * 0.72),
(x0 + s * 0.78, y0 + s * 0.30)]
draw.line(pts, fill="#ffffff", width=w, joint="curve")
return img
def _draw_emoji(self, draw, x, y, s, size):
if self.emoji_path:
try:
f = fonts.load(self.emoji_path, size)
draw.text((x, y), s, font=f, embedded_color=True, anchor="ls")
return
except Exception:
pass
draw.text((x, y), s, font=self._font("regular", size),
fill=self.pal["fg"], anchor="ls")