Files
DanDingNoneBot/tests/test_danding_qqpush_image_render.py

468 lines
19 KiB
Python
Raw Permalink 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.

"""通知卡片渲染测试:版式、场景徽章、中文/Emoji 折行与降级路径。"""
import base64
import importlib.util
import io
from pathlib import Path
from PIL import Image, ImageDraw
def load_image_renderer():
"""按路径加载渲染模块,避免拉起需要 NoneBot 初始化的插件包。"""
path = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_qqpush" / "image_render.py"
spec = importlib.util.spec_from_file_location("danding_qqpush_image_render", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_renderer(module, **kwargs):
return module.ImageRenderer(**kwargs)
def install_offline_pilmoji(monkeypatch, module):
"""用不访问远程 Emoji 源的替身,保证 Emoji 相关渲染测试离线可跑。"""
class OfflinePilmoji:
def __init__(self, image):
self._draw = ImageDraw.Draw(image)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def text(self, xy, text, font=None, fill=None, **kwargs):
self._draw.text(xy, text, font=font, fill=fill)
monkeypatch.setattr(module, "Pilmoji", OfflinePilmoji)
def open_png(data: bytes) -> Image.Image:
return Image.open(io.BytesIO(data)).convert("RGB")
def ink_bbox(image: Image.Image, box=None):
"""返回区域内墨迹(暗色像素)的包围盒,用于判断是否越界。"""
region = image.crop(box) if box else image
mask = region.convert("L").point(lambda value: 255 if value < 200 else 0)
return mask.getbbox()
def ink_pixels(image: Image.Image, box=None) -> int:
"""区域内墨迹像素数量,用于比较某块区域是否新增了文字。"""
region = image.crop(box) if box else image
mask = region.convert("L").point(lambda value: 255 if value < 200 else 0)
return mask.histogram()[255]
def first_ink_row(image: Image.Image, y_from: int, margin: int) -> int:
"""自 y_from 起逐行扫描,返回第一行存在墨迹的 y 坐标。"""
for y in range(y_from, image.size[1] - 1):
if ink_pixels(image, (margin, y, image.size[0] - margin, y + 1)) > 0:
return y
raise AssertionError("区域下方未找到任何墨迹")
def header_bottom(image: Image.Image, margin: int, brand_color) -> int:
"""沿水平中线向下找到蓝色头部结束的位置。"""
width, height = image.size
pixels = image.load()
for y in range(margin, height):
if pixels[width // 2, y] != brand_color:
return y
raise AssertionError("未找到品牌头部下边界")
def color_count(image: Image.Image, box, color) -> int:
colors = image.crop(box).getcolors(maxcolors=image.size[0] * image.size[1])
return dict((pixel, count) for count, pixel in colors).get(color, 0)
def content_limits(renderer, width: int = 800):
margin = renderer.OUTER_MARGIN
card_right = width - margin
return margin + renderer.padding, card_right - renderer.padding
def badge_rows(renderer, width: int = 800):
"""按与 render() 一致的版式计算白色徽章胶囊的范围与宽度。
返回 (badge_top, badge_bottom, badge_width, header_bottom),均为图像绝对坐标。
"""
margin = renderer.OUTER_MARGIN
brand_font = renderer._load_font_by_size(max(int(renderer.font_size * 1.05), 12))
badge_font = renderer._load_font_by_size(max(int(renderer.font_size * 0.8), 11))
badge_text = str(renderer.SCENE_STYLES["common"]["label"])
badge_text_height = renderer._text_size(badge_text, badge_font)[1]
badge_height = badge_text_height + 2 * max(int(renderer.font_size * 0.25), 4)
badge_width = int(renderer._measure_text(badge_text, badge_font)) + 2 * max(int(renderer.font_size * 0.6), 10)
header_height = max(renderer._text_size(renderer.BRAND_LABEL, brand_font)[1], badge_height) + 2 * max(int(renderer.font_size * 0.65), 12)
badge_top = margin + max((header_height - badge_height) // 2, 0)
return badge_top, badge_top + badge_height - 1, badge_width, margin + header_height
def badge_bounds(renderer, width: int = 800):
"""用 render() 的同一公式推出徽章胶囊的水平范围,返回 (badge_left, badge_right)。
卡片底色本身就是白色,因此不能靠“整行扫白”定位胶囊(会把卡片底色一起扫进来),
这里直接按版式公式定位:徽章右边缘贴合内容右边界,宽度由文字宽度加左右内边距决定。
"""
margin = renderer.OUTER_MARGIN
card_right = width - margin
_, _, badge_width, _ = badge_rows(renderer, width)
badge_left = card_right - renderer.padding - badge_width
return badge_left, badge_left + badge_width - 1
def accent_ink_bounds(image: Image.Image, box, color, tolerance: int = 45):
"""返回框内场景强调色像素的绝对包围盒,即徽章文字的可见墨迹(白底不会算作文字)。
搜索范围只覆盖白色胶囊内部,因此不会误把头部品牌蓝当作 emphasis 色。
"""
left, top, right, bottom = box
clipped = image.crop((left, top, right + 1, bottom + 1))
pixels = clipped.load()
xs, ys = [], []
for y in range(clipped.size[1]):
for x in range(clipped.size[0]):
pixel = pixels[x, y]
if all(abs(pixel[index] - color[index]) <= tolerance for index in range(3)):
xs.append(left + x)
ys.append(top + y)
if not xs:
raise AssertionError("未在徽章内找到场景强调色文字墨迹")
return min(xs), min(ys), max(xs), max(ys)
def test_render_keeps_legacy_calls_working():
module = load_image_renderer()
renderer = build_renderer(module)
png = renderer.render("系统告警")
assert png.startswith(b"\x89PNG")
encoded = renderer.render_to_base64("系统告警")
assert encoded.startswith("base64://")
assert base64.b64decode(encoded[len("base64://"):]) == png
# 历史调用方式(位置参数传 title)依然可用
assert renderer.render("系统告警", "自定义标题").startswith(b"\x89PNG")
assert renderer.render_to_base64("系统告警", title="自定义标题").startswith("base64://")
def test_card_uses_cool_gray_backdrop_and_blue_brand_header():
module = load_image_renderer()
renderer = build_renderer(module)
renderer_class = module.ImageRenderer
image = open_png(renderer.render("卡片版式校验", scene="group"))
width, height = image.size
margin = renderer_class.OUTER_MARGIN
assert width == renderer.width == 800
assert image.getpixel((2, 2)) == renderer_class.OUTER_BG_COLOR
assert image.getpixel((width - 3, height - 3)) == renderer_class.OUTER_BG_COLOR
assert image.getpixel((width // 2, margin + 5)) == renderer_class.BRAND_COLOR
assert image.getpixel((width // 2, height - margin - 5)) == renderer.bg_color
def test_scene_badges_distinguish_group_and_private_notifications():
module = load_image_renderer()
renderer = build_renderer(module)
common = renderer.render("同一段通知内容", scene="common")
group = renderer.render("同一段通知内容", scene="group")
private = renderer.render("同一段通知内容", scene="private")
assert len({common, group, private}) == 3
margin = renderer.OUTER_MARGIN
header_box = (margin, margin, 800 - margin, margin + 80)
private_image = open_png(private)
common_image = open_png(common)
# 徽章为白色胶囊,文字使用场景强调色
assert color_count(private_image, header_box, renderer.BADGE_BG_COLOR) > 200
assert color_count(private_image, header_box, renderer.SCENE_STYLES["private"]["color"]) > 0
assert color_count(common_image, header_box, renderer.SCENE_STYLES["common"]["color"]) > 0
assert color_count(common_image, header_box, renderer.SCENE_STYLES["private"]["color"]) == 0
def test_unknown_scene_falls_back_to_common_style():
module = load_image_renderer()
renderer = build_renderer(module)
assert renderer._scene_style("group")["label"] == "群聊通知"
assert renderer._scene_style("private")["label"] == "私聊通知"
unknown = renderer._scene_style("nope")
assert unknown["label"] == renderer.SCENE_STYLES[renderer.DEFAULT_SCENE]["label"]
assert unknown["color"] == renderer.SCENE_STYLES[renderer.DEFAULT_SCENE]["color"]
assert renderer.render("未知场景", scene="nope") == renderer.render("未知场景", scene="common")
def test_long_text_wraps_without_leaving_the_card():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
lines = renderer._wrap_text("超长文本测试," * 200, content_width)
assert all(renderer._measure_text(line, renderer.font) <= content_width for line in lines)
assert "".join(lines) == "超长文本测试," * 200
image = open_png(renderer.render("超长文本测试," * 200, scene="group"))
width, height = image.size
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, width - margin, height - margin)
left, top, right, bottom = ink_bbox(image, body_box)
absolute = (body_box[0] + left, body_box[1] + top, body_box[0] + right, body_box[1] + bottom)
assert absolute[0] >= content_left
assert absolute[2] <= content_right
assert absolute[3] < height - margin
def test_explicit_newlines_and_blank_lines_are_preserved():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
lines = renderer._wrap_text("第一行\n第二行\n\n第四行", content_right - content_left)
assert lines == ["第一行", "第二行", "", "第四行"]
def test_cjk_punctuation_does_not_start_or_end_a_line():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
cases = [
# 28 个全角汉字正好铺满一行,逗号会落在下一行行首
"甲" * 28 + ",后续内容" + "乙" * 26 + "(括号内容)",
# 27 个汉字加左括号刚好占满,右括号内容会被挤到下一行,左括号不能留在行尾
"甲" * 27 + "(乙" * 20,
]
for text in cases:
lines = renderer._wrap_text(text, content_width)
assert "".join(lines) == text
for line in lines:
if line:
assert line[0] not in module.ImageRenderer.LINE_START_FORBIDDEN
assert renderer._measure_text(line, renderer.font) <= content_width
for line in lines[:-1]:
assert line[-1] not in module.ImageRenderer.LINE_END_FORBIDDEN
def test_emoji_is_measured_as_square_and_kept_whole():
module = load_image_renderer()
renderer = build_renderer(module)
atoms = list(renderer._iter_atoms("🏇出发", renderer.font))
assert atoms[0][0] == "🏇"
assert atoms[0][1] == renderer.font.size
# ZWJ 组合、肤色修饰、区域指示符组合都不应被拆开
family, flag = "👨‍👩‍👧", "🇨🇳"
atoms = list(renderer._iter_atoms(f"👍🏻{family}{flag}赞", renderer.font))
groups = [atom for atom, _ in atoms]
assert "👍🏻" in groups
assert family in groups
assert flag in groups
def test_emoji_text_does_not_overflow_the_card(monkeypatch):
module = load_image_renderer()
install_offline_pilmoji(monkeypatch, module)
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
text = "🏇🎉😀 中文与 Emoji 混排的长通知," * 20
lines = renderer._wrap_text(text, content_width)
assert "".join(lines) == text
assert all(renderer._measure_text(line, renderer.font) <= content_width for line in lines)
image = open_png(renderer.render(text, scene="group"))
width, height = image.size
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, width - margin, height - margin)
left, _, right, _ = ink_bbox(image, body_box)
assert body_box[0] + left >= content_left
assert body_box[0] + right <= content_right
def test_body_line_height_is_at_least_one_point_six(monkeypatch):
module = load_image_renderer()
renderer = build_renderer(module, line_spacing=1.2)
single = open_png(renderer.render("单行"))
double = open_png(renderer.render("第一行\n第二行"))
line_height = double.size[1] - single.size[1]
assert line_height > 0
assert line_height >= int(renderer._text_size("测试", renderer.font)[1] * 1.6)
def test_default_title_keeps_legacy_text_and_none_hides_it(monkeypatch):
"""默认标题沿用旧版文案(向后兼容),显式 None / 空字符串才隐藏标题行。"""
module = load_image_renderer()
install_offline_pilmoji(monkeypatch, module)
renderer = build_renderer(module)
assert module.ImageRenderer.DEFAULT_TITLE == "蛋定助手通知您:"
default_image = open_png(renderer.render("正文内容"))
hidden_image = open_png(renderer.render("正文内容", title=None))
empty_image = open_png(renderer.render("正文内容", title=""))
custom_image = open_png(renderer.render("正文内容", title="🏇 赛马开赛"))
# 默认渲染比显式隐藏多一行标题
assert default_image.size[1] > hidden_image.size[1]
# 显式 None 与空字符串等效,且自定义单行标题与默认标题高度一致
assert hidden_image.size[1] == empty_image.size[1]
assert custom_image.size[1] == default_image.size[1]
# 默认渲染在头部下方先画标题行,显式隐藏时同一位置直接是正文
margin = renderer.OUTER_MARGIN
header_end = header_bottom(default_image, margin, module.ImageRenderer.BRAND_COLOR)
top_band = (
margin,
header_end + renderer.padding + 1,
default_image.size[0] - margin,
header_end + renderer.padding + 40,
)
default_band = ink_bbox(default_image, top_band)
hidden_band = ink_bbox(hidden_image, top_band)
assert default_band is not None and hidden_band is not None
# 隐藏标题时正文紧贴头部内边距开始
assert first_ink_row(hidden_image, header_end + 1, margin) >= header_end + renderer.padding
# 标题「蛋定助手通知您:」明显宽于正文「正文内容」,据此确认标题行确实绘制了
assert (default_band[2] - default_band[0]) > (hidden_band[2] - hidden_band[0]) * 1.5
# render_to_base64 同样继承默认标题文案
encoded = renderer.render_to_base64("正文内容")
assert open_png(base64.b64decode(encoded[len("base64://"):])).size == default_image.size
def test_badge_text_ink_is_vertically_centered_in_the_capsule():
"""回归:徽章文字的可见墨迹应在白色胶囊内垂直居中,而非按字体 metrics 偏下。"""
module = load_image_renderer()
renderer = build_renderer(module)
badge_top, badge_bottom, badge_width, _ = badge_rows(renderer)
badge_left, badge_right = badge_bounds(renderer)
# 版式算法自身的一致性:胶囊右边缘贴合内容右边界,宽度由文字加内边距构成
assert badge_left == (800 - renderer.OUTER_MARGIN) - renderer.padding - badge_width
assert badge_right - badge_left + 1 == badge_width
for scene in ("group", "private", "common"):
label = str(renderer.SCENE_STYLES[scene]["label"])
color = tuple(renderer.SCENE_STYLES[scene]["color"])
image = open_png(renderer.render("同一段通知内容", scene=scene))
# 用像素确认胶囊确实落在算出来的水平范围内:胶囊内部为白色,左右外侧仍是头部品牌蓝
center_y = (badge_top + badge_bottom) // 2
assert image.getpixel((badge_left + 2, center_y)) == renderer.BADGE_BG_COLOR, scene
assert image.getpixel((badge_right - 2, center_y)) == renderer.BADGE_BG_COLOR, scene
assert image.getpixel((badge_left - 2, center_y)) == renderer.BRAND_COLOR, scene
assert image.getpixel((badge_right + 2, center_y)) == renderer.BRAND_COLOR, scene
# 文字搜索范围收在胶囊中段的矩形区:胶囊本身是圆角,顶/底行中段藏有圆角,
# 而圆角处仍是头部品牌蓝,会和 group 场景的强调色混淆;左右也避开圆角留白。
corner_inset = (badge_bottom - badge_top + 1) // 2
text_box = (
badge_left + corner_inset,
badge_top,
badge_right - corner_inset,
badge_bottom,
)
ink_left, ink_top, ink_right, ink_bottom = accent_ink_bounds(image, text_box, color)
top_gap = ink_top - badge_top
bottom_gap = badge_bottom - ink_bottom
# 文字墨迹留在胶囊内(左右不越界),且上下留白基本相等(1-2 像素内视为居中)
assert badge_left < ink_left and ink_right < badge_right, (scene, label, ink_left, ink_right)
assert 0 < top_gap <= (badge_bottom - badge_top + 1) // 2, (scene, label, top_gap)
assert 0 < bottom_gap <= (badge_bottom - badge_top + 1) // 2, (scene, label, bottom_gap)
assert abs(top_gap - bottom_gap) <= 2, (scene, label, top_gap, bottom_gap)
def test_badge_text_origin_matches_the_painted_ink_center():
"""回归:_vertical_text_origin 返回的原点能让 textbbox 墨迹中点落在徽章中心线上。"""
module = load_image_renderer()
renderer = build_renderer(module)
badge_font = renderer._load_font_by_size(max(int(renderer.font_size * 0.8), 11))
center_y = 200
for scene in ("group", "private", "common"):
label = str(renderer.SCENE_STYLES[scene]["label"])
origin_y = renderer._vertical_text_origin(center_y, label, badge_font)
bbox = renderer._get_measuring_draw().textbbox((0, origin_y), label, font=badge_font)
assert abs((bbox[1] + bbox[3]) / 2 - center_y) <= 0.5
# 直接以该原点绘制,真实墨迹中点同样贴近中心线
canvas = Image.new("RGB", (200, 400), (255, 255, 255))
ImageDraw.Draw(canvas).text((0, origin_y), label, font=badge_font, fill=tuple(renderer.SCENE_STYLES[scene]["color"]))
left, top, right, bottom = accent_ink_bounds(canvas, (0, 0, 199, 399), tuple(renderer.SCENE_STYLES[scene]["color"]))
assert abs((top + bottom) / 2 - center_y) <= 1.5
def test_empty_text_keeps_placeholder_and_minimum_height():
module = load_image_renderer()
renderer = build_renderer(module)
image = open_png(renderer.render(""))
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, image.size[0] - margin, image.size[1] - margin)
assert image.size[1] >= module.ImageRenderer.MIN_HEIGHT
assert ink_bbox(image, body_box) is not None
def test_render_falls_back_to_plain_text_when_emoji_source_fails(monkeypatch):
module = load_image_renderer()
renderer = build_renderer(module)
class BrokenPilmoji:
def __init__(self, image):
self.image = image
def __enter__(self):
return self
def __exit__(self, *args):
return False
def text(self, *args, **kwargs):
raise RuntimeError("emoji 源不可用")
monkeypatch.setattr(module, "Pilmoji", BrokenPilmoji)
png = renderer.render("降级测试 🏇", scene="private")
assert png.startswith(b"\x89PNG")
assert renderer._emoji_enabled is False
def test_pure_black_text_color_falls_back_to_soft_ink():
module = load_image_renderer()
assert build_renderer(module, text_color=(0, 0, 0)).text_color == module.ImageRenderer.DEFAULT_TEXT_COLOR
assert build_renderer(module, text_color=(10, 20, 30)).text_color == (10, 20, 30)
assert module.ImageRenderer.DEFAULT_TEXT_COLOR != (0, 0, 0)