"""通知卡片渲染测试:版式、场景徽章、中文/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)