Files
Ky11le-draw_tools/nodes/textbox.py
T

245 lines
7.8 KiB
Python

import math, re, numpy as np, torch
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from ..utils.converters import novel_pil2tensor
class TextBoxAutoWrap:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True}),
"font_path": ("STRING",),
"box_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
"box_height": ("INT", {"default": 256, "min": 64, "max": 4096}),
"font_size": ("INT", {"default": 48, "min": 4, "max": 512}),
"letter_spacing": ("INT", {"default": 0, "min": -20, "max": 100}),
"base_chars_per_line": ("INT", {"default": 10, "min": 1, "max": 100}),
"max_lines": ("INT", {"default": 3, "min": 1, "max": 10}),
"min_hscale": ("FLOAT", {"default": 0.6, "min": 0.3, "max": 1.0}),
"font_hex": ("STRING", {"default": "#FFFFFFFF"}), # RRGGBBAA
"stroke_width": ("INT", {"default": 0, "min": 0, "max": 20}),
"stroke_hex": ("STRING", {"default": "#000000FF"}), # 描边颜色
"enable_small_caps": ("BOOLEAN", {"default": False}),
"small_caps_scale": ("FLOAT", {"default": 0.8, "min": 0.5, "max": 1.0}),
# — 新增 ——
"align_right": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "run"
CATEGORY = "DrawTools"
# ---------- 工具 ----------
@staticmethod
def line_height(font):
a, d = font.getmetrics()
return a + d
@staticmethod
def char_font(c, font, small_font, enable_small_caps):
"""返回绘制字符 c 时应使用的 ImageFont 对象"""
return (
small_font if (enable_small_caps and c.isdigit() and small_font) else font
)
@classmethod
def line_pixel_width(
cls, text_line, font, small_font, letter_spacing, enable_small_caps
):
"""计算单行像素宽度(含字间距,末尾不加)"""
if not text_line:
return 0
width = 0
for idx, ch in enumerate(text_line):
fnt = cls.char_font(ch, font, small_font, enable_small_caps)
width += fnt.getlength(ch)
if idx < len(text_line) - 1:
width += letter_spacing
return width
@classmethod
def wrap_by_pixel_with_spacing(
cls,
text,
font,
max_w,
max_lines,
letter_spacing,
enable_small_caps=False,
small_font=None,
):
"""逐像素宽度换行:数字→小字号,其余→大字号"""
lines, buf = [], ""
for ch in text.replace("\n", ""):
test = buf + ch
# 计算 test 的宽度
width = cls.line_pixel_width(
test, font, small_font, letter_spacing, enable_small_caps
)
if width <= max_w:
buf = test
else:
lines.append(buf)
buf = ch
if len(lines) == max_lines:
return lines
if buf and len(lines) < max_lines:
lines.append(buf)
return lines
# ---------- 绘制 ----------
@staticmethod
def draw_text_with_spacing_and_stroke(
draw,
pos,
text,
font,
fill,
letter_spacing,
stroke_width,
stroke_fill,
enable_small_caps=False,
small_font=None,
):
"""数字小字号,其余大字号;baseline 对齐"""
x, y0 = pos
big_a, _ = font.getmetrics()
sml_a, _ = small_font.getmetrics() if small_font else font.getmetrics()
y_offset = max(0, big_a - sml_a)
for ch in text:
use_small = enable_small_caps and ch.isdigit() and small_font is not None
cur_font = small_font if use_small else font
y = y0 + (y_offset if use_small else 0)
# 描边
if stroke_width > 0:
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx or dy:
draw.text(
(x + dx, y + dy), ch, fill=stroke_fill, font=cur_font
)
# 主文字
draw.text((x, y), ch, fill=fill, font=cur_font)
x += cur_font.getlength(ch) + letter_spacing
# ---------- 颜色解析 ----------
@staticmethod
def hex_to_rgba(hex_str: str):
hs = hex_str.strip().lstrip("#")
if len(hs) == 6:
hs += "FF"
if len(hs) != 8:
return (255, 255, 255, 255)
try:
return tuple(int(hs[i : i + 2], 16) for i in (0, 2, 4, 6))
except ValueError:
return (255, 255, 255, 255)
# ---------- 运行 ----------
def run(
self,
text,
font_path,
box_width,
box_height,
font_size,
letter_spacing,
base_chars_per_line,
max_lines,
min_hscale,
font_hex,
stroke_width,
stroke_hex,
enable_small_caps,
small_caps_scale,
align_right,
):
font_rgba = self.hex_to_rgba(font_hex)
stroke_rgba = self.hex_to_rgba(stroke_hex)
font = ImageFont.truetype(font_path, font_size)
small_font = (
ImageFont.truetype(font_path, max(1, int(font_size * small_caps_scale)))
if enable_small_caps
else None
)
# 估算缩放比
char_cnt = len(re.sub(r"\s+", "", text))
chars_per = max(base_chars_per_line, math.ceil(char_cnt / max_lines))
hscale = max(base_chars_per_line / chars_per, min_hscale)
virt_w = int(round(box_width / hscale))
# 换行
lines = self.wrap_by_pixel_with_spacing(
text, font, virt_w, max_lines, letter_spacing, enable_small_caps, small_font
)
lh = max(self.line_height(font), self.line_height(small_font or font))
# 虚拟画布
img_v = Image.new("RGBA", (virt_w, box_height), (0, 0, 0, 0))
msk_v = Image.new("L", (virt_w, box_height), 0)
d_v = ImageDraw.Draw(img_v)
m_v = ImageDraw.Draw(msk_v)
# 逐行绘制
y = 0
for ln in lines:
# 根据对齐方式确定起始 x
line_width = self.line_pixel_width(
ln, font, small_font, letter_spacing, enable_small_caps
)
x_start = virt_w - line_width if align_right else 0
# 正片
self.draw_text_with_spacing_and_stroke(
d_v,
(x_start, y),
ln,
font,
font_rgba,
letter_spacing,
stroke_width,
stroke_rgba,
enable_small_caps,
small_font,
)
# 蒙版
self.draw_text_with_spacing_and_stroke(
m_v,
(x_start, y),
ln,
font,
255,
letter_spacing,
stroke_width,
255,
enable_small_caps,
small_font,
)
y += lh
# 缩回目标尺寸 & 锐化
img = img_v.resize((box_width, box_height), resample=Image.LANCZOS).filter(
ImageFilter.SHARPEN
)
mask = msk_v.resize((box_width, box_height), resample=Image.NEAREST).filter(
ImageFilter.SHARPEN
)
img_tensor = novel_pil2tensor(img)
mask_rgb = torch.stack(
[torch.from_numpy(np.array(mask, dtype=np.float32) / 255.0)], dim=0
)
return (img_tensor.unsqueeze(0), mask_rgb.unsqueeze(0))