front/xhs-agent-desktop/engine/xhs_bridge/smart_image.py
zzh 5a26ae0048 feat: 小红书模块迁移、登录重构与 xhs-agent-desktop 桌面 Agent
- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手)

- 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度

- 登录页重构,整合微信登录与 OMS JWT 认证

- 订单、渠道、用户中心等页面与 API 增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:13:13 +08:00

336 lines
9.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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