- 纯 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>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Markdown 解析器(为图片渲染定制的轻量实现,零第三方依赖)
|
||
=========================================================
|
||
块级:标题 / 段落 / 围栏代码块 / 引用(可嵌套)/ 有序·无序·任务列表(可嵌套)/
|
||
表格(含对齐)/ 分割线
|
||
内联:`code`、**粗**、*斜*、***粗斜***、~~删除~~、[链接](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
|