- 纯 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>
167 lines
6.0 KiB
Python
167 lines
6.0 KiB
Python
# -*- 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
|