Files
rui40000-RUI-Nodes/markdown_image_node.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

162 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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 转文本图片节点
=======================
输入 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",
}