from __future__ import annotations import re import sys from datetime import datetime from pathlib import Path from xhs_bridge.config import PROJECT_ROOT # 与 img/templates/xhs_yellow_card.png 生成参数一致(1080×1440) _CANVAS_W = 1080 _CANVAS_H = 1440 _CARD_X = 72 _CARD_Y = 96 _CARD_W = 936 _CARD_H = 1296 _PAD_X = 48 _PAD_TOP = 56 _PAD_BOTTOM = 56 _TEXT_COLOR = (42, 42, 42) # 标题一般不超过 25 字,按画布自适应字号 _MAX_TITLE_CHARS = 25 _TITLE_SIZE_MIN = 56 _TITLE_SIZE_MAX = 112 _CHARS_PER_LINE = 13 # 约每行字数(随字号在 fit 循环中校验) # 含表情符号、符号表情、键帽(1️⃣)及 ZWJ 组合 _EMOJI_RE = re.compile( r"(?:" r"[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D]" r"|[0-9#*]\uFE0F?\u20E3" r")+", flags=re.UNICODE, ) Run = tuple[str, bool] # (片段, 是否表情) def template_dir() -> Path: if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) / "img" / "templates" return PROJECT_ROOT / "img" / "templates" def default_template_path() -> Path: return template_dir() / "xhs_yellow_card.png" def output_dir() -> Path: path = PROJECT_ROOT / ".xhs_bridge" / "smart_images" path.mkdir(parents=True, exist_ok=True) return path # 俏皮标题字体优先级:幼圆 → 行楷 → 楷体 → 雅黑 _PLAYFUL_TITLE_FONTS = ( "SIMYOU.TTF", "STXINGKA.TTF", "simkai.ttf", "msyh.ttc", ) def _load_playful_title_font(size: int): from PIL import ImageFont win = Path("C:/Windows/Fonts") for name in _PLAYFUL_TITLE_FONTS: path = win / name if path.is_file(): try: return ImageFont.truetype(str(path), size) except OSError: continue return ImageFont.load_default() def _load_emoji_font(size: int): from PIL import ImageFont win = Path("C:/Windows/Fonts") for name in ("seguiemj.ttf", "Segoe UI Emoji.ttf", "NotoColorEmoji.ttf"): path = win / name if path.is_file(): try: return ImageFont.truetype(str(path), size) except OSError: continue win = Path("C:/Windows/Fonts") if (win / "msyh.ttc").is_file(): try: from PIL import ImageFont return ImageFont.truetype(str(win / "msyh.ttc"), size) except OSError: pass return _load_playful_title_font(size) def _normalize_text(text: str) -> str: text = (text or "").strip() text = re.sub(r"\r\n?", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) return text def _split_runs(text: str) -> list[Run]: if not text: return [] runs: list[Run] = [] pos = 0 for match in _EMOJI_RE.finditer(text): if match.start() > pos: runs.append((text[pos : match.start()], False)) runs.append((match.group(), True)) pos = match.end() if pos < len(text): runs.append((text[pos:], False)) return runs def _flatten_units(text: str) -> list[Run]: units: list[Run] = [] for segment, is_emoji in _split_runs(text): if is_emoji: units.append((segment, True)) else: for ch in segment: units.append((ch, False)) return units def _runs_width(draw, runs: list[Run], text_font, emoji_font) -> float: width = 0.0 for segment, is_emoji in runs: font = emoji_font if is_emoji else text_font width += draw.textlength(segment, font=font) return width def _wrap_paragraph(draw, paragraph: str, text_font, emoji_font, max_width: int) -> list[list[Run]]: paragraph = paragraph.strip() if not paragraph: return [[]] units = _flatten_units(paragraph) lines: list[list[Run]] = [] current: list[Run] = [] def append_unit(unit: Run) -> None: nonlocal current seg, is_emoji = unit trial = current + [(seg, is_emoji)] if current and _runs_width(draw, trial, text_font, emoji_font) > max_width: lines.append(current) current = [(seg, is_emoji)] else: current = trial if _runs_width(draw, current, text_font, emoji_font) > max_width: lines.append(current) current = [] for unit in units: append_unit(unit) if current: lines.append(current) return lines or [[]] def _wrap_text(draw, text: str, text_font, emoji_font, max_width: int) -> list[list[Run]]: lines: list[list[Run]] = [] for paragraph in text.split("\n"): if not paragraph.strip(): lines.append([]) continue lines.extend(_wrap_paragraph(draw, paragraph, text_font, emoji_font, max_width)) return lines def _draw_runs( draw, x: int, y: int, runs: list[Run], *, text_font, emoji_font, fill, ) -> None: cx = float(x) for segment, is_emoji in runs: font = emoji_font if is_emoji else text_font kwargs: dict = {"fill": fill, "font": font} if is_emoji: try: draw.text((cx, y), segment, **kwargs, embedded_color=True) except TypeError: draw.text((cx, y), segment, **kwargs) else: draw.text((cx, y), segment, **kwargs) cx += draw.textlength(segment, font=font) def _draw_runs_centered( draw, box_x: int, box_width: int, y: int, runs: list[Run], *, text_font, emoji_font, fill, ) -> None: line_w = _runs_width(draw, runs, text_font, emoji_font) x = box_x + max(0, (box_width - line_w) / 2) _draw_runs(draw, int(x), y, runs, text_font=text_font, emoji_font=emoji_font, fill=fill) def _title_char_count(text: str) -> int: """估算标题字数(不含换行),用于自适应字号。""" flat = re.sub(r"\s+", "", text.replace("\n", "")) return min(len(flat), _MAX_TITLE_CHARS) or 1 def _fit_title_layout( draw, title_text: str, inner_w: int, inner_h: int, ) -> tuple[object, object, list[list[Run]], int]: """按白卡区域选取尽可能大的字号,保证换行后仍能居中放下。""" chars = _title_char_count(title_text) est_lines = max(1, min(3, (chars + _CHARS_PER_LINE - 1) // _CHARS_PER_LINE)) chars_per_line = max(1, (chars + est_lines - 1) // est_lines) # 字少单行大字;约 25 字内通常 1~2 行 width_cap = int(inner_w / (chars_per_line * 0.9)) height_cap = int(inner_h / (est_lines * 1.12)) size_hi = min(_TITLE_SIZE_MAX, width_cap, height_cap, int(inner_w * 0.16)) best: tuple[object, object, list[list[Run]], int] | None = None for size in range(size_hi, _TITLE_SIZE_MIN - 1, -2): text_font = _load_playful_title_font(size) emoji_font = _load_emoji_font(size) lines = _wrap_text(draw, title_text, text_font, emoji_font, inner_w) lines = [ln for ln in lines if ln] if not lines: continue lh = _line_height(text_font, emoji_font) total_h = len(lines) * lh max_w = max(_runs_width(draw, ln, text_font, emoji_font) for ln in lines) if total_h <= inner_h and max_w <= inner_w: best = (text_font, emoji_font, lines, lh) break if best is not None: return best size = _TITLE_SIZE_MIN text_font = _load_playful_title_font(size) emoji_font = _load_emoji_font(size) lines = _wrap_text(draw, title_text, text_font, emoji_font, inner_w) lines = [ln for ln in lines if ln] or [[]] return text_font, emoji_font, lines, _line_height(text_font, emoji_font) def _line_height(text_font, emoji_font) -> int: try: a1, d1 = text_font.getmetrics() a2, d2 = emoji_font.getmetrics() return max(a1 + d1, a2 + d2) + 8 except Exception: return 44 def render_smart_note_image( title: str, content: str = "", *, template_path: Path | None = None, output_path: Path | None = None, ) -> Path: """将标题渲染到小红书黄底白卡模板上并保存(不渲染正文)。""" from PIL import Image, ImageDraw _ = content # 保留参数兼容;智能配图仅使用标题 tpl = template_path or default_template_path() if not tpl.is_file(): raise FileNotFoundError(f"未找到智能图片模板:{tpl}") title_text = _normalize_text(title) if not title_text: raise ValueError("请填写标题") if output_path is None: stamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_path = output_dir() / f"smart_note_{stamp}.png" output_path = Path(output_path).resolve() output_path.parent.mkdir(parents=True, exist_ok=True) base = Image.open(tpl).convert("RGB") if base.size != (_CANVAS_W, _CANVAS_H): base = base.resize((_CANVAS_W, _CANVAS_H), Image.Resampling.LANCZOS) draw = ImageDraw.Draw(base) inner_x = _CARD_X + _PAD_X inner_w = _CARD_W - _PAD_X * 2 card_inner_h = _CARD_H - _PAD_TOP - _PAD_BOTTOM title_text_font, title_emoji_font, title_lines, th = _fit_title_layout( draw, title_text, inner_w, card_inner_h ) block_h = max(th, len(title_lines) * th) y = _CARD_Y + _PAD_TOP + max(0, (card_inner_h - block_h) // 2) for runs in title_lines: _draw_runs_centered( draw, inner_x, inner_w, y, runs, text_font=title_text_font, emoji_font=title_emoji_font, fill=_TEXT_COLOR, ) y += th base.save(output_path, "PNG", optimize=True) return output_path