Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PicGen Console

一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.62**。它把
一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.63**。它把
`/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具)
包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。

Expand All @@ -14,7 +14,9 @@

![PicGen Console 主程序界面](demo1.png)

## 0.1.62 主要特性
## 0.1.63 主要特性

- **行程图按需嵌入标题字体**:仅打包本次标题和日期实际使用的字形,保留原字体效果,同时显著降低 SVG 体积和客户浏览器解码压力。

- **简洁模式可直接接收分享**:主页新增“收到分享”区块,支持刷新并直接打开同事分享的成品;新用户无需先切换到专业模式。

Expand Down Expand Up @@ -123,10 +125,10 @@ PICGEN_LOG_FORMAT=json \
### Docker

```bash
docker build -t minorli/picgen:0.1.62 .
docker build -t minorli/picgen:0.1.63 .
docker run --rm -p 8000:8000 \
-v picgen-data:/app/data \
minorli/picgen:0.1.62
minorli/picgen:0.1.63
```

或:
Expand All @@ -141,10 +143,10 @@ docker compose up -d
./scripts/docker-build-push.sh
```

默认会构建并推送 `minorli/picgen:0.1.62`。也可以覆盖:
默认会构建并推送 `minorli/picgen:0.1.63`。也可以覆盖:

```bash
IMAGE=minorli/picgen VERSION=0.1.62 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
IMAGE=minorli/picgen VERSION=0.1.63 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
```

镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root
Expand Down Expand Up @@ -257,7 +259,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.62 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.63 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.62
image: minorli/picgen:0.1.63
build:
context: .
ports:
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "picgen"
version = "0.1.62"
version = "0.1.63"
description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs."
readme = "README.md"
requires-python = ">=3.12"
Expand All @@ -23,6 +23,7 @@ dependencies = [
"pydantic-settings>=2.4.0",
"anyio>=4.4.0",
"pillow>=11.0.0",
"fonttools>=4.0.0",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

IMAGE="${IMAGE:-minorli/picgen}"
VERSION="${VERSION:-0.1.62}"
VERSION="${VERSION:-0.1.63}"
PLATFORM="${PLATFORM:-linux/amd64}"

docker buildx build \
Expand Down
44 changes: 35 additions & 9 deletions src/picgen/itinerary_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import base64
import html
import logging
import math
import os
import re
Expand All @@ -14,11 +15,14 @@
from collections.abc import Awaitable, Callable
from datetime import datetime
from functools import lru_cache
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib import parse

import httpx
from fontTools import subset as font_subset
from fontTools.ttLib import TTFont

from .config import Settings
from .restricted_destinations import stop_has_restricted_destination
Expand All @@ -32,6 +36,8 @@
LOGO_HREF = "6renyou.png"
TITLE_FONT_FAMILY = "PicGenRouteTitle"
TITLE_FONT_RELATIVE_PATH = Path("fonts/zcool-xiaowei/ZCOOLXiaoWei-Regular.ttf")
logger = logging.getLogger(__name__)
logging.getLogger("fontTools.subset").setLevel(logging.WARNING)
INSTRUCTION_STOP_PREFIXES = (
"地点与地理校验",
"地理校验",
Expand Down Expand Up @@ -81,14 +87,29 @@ def _candidate_static_dirs() -> list[Path]:
return deduped


@lru_cache(maxsize=1)
def _embedded_title_font_face_css_cached() -> str:
def _subset_title_font_bytes(font_path: Path, glyphs: str) -> bytes:
font = TTFont(font_path, recalcTimestamp=False)
try:
subsetter = font_subset.Subsetter(options=font_subset.Options())
subsetter.populate(text=glyphs)
subsetter.subset(font)
output = BytesIO()
font.save(output, reorderTables=False)
return output.getvalue()
finally:
font.close()


@lru_cache(maxsize=64)
def _embedded_title_font_face_css_cached(glyphs: str) -> str:
for static_dir in _candidate_static_dirs():
font_path = static_dir / TITLE_FONT_RELATIVE_PATH
try:
font_bytes = font_path.read_bytes()
font_bytes = _subset_title_font_bytes(static_dir / TITLE_FONT_RELATIVE_PATH, glyphs)
except OSError:
continue
except Exception as exc:
logger.warning("itinerary_title_font_subset_failed", extra={"error_type": type(exc).__name__})
return ""
encoded = base64.b64encode(font_bytes).decode("ascii")
return (
"/* ZCOOL XiaoWei, SIL Open Font License 1.1 */"
Expand All @@ -99,11 +120,14 @@ def _embedded_title_font_face_css_cached() -> str:
return ""


def _embedded_title_font_face_css() -> str:
def _embedded_title_font_face_css(text: str) -> str:
# Only cache SUCCESS: a transient read failure (missing volume, bad cwd at
# first render) must not permanently strip the title font from every
# poster this process renders afterwards.
css = _embedded_title_font_face_css_cached()
glyphs = "".join(sorted(set(text)))
if not glyphs:
return ""
css = _embedded_title_font_face_css_cached(glyphs)
if not css:
_embedded_title_font_face_css_cached.cache_clear()
return css
Expand Down Expand Up @@ -1743,8 +1767,10 @@ def render_itinerary_map_svg(
f'<path d="{segment["path"]}" class="route-line{" transfer" if segment["transfer"] else ""}"/>'
for segment in route_segments
]
title = _svg_text(plan.get("title") or "定制旅行路线图")
subtitle = _svg_text(plan.get("subtitle") or "")
title_text = str(plan.get("title") or "定制旅行路线图")
subtitle_text = str(plan.get("subtitle") or "")
title = _svg_text(title_text)
subtitle = _svg_text(subtitle_text)
safe_background_url = (
background_image_url
if background_image_url.startswith(
Expand Down Expand Up @@ -1836,7 +1862,7 @@ def render_itinerary_map_svg(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}" role="img">'
)
title_font_face_css = _embedded_title_font_face_css() if safe_background_url else ""
title_font_face_css = _embedded_title_font_face_css(f"{title_text}{subtitle_text}") if safe_background_url else ""
return "\n".join(
[
svg_open[:-1] + f' data-density="{"dense" if dense_layout else "normal"}">',
Expand Down
4 changes: 2 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import {
calculateLogoPlacementScore,
calculateOfficialLogoPixelMatch,
chooseLogoPlacement,
} from "./logo-placement.mjs?v=0.1.62"
} from "./logo-placement.mjs?v=0.1.63"
import {
DEFAULT_RESPONSES_MODEL,
RESPONSES_MODEL_STORAGE_VERSION,
RESPONSES_REASONING_STORAGE_VERSION,
migrateStoredResponsesReasoningSettings,
migrateStoredResponsesSettings,
} from "./responses-settings.mjs?v=0.1.62"
} from "./responses-settings.mjs?v=0.1.63"

const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"])
const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh"
Expand Down
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PicGen Console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23108566'/%3E%3Cpath d='M32 13l16 9v20l-16 9-16-9V22l16-9z' fill='%23ecfdf5'/%3E%3Ccircle cx='32' cy='32' r='8' fill='%23108566'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1.62">
<link rel="stylesheet" href="styles.css?v=0.1.63">
</head>
<body class="auth-gate">
<div id="authOverlay" class="auth-page" aria-hidden="false">
Expand Down Expand Up @@ -1061,7 +1061,7 @@ <h2>管理员高级设置</h2>
</div>

<footer class="system-footer">
<span>PicGen Console v0.1.62</span>
<span>PicGen Console v0.1.63</span>
<span>本地代理保存结果到 <strong>data/outputs</strong></span>
</footer>
</div>
Expand Down Expand Up @@ -1371,6 +1371,6 @@ <h2 id="previewModalTitle">图片预览</h2>
<span id="toastMessageText"></span>
</div>

<script src="app.js?v=0.1.62" type="module"></script>
<script src="app.js?v=0.1.63" type="module"></script>
</body>
</html>
49 changes: 49 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import anyio
import pytest
from fastapi.testclient import TestClient
from fontTools.ttLib import TTFont
from PIL import Image
from starlette.types import Scope

Expand Down Expand Up @@ -1780,6 +1781,54 @@ def test_itinerary_map_svg_uses_soft_route_style_and_country_labels():
assert "font-family:'PicGenRouteTitle'" in svg


def test_itinerary_map_svg_embeds_a_bounded_title_font_subset():
plan = build_itinerary_map_plan(
title="北疆<秋日&环线",
subtitle="9/5 - 9/8",
stops=[
{"date": "D1", "name": "乌鲁木齐", "lat": 43.8256, "lng": 87.6168},
{"date": "D2", "name": "喀纳斯", "lat": 48.7087, "lng": 87.0174},
],
)

svg = render_itinerary_map_svg(plan, background_image_url=f"data:image/png;base64,{TINY_PNG_B64}")
encoded_font = svg.split("data:font/ttf;base64,", 1)[1].split(")", 1)[0]

font_bytes = base64.b64decode(encoded_font)
subset_font = TTFont(BytesIO(font_bytes))
embedded_characters = {chr(codepoint) for codepoint in subset_font.getBestCmap()}

assert set("北疆<秋日&环线9/5 - 8") <= embedded_characters
assert len(font_bytes) < 100_000
assert len(svg.encode("utf-8")) < 200_000


def test_itinerary_map_svg_falls_back_when_title_font_subsetting_fails(monkeypatch, caplog):
import picgen.itinerary_map as itinerary_map

plan = build_itinerary_map_plan(
title="北疆秋日环线",
subtitle="9/5 - 9/8",
stops=[
{"date": "D1", "name": "乌鲁木齐", "lat": 43.8256, "lng": 87.6168},
{"date": "D2", "name": "喀纳斯", "lat": 48.7087, "lng": 87.0174},
],
)

def fail_subset(*_args):
raise ValueError("broken font")

itinerary_map._embedded_title_font_face_css_cached.cache_clear()
monkeypatch.setattr(itinerary_map, "_subset_title_font_bytes", fail_subset)

with caplog.at_level("WARNING"):
svg = render_itinerary_map_svg(plan, background_image_url=f"data:image/png;base64,{TINY_PNG_B64}")

assert "@font-face{font-family:'PicGenRouteTitle'" not in svg
assert "北疆秋日环线" in svg
assert "itinerary_title_font_subset_failed" in caplog.text


def test_itinerary_country_labels_stay_inside_portrait_canvas():
plan = build_itinerary_map_plan(
title="欧洲文化路线",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_static_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None:
settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8")

assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js
assert 'from "./responses-settings.mjs?v=0.1.62"' in app_js
assert 'from "./responses-settings.mjs?v=0.1.63"' in app_js
assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js
assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js
assert "function migrateStoredResponsesSettings" in settings_js
Expand All @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None:
assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js
assert "composeLogoOverlayForCandidates" in app_js
assert "createOfficialLogoCanvas" in app_js
assert 'from "./logo-placement.mjs?v=0.1.62"' in app_js
assert 'from "./logo-placement.mjs?v=0.1.63"' in app_js
assert "chooseLogoPlacement" in app_js
assert "calculateLogoPlacementScore" in app_js
assert "calculateOfficialLogoPixelMatch" in app_js
Expand Down
Loading
Loading