diff --git a/.gitignore b/.gitignore index faa3900..1a854cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,12 @@ __pycache__/ *.py[cod] *.egg-info/ .DS_Store + +# 字体二进制不入库(版权 + 体积原因,见 font/README.md) +font/*.ttf +font/*.otf +font/*.ttc +font/*.otc +font/*.TTF +font/*.OTF +font/*.TTC diff --git a/README.md b/README.md index 4c8da98..772456e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理 - [页面旁白删除器 / Page Narration Remover](#7-页面旁白删除器--page-narration-remover) - [文本列表制作器 / Text List Creator](#8-文本列表制作器--text-list-creator) - [转化为utf-8编码 / Convert to UTF-8](#11-转化为utf-8编码--convert-to-utf-8) +- [Markdown转图片 / Markdown To Image](#19-markdown转图片--markdown-to-image) ### 🎭 遮罩处理类 - [遮罩筛选 / Mask Selector](#9-遮罩筛选--mask-selector) @@ -760,6 +761,37 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输 --- +### 19. Markdown转图片 / Markdown To Image + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +输入 Markdown 文本,输出按阅读器级排版渲染的图片(IMAGE)。视觉规范对标 GitHub / Typora:标题层级字号(2.0/1.5/1.25/1.0/0.875/0.85 倍正文)、H1/H2 底部分隔线、引用左竖条、代码块圆角底色 + 等宽字体 + 语言标签、表格圆角外框 + 表头加粗底色 + 斑马纹 + 列对齐、任务清单勾选框、彩色 Emoji。纯 PIL 实现,无额外依赖。 + +**支持的 Markdown 语法**: +标题 `#`~`######`、段落(单换行即硬换行)、**粗体**、*斜体*、`行内代码`、~~删除线~~、[链接]()、有序/无序/嵌套列表、任务清单 `- [x]`、引用 `>`(可嵌套)、围栏代码块、表格(`:---:` 对齐语法)、分割线 `---`;表格与正文中的 Emoji 以系统彩色字体渲染。 + +**输入参数**: +- `markdown` (STRING): Markdown 文本 +- `size_preset` (选择): 常用尺寸快选(1080×1440 / 1080×1920 / 1080×1080 / 1920×1080 / A4 等),选 `custom` 时使用下方宽高 +- `width` / `height` (INT): 精确尺寸(64~8192px,`custom` 时生效) +- `font` (选择): 字体,列表来自 `Ruinode/font` 目录(ttf/otf/ttc 均可,放入后刷新页面即出现在下拉;同族粗体文件如 `msyhbd` 会自动配对用于渲染粗体,无粗体文件时描边模拟) +- `theme` (选择): 浅色 / 深色 / 米色三套阅读器配色 +- `body_size`、`h1_size` ~ `h6_size` (STRING, 可选): 各级字号,`auto`(默认)按输出尺寸二分搜索「恰好优雅填满画布」的字号,各级也可分别填数字精确指定 +- `letter_spacing` (STRING, 可选): 字间距像素,默认 `auto` +- `line_spacing` (STRING, 可选): 行高倍数(如 `1.8`),默认 `auto`(正文 1.65) +- `max_chars_per_line` (STRING, 可选): 单行文字字数上限,达到即换行;默认 `auto`(按像素宽自然换行) + +**输出**: +- `image` (IMAGE): 渲染结果,固定为所选宽高;内容超高时按现有字号裁剪并在控制台提示 + +**使用场景**: +- 将 LLM 输出的 Markdown(如 ZenMux 节点的 text)直接转成可分享的长图 +- 生成小红书 / 公众号风格的图文卡片、A4 打印稿 +- 工作流内把结构化报告(含表格、代码)落成图像资产 + +--- + ## 🐕 关于 Rui-Node🐶 Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。 diff --git a/__init__.py b/__init__.py index cad857e..15d8e54 100644 --- a/__init__.py +++ b/__init__.py @@ -50,6 +50,14 @@ except Exception as _e: print(f"[Ruinode] ZenMux 节点未加载:{_e}") ZENMUX_NODE_CLASS_MAPPINGS = {} ZENMUX_NODE_DISPLAY_NAME_MAPPINGS = {} +# 新增:Markdown 转图片节点(阅读器级排版渲染) +try: + from .markdown_image_node import NODE_CLASS_MAPPINGS as MDIMG_NODE_CLASS_MAPPINGS + from .markdown_image_node import NODE_DISPLAY_NAME_MAPPINGS as MDIMG_NODE_DISPLAY_NAME_MAPPINGS +except Exception as _e: + print(f"[Ruinode] Markdown转图片 节点未加载:{_e}") + MDIMG_NODE_CLASS_MAPPINGS = {} + MDIMG_NODE_DISPLAY_NAME_MAPPINGS = {} # 合并节点映射字典 NODE_CLASS_MAPPINGS = {} @@ -71,6 +79,7 @@ NODE_CLASS_MAPPINGS.update(COLORMATCHER_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(IMAGESPLITTER_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(SDMATTE_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(ZENMUX_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(MDIMG_NODE_CLASS_MAPPINGS) # 合并节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = {} @@ -92,5 +101,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(SDMATTE_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(ZENMUX_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(MDIMG_NODE_DISPLAY_NAME_MAPPINGS) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/font/README.md b/font/README.md new file mode 100644 index 0000000..9882ddf --- /dev/null +++ b/font/README.md @@ -0,0 +1,13 @@ +# font 字体目录 + +「Markdown转图片」节点的字体候选列表来自本目录,把 `.ttf` / `.otf` / `.ttc` 字体文件放进来、 +刷新 ComfyUI 页面即可出现在节点的 `font` 下拉里。 + +- **字体文件不随仓库分发**(微软雅黑等系统字体受版权保护,且体积大),请自行放入。 + Windows 用户可直接从 `C:\Windows\Fonts` 复制常用字体到本目录,例如: + `msyh.ttc`(微软雅黑)、`msyhbd.ttc`(雅黑粗体)、`simhei.ttf`(黑体)、 + `simsun.ttc`(宋体)、`simkai.ttf`(楷体)、`Deng.ttf`(等线)等。 +- **粗体自动配对**:同目录下存在同名 + `bd`/`b` 后缀的字体(如 `msyh` + `msyhbd`)时, + 渲染粗体会使用真实字重;否则用描边模拟。 +- **目录为空时**:节点自动回退使用 Windows 系统字体(微软雅黑/黑体/宋体/Arial)。 +- Emoji 彩色渲染使用系统 `seguiemj.ttf`,代码块等宽字体使用系统 `consola.ttf`,无需放入本目录。 diff --git a/markdown_image_node.py b/markdown_image_node.py new file mode 100644 index 0000000..300cb77 --- /dev/null +++ b/markdown_image_node.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +Markdown 转文本图片节点 +======================= +输入 Markdown 文本,按阅读器级排版输出一张图片(IMAGE)。 + +- 尺寸:常用尺寸下拉快选,或选 custom 后用 width/height 精确指定 +- 字体:下拉列表来自 Ruinode/font 目录(ttf/otf/ttc),刷新页面即可看到新放入的字体 +- 各级字号(正文 + H1~H6)、字间距、行间距、单行字数上限均支持「auto」或手动数值, + auto 会按输出尺寸自动求最合适的值(字号用二分法恰好优雅填满画布) +- 表格、Emoji、代码块、引用、任务清单等完整支持,视觉规范对标 GitHub 阅读器 +""" +import numpy as np +import torch + +from .mdimg import scan_fonts, MarkdownImageRenderer +from .mdimg.fonts import DEFAULT_FONT_LABEL + +_SIZE_PRESETS = { + "custom(使用下方宽高)": None, + "1080×1440 竖版 3:4": (1080, 1440), + "1080×1350 竖版 4:5": (1080, 1350), + "1080×1920 手机 9:16": (1080, 1920), + "1080×1080 方形 1:1": (1080, 1080), + "1920×1080 横屏 16:9": (1920, 1080), + "1280×720 横屏 720P": (1280, 720), + "1200×630 链接封面": (1200, 630), + "2480×3508 A4 纵向": (2480, 3508), +} + +_THEMES = {"浅色 light": "light", "深色 dark": "dark", "米色 sepia": "sepia"} + +_DEMO_MD = """# Markdown 转图片 + +支持 **粗体**、*斜体*、`行内代码`、~~删除线~~ 与 [链接](https://example.com)。 + +## 列表与任务 ✅ + +- 第一项:支持 Emoji 😀🎉 +- 第二项 + - 嵌套子项 +- [x] 已完成任务 +- [ ] 待办任务 + +## 表格 📊 + +| 模型 | 输入价 | 输出价 | +|:-----|:------:|-------:| +| GPT 🤖 | $0.2/M | $1.25/M | +| Claude 🧠 | $10/M | $50/M | + +> 引用块:优雅的细节,来自阅读器级的排版。 + +```python +def hello(): + print("Hello, Markdown!") +``` +""" + + +def _auto_num(s, lo=None, hi=None): + """'auto'/空 -> None;否则解析为 float 并夹取范围。""" + t = str(s or "").strip().lower() + if t in ("", "auto", "自动", "none"): + return None + try: + v = float(t) + except ValueError: + return None + if lo is not None: + v = max(lo, v) + if hi is not None: + v = min(hi, v) + return v + + +class MarkdownToImageNode: + """Markdown 文本 -> 排版图片。""" + + @classmethod + def INPUT_TYPES(cls): + font_labels = list(scan_fonts().keys()) + default_font = DEFAULT_FONT_LABEL if DEFAULT_FONT_LABEL in font_labels \ + else font_labels[0] + size_opt = {"default": "auto", "multiline": False} + return { + "required": { + "markdown": ("STRING", {"default": _DEMO_MD, "multiline": True}), + "size_preset": (list(_SIZE_PRESETS.keys()), { + "default": "1080×1440 竖版 3:4", + }), + "width": ("INT", {"default": 1080, "min": 64, "max": 8192, "step": 8}), + "height": ("INT", {"default": 1440, "min": 64, "max": 8192, "step": 8}), + "font": (font_labels, {"default": default_font}), + "theme": (list(_THEMES.keys()), {"default": "浅色 light"}), + }, + "optional": { + "body_size": ("STRING", dict(size_opt)), + "h1_size": ("STRING", dict(size_opt)), + "h2_size": ("STRING", dict(size_opt)), + "h3_size": ("STRING", dict(size_opt)), + "h4_size": ("STRING", dict(size_opt)), + "h5_size": ("STRING", dict(size_opt)), + "h6_size": ("STRING", dict(size_opt)), + "letter_spacing": ("STRING", dict(size_opt)), + "line_spacing": ("STRING", dict(size_opt)), + "max_chars_per_line": ("STRING", dict(size_opt)), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "render" + CATEGORY = "Rui-Node🐶/文本处理📝" + + @classmethod + def VALIDATE_INPUTS(cls, font, size_preset): + """字体/预设列表可能随目录或版本变化,宽松放行(运行时兜底)。""" + return True + + def render(self, markdown, size_preset, width, height, font, theme, + body_size="auto", h1_size="auto", h2_size="auto", h3_size="auto", + h4_size="auto", h5_size="auto", h6_size="auto", + letter_spacing="auto", line_spacing="auto", + max_chars_per_line="auto"): + # ---- 尺寸 ---- + preset = _SIZE_PRESETS.get(size_preset) + w, h = preset if preset else (int(width), int(height)) + # ---- 字体 ---- + fmap = scan_fonts() + font_path = fmap.get(font) + if font_path is None: + font_path = next(iter(fmap.values())) + print(f"[Rui-Node] 字体 '{font}' 不在列表中,已回退 {font_path or '内置默认'}") + # ---- 参数 ---- + sizes = { + "body": _auto_num(body_size, 6, 300), + "h1": _auto_num(h1_size, 6, 400), "h2": _auto_num(h2_size, 6, 400), + "h3": _auto_num(h3_size, 6, 400), "h4": _auto_num(h4_size, 6, 400), + "h5": _auto_num(h5_size, 6, 400), "h6": _auto_num(h6_size, 6, 400), + } + renderer = MarkdownImageRenderer( + width=w, height=h, font_path=font_path, + theme=_THEMES.get(theme, "light"), + sizes=sizes, + letter_spacing=_auto_num(letter_spacing, -10, 100), + line_spacing=_auto_num(line_spacing, 0.8, 4.0), + max_chars=_auto_num(max_chars_per_line, 1, 1000), + ) + img = renderer.render(markdown) + arr = np.asarray(img, dtype=np.float32) / 255.0 + tensor = torch.from_numpy(arr).unsqueeze(0) # BHWC + return (tensor,) + + +NODE_CLASS_MAPPINGS = { + "MarkdownToImage": MarkdownToImageNode, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "MarkdownToImage": "Markdown转图片 / Markdown To Image", +} diff --git a/mdimg/__init__.py b/mdimg/__init__.py new file mode 100644 index 0000000..f51183a --- /dev/null +++ b/mdimg/__init__.py @@ -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"] diff --git a/mdimg/fonts.py b/mdimg/fonts.py new file mode 100644 index 0000000..4b6be2a --- /dev/null +++ b/mdimg/fonts.py @@ -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 diff --git a/mdimg/parser.py b/mdimg/parser.py new file mode 100644 index 0000000..06547b2 --- /dev/null +++ b/mdimg/parser.py @@ -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`+)(?P.+?)(?P=code)" + r"|(?P\*\*\*|___)(?P[^*_]+?)(?P=bi)" + r"|(?P\*\*|__)(?P.+?)(?P=b)" + r"|(?P\*)(?P[^*]+?)\*" + r"|(?[^_]+?)_(?![0-9A-Za-z_])" + r"|(?P~~)(?P.+?)~~" + r"|(?P!)?\[(?P[^\]]*)\]\((?P[^)\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 diff --git a/mdimg/renderer.py b/mdimg/renderer.py new file mode 100644 index 0000000..050cf8f --- /dev/null +++ b/mdimg/renderer.py @@ -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")