- 纯 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>
162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
# -*- 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",
|
||
}
|