Files
rui40000-RUI-Nodes/mdimg/renderer.py
T
rui40000andClaude Fable 5 d419f9262c 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>
2026-07-24 13:03:46 +08:00

667 lines
27 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# -*- 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")