修复通知图片徽章文字垂直对齐
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user