diff --git a/danding_bot/plugins/danding_qqpush/image_render.py b/danding_bot/plugins/danding_qqpush/image_render.py index 90b64f7..baf653d 100644 --- a/danding_bot/plugins/danding_qqpush/image_render.py +++ b/danding_bot/plugins/danding_qqpush/image_render.py @@ -172,6 +172,32 @@ class ImageRenderer: bbox = draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0], bbox[3] - bbox[1] + def _vertical_text_origin( + self, + center_y: int, + text: str, + font: ImageFont.FreeTypeFont, + ) -> int: + """ + 计算让文本可见墨迹在指定高度内垂直居中所需的绘制原点 y。 + + PIL 以字体升部基准线定位,textbbox 相对绘制原点的 top 通常不为 0 + (例如 19px 微软雅黑的中文 bbox 为 (0, 5, w, 24)),直接按 + bbox 高度居中会让文字整体偏下,这里改用真实墨迹上下边界来对齐。 + + Args: + center_y: 目标垂直中心线(图像绝对坐标) + text: 文本内容 + font: 字体对象 + + Returns: + 绘制原点 y 坐标 + """ + draw = self._get_measuring_draw() + bbox = draw.textbbox((0, 0), text, font=font) + # bbox top/bottom 是相对绘制原点的偏移,故墨迹中点 = origin_y + (bbox[1] + bbox[3]) / 2 + return int(round(center_y - (bbox[1] + bbox[3]) / 2)) + def _char_width(self, char: str, font: ImageFont.FreeTypeFont) -> float: """ 测量单个字符宽度,缺字形时按方块宽度兜底。 @@ -419,7 +445,12 @@ class ImageRenderer: ) brand_top = margin + max((header_height - brand_height) // 2, 0) - badge_text_top = badge_top + max((badge_height - badge_text_height) // 2, 0) + # 按真实墨迹边界居中,避免 textbbox top 偏移导致徽章文字偏下 + badge_text_top = self._vertical_text_origin( + badge_top + badge_height // 2, + badge_text, + badge_font, + ) with ExitStack() as stack: pilmoji: Optional[Pilmoji] = None diff --git a/tests/test_danding_qqpush_image_render.py b/tests/test_danding_qqpush_image_render.py index 4764e93..268eb5c 100644 --- a/tests/test_danding_qqpush_image_render.py +++ b/tests/test_danding_qqpush_image_render.py @@ -86,6 +86,57 @@ def content_limits(renderer, width: int = 800): 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) @@ -307,6 +358,69 @@ def test_default_title_keeps_legacy_text_and_none_hides_it(monkeypatch): 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)