33 Commits
v1.0.3 ... main

Author SHA1 Message Date
douboer
82ab714405 update at 2026-02-14 16:39:10 2026-02-14 16:39:10 +08:00
douboer
b7dac22327 update at 2026-02-14 16:38:52 2026-02-14 16:38:52 +08:00
douboer
1fb3b51beb update at 2026-02-14 16:35:57 2026-02-14 16:35:57 +08:00
douboer
e4e552cca6 update at 2026-02-14 16:32:17 2026-02-14 16:32:17 +08:00
douboer
35e17b4ba2 update at 2026-02-14 16:24:00 2026-02-14 16:24:00 +08:00
douboer
797da6eb76 update at 2026-02-14 16:11:35 2026-02-14 16:11:35 +08:00
douboer
88648748e7 update at 2026-02-14 16:09:24 2026-02-14 16:09:24 +08:00
douboer
46327636b0 update at 2026-02-14 16:06:19 2026-02-14 16:06:19 +08:00
douboer
3fc17e7f45 update at 2026-02-12 09:56:46 2026-02-12 09:56:46 +08:00
douboer
a8f6168433 update at 2026-02-11 19:45:21 2026-02-11 19:45:21 +08:00
douboer
9d8316332b update at 2026-02-11 19:40:43 2026-02-11 19:40:43 +08:00
douboer
e55d0d00c0 update at 2026-02-11 19:39:59 2026-02-11 19:39:59 +08:00
douboer
63f11b0067 update at 2026-02-11 19:35:34 2026-02-11 19:35:34 +08:00
douboer
cc6c9c8a99 update at 2026-02-11 19:31:11 2026-02-11 19:31:11 +08:00
douboer
b0c7ea4cba update at 2026-02-11 19:25:25 2026-02-11 19:25:25 +08:00
douboer
3d5d517439 update at 2026-02-11 19:17:20 2026-02-11 19:17:20 +08:00
douboer
b3add14421 update at 2026-02-11 19:13:31 2026-02-11 19:13:31 +08:00
douboer
74eebc19db update at 2026-02-11 18:37:30 2026-02-11 18:37:30 +08:00
douboer
2a737f2857 update at 2026-02-11 18:33:57 2026-02-11 18:33:57 +08:00
douboer
eb27b93d1e update at 2026-02-11 18:31:17 2026-02-11 18:31:17 +08:00
douboer
3bbd9e3069 update at 2026-02-11 18:27:51 2026-02-11 18:27:51 +08:00
douboer
22685a412b update at 2026-02-11 18:26:06 2026-02-11 18:26:06 +08:00
douboer
91fa46bd0c remove frontend/.gitignore and track frontend dist 2026-02-11 17:32:46 +08:00
douboer@gmail.com
494c9aec0e update at 2026-02-11 17:32:09 2026-02-11 17:32:09 +08:00
douboer@gmail.com
05ccc985c5 update at 2026-02-11 17:30:44 2026-02-11 17:30:44 +08:00
douboer
2329d36260 refresh gitignore 2026-02-11 17:17:24 +08:00
douboer
4c7cbc8ae2 update at 2026-02-11 17:05:23 2026-02-11 17:05:23 +08:00
douboer@gmail.com
831d708838 update at 2026-02-11 17:03:38 2026-02-11 17:03:38 +08:00
douboer
859ec836df update at 2026-02-11 16:55:46 2026-02-11 16:55:46 +08:00
douboer
1076ca1fa0 add frontend dist 2026-02-11 16:54:17 +08:00
douboer
7899b42e5c update at 2026-02-11 16:50:49 2026-02-11 16:50:49 +08:00
douboer
b5f5ade1f3 update at 2026-02-11 16:48:41 2026-02-11 16:48:41 +08:00
douboer
0dbb991522 update at 2026-02-11 12:10:49 2026-02-11 12:10:49 +08:00
32 changed files with 4019 additions and 404 deletions

View File

@@ -1,15 +0,0 @@
# 生产环境配置,此文件不会被提交到 Git已在 .gitignore 中)
# Supabase 配置(服务端/脚本使用)
SUPABASE_URL=https://fwwsxicqmsoimyrafyek.supabase.co
SUPABASE_KEY=sb_publishable_FgA0knvKiNMJEv4k-Q1cAA_IqZW0kjn
SUPABASE_SECRET_KEY=sb_secret_8uVHDbhMxE4HOlMBxMWjjw_vsHoZVI8
ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5tDM8CzT8ABCdYKrfzH8
ALIBABA_CLOUD_ACCESS_KEY_SECRET=hCejmpYoaCGehw2I4jcJo2qd2TwB62
# 微信小程序 appID和apprSecret
mpAppID=wxeda897f274ff33cf
mpAppSecret=a2240b3b6bbc3f7757ad689a89d334cc

5
.gitignore vendored
View File

@@ -9,8 +9,6 @@ lerna-debug.log*
node_modules
.npm-cache
dist
dist-ssr
*.local
# Editor directories and files
@@ -26,7 +24,9 @@ dist-ssr
*.ttf
frontend/vite.config.ts
frontend/dist/fonts.json
frontend/public/fonts.json
miniprogram/assets/fonts.json
# secrets
.env
@@ -35,3 +35,4 @@ frontend/public/fonts.json
private.*.key
miniprogram/private.*.key
project.private.config.json
__pycache__

5
AI_CONTEXT.md Normal file
View File

@@ -0,0 +1,5 @@
# 项目 AI 协作说明
## 项目目录结构

Binary file not shown.

View File

@@ -1,5 +1,7 @@
# Font2SVG - Nginx 配置mac.biboer.cn
# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API
# Font2SVG - Mac Nginx 配置mac.biboer.cn / mac-tunnel.biboer.cn
# 用途:
# 1) 为微信小程序提供字体清单、默认配置、路由配置与渲染 API
# 2) 提供 Web 应用静态页面frontend/dist
server {
listen 80;
@@ -9,15 +11,15 @@ server {
}
# Cloudflare Tunnel 入口(推荐):
# 外部 https://mac-tunnel.biboer.cn(443) -> cloudflared -> 本机 80直连本机服务
# 外部 https://mac-tunnel.biboer.cn(443) -> cloudflared -> 本机 80Nginx
server {
listen 80;
listen [::]:80;
server_name mac-tunnel.biboer.cn;
# 静态资源根目录(包含 fonts/、fonts.json、miniprogram/assets/*
# 项目根目录(包含 fonts/、fonts.json、miniprogram/assets/*、frontend/dist/*
root /Users/gavin/font2svg;
index fonts.json;
index index.html;
access_log /opt/homebrew/var/log/nginx/access.log;
error_log /opt/homebrew/var/log/nginx/error.log;
@@ -32,6 +34,14 @@ server {
# MIME
types {
text/html html htm shtml;
text/css css;
application/javascript js mjs;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/gif gif;
image/x-icon ico;
application/json json;
font/ttf ttf;
font/otf otf;
@@ -66,11 +76,14 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# 配置文件:短缓存,便于切换
# -------------------- 小程序静态配置 --------------------
# 字体清单:短缓存,便于更新
location = /fonts.json {
expires 1h;
add_header Cache-Control "public, must-revalidate" always;
try_files $uri =404;
# Web 端固定请求 /fonts.json依次回退到 dist/public/root 三处清单
try_files /frontend/dist/fonts.json /frontend/public/fonts.json /fonts.json =404;
}
location = /miniprogram/assets/fonts.json {
@@ -98,9 +111,45 @@ server {
try_files $uri =404;
}
# 默认仅提供静态文件
# -------------------- Web 静态应用frontend/dist --------------------
# Vite 构建产物目录:/assets/*
location ^~ /assets/ {
expires 30d;
add_header Cache-Control "public, immutable" always;
try_files /frontend/dist$uri =404;
}
# 常见入口文件(精确匹配)
location = /index.html {
try_files /frontend/dist/index.html =404;
}
location = /default.json {
try_files /frontend/dist/default.json =404;
}
location = /favicon.ico {
try_files /frontend/dist/favicon.ico =404;
}
location = /favicon.png {
try_files /frontend/dist/favicon.png =404;
}
location = /favicon.svg {
try_files /frontend/dist/favicon.svg =404;
}
location = /favicon_new.png {
try_files /frontend/dist/favicon_new.png =404;
}
# Web SPA 路由回退(刷新任意前端路径时返回 index.html
# 为 Web 单独指定 root避免 try_files 内部重定向循环导致 500。
location / {
try_files $uri =404;
root /Users/gavin/font2svg/frontend/dist;
try_files $uri $uri/ /index.html;
}
# 禁止访问隐藏文件

24
frontend/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1 @@
import{_ as v}from"./index-JIsg-Hgz.js";function p(t,e,n="text/plain"){const o=new Blob([t],{type:n});s(o,e)}function s(t,e){const n=URL.createObjectURL(t),o=document.createElement("a");o.href=n,o.download=e,document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(n)}function P(t,e){p(t,e,"image/svg+xml")}async function F(t,e="font2svg-export.zip"){const n=(await v(async()=>{const{default:r}=await import("./jszip.min-D7KnG0-e.js").then(i=>i.j);return{default:r}},[])).default,o=new n;for(const r of t)o.file(r.name,r.content);const a=await o.generateAsync({type:"blob"});s(a,e)}function d(t){if(!t)return null;const e=t.match(/-?\d+(\.\d+)?/);if(!e)return null;const n=Number(e[0]);return Number.isFinite(n)?n:null}function x(t){const n=new DOMParser().parseFromString(t,"image/svg+xml").documentElement,o=d(n.getAttribute("width")),a=d(n.getAttribute("height"));if(o&&a)return{width:o,height:a};const r=n.getAttribute("viewBox");if(r){const i=r.trim().split(/[\s,]+/).map(Number);if(i.length===4&&Number.isFinite(i[2])&&Number.isFinite(i[3]))return{width:Math.max(1,i[2]),height:Math.max(1,i[3])}}return{width:1024,height:1024}}async function _(t,e){const n=x(t),o=e?.scale??1,a=Math.max(1,Math.round((e?.width??n.width)*o)),r=Math.max(1,Math.round((e?.height??n.height)*o)),i=document.createElement("canvas");i.width=a,i.height=r;const l=i.getContext("2d");if(!l)throw new Error("无法创建 PNG 画布");e?.backgroundColor?(l.fillStyle=e.backgroundColor,l.fillRect(0,0,a,r)):l.clearRect(0,0,a,r);const f=new Blob([t],{type:"image/svg+xml;charset=utf-8"}),u=URL.createObjectURL(f);try{const c=new Image;await new Promise((w,b)=>{c.onload=()=>w(),c.onerror=()=>b(new Error("SVG 转 PNG 失败")),c.src=u}),l.drawImage(c,0,0,a,r)}finally{URL.revokeObjectURL(u)}const g=await new Promise(c=>{i.toBlob(c,"image/png")});if(!g)throw new Error("PNG 编码失败");return g}async function R(t,e,n){const o=await _(t,n);s(o,e)}function m(t){return t.replace(/[<>:"/\\|?*\x00-\x1F]/g,"_").replace(/\s+/g,"_").substring(0,200)}function h(t,e){const n=m(Array.from(t).slice(0,8).join(""));return`${m(e.substring(0,20))}_${n}`}function B(t,e){return`${h(t,e)}.svg`}function L(t,e){return`${h(t,e)}.png`}export{_ as convertSvgToPngBlob,s as downloadBlob,F as downloadMultipleFiles,R as downloadPngFromSvg,P as downloadSvg,p as downloadText,L as generatePngFilename,B as generateSvgFilename,m as sanitizeFilename};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 170 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

11
frontend/dist/default.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"inputText": "星程字体转换",
"fontSize": 50,
"textColor": "#dc2626",
"selectedFontIds": [
"0001"
],
"favoriteFontIds": [
"0001"
]
}

BIN
frontend/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

9
frontend/dist/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 170 KiB

BIN
frontend/dist/favicon_new.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

17
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 统一使用 favicon_new.png 作为站点图标,避免 Safari 继续优先采用 SVG 或 mask 图标 -->
<link rel="icon" type="image/png" href="/favicon_new.png" />
<link rel="shortcut icon" href="/favicon_new.png" />
<link rel="apple-touch-icon" href="/favicon_new.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Font2SVG - 字体转SVG工具</title>
<script type="module" crossorigin src="/assets/index-JIsg-Hgz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvZNih7U.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -20,6 +20,7 @@ type SvgPreviewExpose = {
}
const svgPreviewRef = ref<SvgPreviewExpose | null>(null)
const showWeixinQrModal = ref(false)
const fontSizePercent = computed(() => {
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
@@ -78,7 +79,7 @@ async function handleExport(format: ExportFormat) {
}
try {
const { generateSvg } = await import('./utils/svg-builder')
const { renderSvgByApi } = await import('./utils/render-api')
const {
convertSvgToPngBlob,
downloadSvg,
@@ -88,22 +89,27 @@ async function handleExport(format: ExportFormat) {
generateSvgFilename,
} = await import('./utils/download')
const renderByApi = async (fontId: string) => {
return renderSvgByApi({
fontId,
text: inputText,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
}
if (selectedItems.length === 1) {
// 单个字体,直接下载 SVG
const item = selectedItems[0]
if (!item?.fontInfo.font) {
alert('选中字体未加载完成,请稍后重试')
const fontId = item?.fontInfo?.id
if (!fontId) {
alert('选中字体信息无效,请重新选择后重试')
return
}
const font = item.fontInfo.font
const svgResult = await generateSvg({
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
const svgResult = await renderByApi(fontId)
if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName)
@@ -122,19 +128,13 @@ async function handleExport(format: ExportFormat) {
for (const item of selectedItems) {
try {
const font = item.fontInfo.font
if (!font) {
console.warn(`字体 ${item.fontInfo.name} 尚未加载,已跳过导出`)
const fontId = item?.fontInfo?.id
if (!fontId) {
console.warn('发现无效字体项,已跳过')
continue
}
const svgResult = await generateSvg({
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
const svgResult = await renderByApi(fontId)
if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName)
@@ -215,21 +215,21 @@ console.log('App.vue: script setup completed')
</script>
<template>
<div class="w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
<div class="app-container w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
<!-- Frame 7: 顶部工具栏 -->
<div class="flex gap-2 items-center shrink-0 h-24 px-2 py-1">
<div class="header-row flex gap-2 items-center shrink-0 h-24 px-2 py-1">
<!-- webicon - 48x48 -->
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0">
<div class="logo-box w-12 h-12 rounded-xl overflow-hidden shrink-0">
<img src="./assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
</div>
<!-- 星程字体转换 - 弹性宽度 -->
<div class="shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
<!-- 星程字体转换 - 弹性宽度 (移动端隐藏) -->
<div class="app-title shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
<img src="./assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
</div>
<!-- slider - 增加宽度 -->
<div class="flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
<div class="slider-box flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
<button
@click="handleFontSizeChange(uiStore.fontSize - 10)"
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
@@ -281,8 +281,8 @@ console.log('App.vue: script setup completed')
</label>
</div>
<!-- Frame 14: 输入框 - 弹性宽度 -->
<div class="flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
<!-- Frame 14: 输入框 - 弹性宽度 (桌面端显示) -->
<div class="input-box-desktop flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
<textarea
:value="uiStore.inputText"
@input="handleTextInput"
@@ -292,14 +292,14 @@ console.log('App.vue: script setup completed')
</div>
<!-- Export Group -->
<div class="flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
<div class="w-[18px] h-[42px] shrink-0 pointer-events-none">
<div class="export-group flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
<div class="export-label w-[18px] h-[42px] shrink-0 pointer-events-none">
<img src="./assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
</div>
<button
@click="handleExport('svg')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 SVG"
>
<img src="./assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
@@ -307,27 +307,66 @@ console.log('App.vue: script setup completed')
<button
@click="handleExport('png')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
class="export-btn w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 PNG"
>
<img src="./assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
</button>
<button
@click="showWeixinQrModal = true"
class="weixin-btn w-[54px] h-[42px] shrink-0 cursor-pointer hover:opacity-85 transition-opacity p-0 border-0 bg-transparent"
title="微信小程序"
>
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-full h-full object-contain" />
</button>
</div>
</div>
<!-- 移动端输入栏 (仅移动端显示) -->
<div class="input-row-mobile hidden px-2 py-1 shrink-0">
<div class="flex-1 bg-[#f7f8fa] rounded-lg px-2 h-[30px]">
<textarea
:value="uiStore.inputText"
@input="handleTextInput"
placeholder="此处输入内容"
class="w-full h-full bg-transparent border-none outline-none text-sm text-[#4e5969] placeholder-[#4e5969] resize-none leading-[30px] overflow-hidden"
/>
</div>
</div>
<!-- 微信小程序二维码弹窗 -->
<div
v-if="showWeixinQrModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showWeixinQrModal = false"
>
<div class="bg-white rounded-xl p-6 shadow-xl relative max-w-[320px]">
<button
@click="showWeixinQrModal = false"
class="absolute top-2 right-2 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 transition-colors border-0 bg-transparent cursor-pointer text-xl"
title="关闭"
>
&times;
</button>
<h3 class="text-center text-gray-800 font-medium mb-4">微信扫码使用小程序</h3>
<img src="./assets/icons/weixin.svg" alt="微信小程序二维码" class="w-64 h-64 object-contain" />
</div>
</div>
<!-- Frame 9: 主内容区 -->
<div class="flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
<!-- Frame 15: 左侧栏 - 弹性宽度 -->
<div class="flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
<div class="main-content flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
<!-- Frame 15: 左侧栏 - 弹性宽度 (桌面端) -->
<div class="sidebar-left flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
<!-- Frame 5: 字体选择 - 弹性高度 -->
<div class="flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
<div class="font-selector-box flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
<FontSelector />
</div>
</div>
<!-- Frame 5: 已收藏字体 - 弹性高度 -->
<div class="border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
<div class="favorites-box border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
<div class="flex items-center pr-[9px]">
<h2 class="text-base text-black shrink-0 leading-none flex-1">
已收藏字体{{ favoriteFontCount }}字体
@@ -351,7 +390,7 @@ console.log('App.vue: script setup completed')
</div>
<!-- Frame 8: 右侧预览区 - 弹性宽度 -->
<div class="flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
<div class="preview-box flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
<div class="flex items-center pr-[9px]">
<h2 class="text-base text-black shrink-0 leading-none flex-1">效果预览</h2>
<button
@@ -370,10 +409,27 @@ console.log('App.vue: script setup completed')
<SvgPreview ref="svgPreviewRef" />
</div>
</div>
<!-- 移动端底部区域: 选择 + 已收藏 并排 -->
<div class="bottom-section-mobile hidden">
<!-- 字体选择 -->
<div class="font-selector-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
<FontSelector />
</div>
</div>
<!-- 已收藏字体 -->
<div class="favorites-mobile border border-solid border-[#3EE4C3] rounded-lg p-1.5 flex flex-col gap-1 overflow-hidden">
<h2 class="text-sm text-black shrink-0 leading-none">已收藏</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden">
<FavoritesList />
</div>
</div>
</div>
</div>
<!-- 底部版权 -->
<div class="text-[#86909c] text-xs text-center shrink-0 h-6 pt-4 flex items-center justify-center px-2">
<div class="copyright-footer text-[#86909c] text-xs text-center shrink-0 flex items-center justify-center px-2">
@版权说明所有字体来源互联网分享仅供效果预览不做下载传播如有侵权请告知douboer@gmail.com
</div>
</div>
@@ -415,4 +471,188 @@ console.log('App.vue: script setup completed')
border-radius: 10px;
background: transparent;
}
/* 桌面端版权区域 */
.copyright-footer {
height: 24px;
padding-top: 16px;
}
/* 移动端响应式布局 (iOS/Android web) */
@media (max-width: 768px) {
.app-container {
padding: 4px;
overflow: hidden;
}
/* 顶部工具栏 - 压缩 */
.header-row {
height: 48px;
gap: 4px;
padding: 0 4px;
flex-wrap: nowrap;
}
.logo-box {
width: 48px;
height: 48px;
border-radius: 12px;
}
/* 隐藏标题 */
.app-title {
display: none;
}
/* slider 压缩 */
.slider-box {
width: 132px !important;
flex: 1;
min-width: 100px;
gap: 4px;
padding: 0 4px;
height: 24px !important;
}
/* 隐藏桌面端输入框 */
.input-box-desktop {
display: none;
}
/* 显示移动端输入框 */
.input-row-mobile {
display: flex !important;
gap: 6px;
margin-bottom: 4px;
padding-top: 0;
padding-bottom: 0;
}
/* 导出组缩小 */
.export-group {
gap: 2px;
padding: 2px;
}
.export-label {
width: 12px;
height: 28px;
}
.export-btn {
width: 32px;
height: 32px;
}
.export-btn img {
width: 32px;
height: 32px;
}
.weixin-btn {
width: 36px !important;
height: 28px !important;
}
/* 主内容区 - 改为垂直布局 */
.main-content {
flex-direction: column;
gap: 4px;
padding: 0 4px;
overflow: hidden;
}
/* 隐藏桌面端左侧栏 */
.sidebar-left {
display: none;
}
/* 预览区 - 占上半部分 */
.preview-box {
flex: 1;
min-height: 0;
border-color: #3EE4C3;
}
.preview-box h2 {
font-size: 14px;
}
/* 显示移动端底部区域 */
.bottom-section-mobile {
display: flex !important;
flex-direction: row;
gap: 8px;
flex: 1;
min-height: 0;
}
.font-selector-mobile,
.favorites-mobile {
flex: 1;
min-width: 0;
min-height: 0;
}
.font-selector-mobile h2,
.favorites-mobile h2 {
font-size: 14px;
margin-bottom: 4px;
}
/* 移动端字体选择器文字大小统一 */
.font-selector-mobile :deep(.text-\[16px\]) {
font-size: 14px !important;
}
.font-selector-mobile :deep(.text-base) {
font-size: 14px !important;
}
/* 版权信息缩小 */
.copyright-footer {
font-size: 10px;
padding: 4px 0;
height: auto;
flex-shrink: 0;
}
}
/* 更小屏幕适配 (iPhone SE 等) */
@media (max-width: 480px) {
.header-row {
height: 44px;
gap: 2px;
}
.logo-box {
width: 44px;
height: 44px;
}
.slider-box {
width: 100px !important;
min-width: 80px;
}
.export-btn {
width: 28px;
height: 28px;
}
.export-btn img {
width: 28px;
height: 28px;
}
.weixin-btn {
width: 32px !important;
height: 24px !important;
}
/* 手机端底部区域垂直布局 */
.bottom-section-mobile {
flex-direction: column;
}
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -56,7 +56,7 @@ const hasMatchedFonts = computed(() => {
<div class="sticky top-0 z-10 bg-white pt-1 pb-1">
<div class="flex items-center gap-3">
<div class="text-[16px] leading-none text-black font-bold shrink-0">
选择预览字体
选择字体
</div>
<div class="flex-1 min-w-0">
<div class="h-8 rounded-[10px] bg-[#F3EDF7] pl-2 flex items-center">

View File

@@ -2,20 +2,37 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { useFontStore } from '../stores/fontStore'
import { useUiStore } from '../stores/uiStore'
import { generateSvg } from '../utils/svg-builder'
import { renderSvgByApi } from '../utils/render-api'
import { MAX_CHARS_PER_LINE } from '../utils/text-layout'
import type { PreviewItem as PreviewItemType } from '../types/font'
import type { SvgGenerateResult, FontInfo } from '../types/font'
const fontStore = useFontStore()
const uiStore = useUiStore()
const previewItems = ref<PreviewItemType[]>([])
interface PreviewApiCacheItem {
svg: string
width: number
height: number
fontName: string
renderFontSize: number
}
interface PreviewRenderItem extends PreviewItemType {
baseSvg: string
baseWidth: number
baseHeight: number
renderFontSize: number
}
const previewItems = ref<PreviewRenderItem[]>([])
const isGenerating = ref(false)
const isBatchGenerating = ref(false)
const activePreviewFonts = ref<FontInfo[]>([])
const processedFontCount = ref(0)
const renderedPreviewCount = ref(0)
const previewTriggerItemEl = ref<HTMLElement | null>(null)
const previewErrorMessage = ref('')
const previewFonts = computed(() => fontStore.previewFonts)
const inputText = computed(() => uiStore.inputText)
@@ -24,17 +41,18 @@ const fillColor = computed(() => uiStore.textColor)
const PREVIEW_DEBOUNCE_MS = 240
const PREVIEW_CONCURRENCY = 4
const PREVIEW_GEOMETRY_CACHE_LIMIT = 600
const PREVIEW_COLOR_TOKEN = '__FONT2SVG_FILL__'
const PREVIEW_API_CACHE_LIMIT = 600
const PREVIEW_BATCH_SIZE = 20
const PREVIEW_PREFETCH_OFFSET = 10
const PREVIEW_RENDER_FONT_SIZE = 120
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
let previewGenerationToken = 0
let hasTriggeredInitialGenerate = false
let previewLazyLoadObserver: IntersectionObserver | null = null
let batchOwnerToken: number | null = null
const previewGeometryCache = new Map<string, Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }>()
const previewApiCache = new Map<string, PreviewApiCacheItem>()
const isAllPreviewSelected = computed(() => {
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
@@ -51,6 +69,10 @@ const visiblePreviewItems = computed(() => {
return previewItems.value.slice(0, renderedPreviewCount.value)
})
const hasRenderableInput = computed(() => {
return inputText.value.trim() !== '' && activePreviewFonts.value.length > 0
})
const previewTriggerIndex = computed(() => {
if (!hasMorePreviewItems.value || visiblePreviewItems.value.length <= 0) {
return -1
@@ -131,88 +153,173 @@ function setPreviewItemRef(el: unknown, index: number) {
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
}
function getPreviewGeometryCacheKey(fontInfo: FontInfo): string {
return [fontInfo.id, fontInfo.path, inputText.value, fontSize.value].join('::')
function normalizeHexColor(input: string, fallback = '#000000'): string {
const value = String(input || '').trim()
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return value
}
return fallback
}
function readPreviewGeometryFromCache(key: string) {
const cached = previewGeometryCache.get(key)
function clampFontSize(value: number, fallback = PREVIEW_RENDER_FONT_SIZE): number {
if (!Number.isFinite(value)) {
return fallback
}
return Math.max(1, Math.min(2048, Math.round(value)))
}
function formatSvgNumber(value: number): string {
const text = Number(value).toFixed(2)
return text.replace(/\.?0+$/, '')
}
function replaceSvgFillColor(svg: string, color: string): string {
const normalizedColor = normalizeHexColor(color)
if (!svg) return ''
if (/<g\b[^>]*\sfill="[^"]*"/.test(svg)) {
return svg.replace(/(<g\b[^>]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
}
return svg.replace(/<g\b([^>]*)>/, `<g$1 fill="${normalizedColor}">`)
}
function scaleSvgDimensions(svg: string, scale: number): string {
if (!svg || !Number.isFinite(scale) || scale <= 0) {
return svg
}
return svg
.replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (_, width) => {
const scaledWidth = Number(width) * scale
return `width="${formatSvgNumber(scaledWidth)}"`
})
.replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (_, height) => {
const scaledHeight = Number(height) * scale
return `height="${formatSvgNumber(scaledHeight)}"`
})
}
function buildPreviewCacheKey(
fontId: string,
text: string,
letterSpacing: number,
maxCharsPerLine: number,
): string {
const safeSpacing = Number.isFinite(letterSpacing) ? letterSpacing.toFixed(4) : '0.0000'
return [fontId, safeSpacing, String(maxCharsPerLine), text].join('::')
}
function getPreviewApiCacheKey(fontInfo: FontInfo): string {
return buildPreviewCacheKey(
fontInfo.id,
String(inputText.value || ''),
Number(uiStore.letterSpacing) || 0,
MAX_CHARS_PER_LINE,
)
}
function readPreviewFromCache(key: string): PreviewApiCacheItem | null {
const cached = previewApiCache.get(key)
if (!cached) {
return null
}
previewGeometryCache.delete(key)
previewGeometryCache.set(key, cached)
previewApiCache.delete(key)
previewApiCache.set(key, cached)
return cached
}
function writePreviewGeometryToCache(
key: string,
geometry: Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string },
) {
if (previewGeometryCache.has(key)) {
previewGeometryCache.delete(key)
function writePreviewToCache(key: string, value: PreviewApiCacheItem) {
if (previewApiCache.has(key)) {
previewApiCache.delete(key)
}
previewGeometryCache.set(key, geometry)
previewApiCache.set(key, value)
while (previewGeometryCache.size > PREVIEW_GEOMETRY_CACHE_LIMIT) {
const oldestKey = previewGeometryCache.keys().next().value
while (previewApiCache.size > PREVIEW_API_CACHE_LIMIT) {
const oldestKey = previewApiCache.keys().next().value
if (oldestKey === undefined) {
break
}
previewGeometryCache.delete(oldestKey)
previewApiCache.delete(oldestKey)
}
}
async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise<SvgGenerateResult | null> {
if (!fontInfo.font) {
return null
}
const cacheKey = getPreviewGeometryCacheKey(fontInfo)
let geometry = readPreviewGeometryFromCache(cacheKey)
if (!geometry) {
const baseResult = await generateSvg({
text: inputText.value,
font: fontInfo.font,
fontSize: fontSize.value,
fillColor: PREVIEW_COLOR_TOKEN,
})
geometry = {
width: baseResult.width,
height: baseResult.height,
fontName: baseResult.fontName,
svgTemplate: baseResult.svg,
}
writePreviewGeometryToCache(cacheKey, geometry)
}
function toStyledSvgResult(item: PreviewRenderItem): SvgGenerateResult {
const targetSize = clampFontSize(Number(fontSize.value), PREVIEW_RENDER_FONT_SIZE)
const renderSize = Number(item.renderFontSize) > 0 ? Number(item.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
const scale = targetSize / renderSize
const styledSvg = scaleSvgDimensions(
replaceSvgFillColor(item.baseSvg, fillColor.value),
scale,
)
return {
width: geometry.width,
height: geometry.height,
fontName: geometry.fontName,
svg: geometry.svgTemplate.split(PREVIEW_COLOR_TOKEN).join(fillColor.value),
svg: styledSvg,
width: Number(item.baseWidth) > 0 ? Number(item.baseWidth) * scale : 0,
height: Number(item.baseHeight) > 0 ? Number(item.baseHeight) * scale : 0,
fontName: item.svgResult.fontName || item.fontInfo.name,
}
}
function applyLocalStyleToPreviewItem(item: PreviewRenderItem): PreviewRenderItem {
return {
...item,
svgResult: toStyledSvgResult(item),
}
}
function applyLocalPreviewStyles() {
if (previewItems.value.length === 0) {
return
}
previewItems.value = previewItems.value.map(item => applyLocalStyleToPreviewItem(item))
}
async function getOrRenderPreviewBase(fontInfo: FontInfo): Promise<PreviewApiCacheItem | null> {
const cacheKey = getPreviewApiCacheKey(fontInfo)
const cached = readPreviewFromCache(cacheKey)
if (cached) {
return cached
}
const result = await renderSvgByApi({
fontId: fontInfo.id,
text: inputText.value,
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
const base: PreviewApiCacheItem = {
svg: result.svg,
width: result.width,
height: result.height,
fontName: result.fontName || fontInfo.name,
renderFontSize: PREVIEW_RENDER_FONT_SIZE,
}
writePreviewToCache(cacheKey, base)
return base
}
async function generatePreviewBatch(
fonts: FontInfo[],
startIndex: number,
batchSize: number,
generationToken: number,
): Promise<PreviewItemType[]> {
): Promise<{ items: PreviewRenderItem[]; errors: string[] }> {
const endIndex = Math.min(startIndex + batchSize, fonts.length)
const batchFonts = fonts.slice(startIndex, endIndex)
if (batchFonts.length === 0) {
return []
return { items: [], errors: [] }
}
const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
const items = new Array<PreviewItemType | null>(batchFonts.length).fill(null)
const items = new Array<PreviewRenderItem | null>(batchFonts.length).fill(null)
const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length)
let nextIndex = 0
const errors: string[] = []
const worker = async () => {
while (true) {
@@ -231,36 +338,31 @@ async function generatePreviewBatch(
continue
}
if (!fontInfo.loaded) {
try {
await fontStore.loadFont(fontInfo)
} catch (error) {
console.error(`Failed to load font ${fontInfo.name}:`, error)
continue
}
}
if (isStaleGeneration(generationToken) || !fontInfo.font) {
const base = await getOrRenderPreviewBase(fontInfo)
if (!base || isStaleGeneration(generationToken)) {
continue
}
try {
const svgResult = await generateSvgWithPreviewCache(fontInfo)
if (!svgResult) {
continue
}
if (isStaleGeneration(generationToken)) {
return
}
items[localIndex] = {
const item: PreviewRenderItem = {
fontInfo,
svgResult,
selected: selectedFontIdSet.has(fontInfo.id),
baseSvg: base.svg,
baseWidth: base.width,
baseHeight: base.height,
renderFontSize: base.renderFontSize,
svgResult: {
svg: '',
width: 0,
height: 0,
fontName: base.fontName || fontInfo.name,
},
}
items[localIndex] = applyLocalStyleToPreviewItem(item)
} catch (error) {
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error)
const message = error instanceof Error ? error.message : String(error)
console.error(`Failed to render preview for ${fontInfo.name}:`, error)
errors.push(`${fontInfo.name}: ${message}`)
}
}
}
@@ -268,23 +370,36 @@ async function generatePreviewBatch(
await Promise.all(Array.from({ length: workerCount }, () => worker()))
if (isStaleGeneration(generationToken)) {
return []
return { items: [], errors: [] }
}
return items.filter((item): item is PreviewItemType => item !== null)
return {
items: items.filter((item): item is PreviewRenderItem => item !== null),
errors,
}
}
async function loadNextPreviewBatch(generationToken: number) {
if (isBatchGenerating.value || isStaleGeneration(generationToken)) {
if (isStaleGeneration(generationToken)) {
return
}
if (isBatchGenerating.value) {
if (batchOwnerToken === generationToken) {
return
}
// 旧批次仍在收尾时,允许新批次接管
isBatchGenerating.value = false
batchOwnerToken = null
}
const startIndex = processedFontCount.value
if (startIndex >= activePreviewFonts.value.length) {
return
}
isBatchGenerating.value = true
batchOwnerToken = generationToken
try {
const batchItems = await generatePreviewBatch(
@@ -303,16 +418,110 @@ async function loadNextPreviewBatch(generationToken: number) {
activePreviewFonts.value.length,
)
previewItems.value = [...previewItems.value, ...batchItems]
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
const uniqueBatchItems = batchItems.items.filter(item => !existingIds.has(item.fontInfo.id))
previewItems.value = [...previewItems.value, ...uniqueBatchItems]
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + PREVIEW_BATCH_SIZE,
renderedPreviewCount.value + uniqueBatchItems.length,
previewItems.value.length,
)
if (batchItems.errors.length > 0 && previewItems.value.length === 0) {
previewErrorMessage.value = `预览生成失败:${batchItems.errors[0]}`
} else if (previewItems.value.length > 0) {
previewErrorMessage.value = ''
}
} catch (error) {
console.error('Failed to load preview batch:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally {
if (batchOwnerToken === generationToken) {
isBatchGenerating.value = false
batchOwnerToken = null
}
}
}
async function syncPreviewFontsIncrementally(previousFonts: FontInfo[]) {
const generationToken = ++previewGenerationToken
const nextPreviewFonts = [...previewFonts.value]
const validPreviewFontIds = new Set(nextPreviewFonts.map(font => font.id))
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
activePreviewFonts.value = nextPreviewFonts
previewErrorMessage.value = ''
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
previewItems.value = []
renderedPreviewCount.value = 0
processedFontCount.value = 0
isGenerating.value = false
isBatchGenerating.value = false
batchOwnerToken = null
return
}
const nextIdSet = new Set(nextPreviewFonts.map(font => font.id))
const previousIdSet = new Set(previousFonts.map(font => font.id))
const addedFonts = nextPreviewFonts.filter(font => !previousIdSet.has(font.id))
previewItems.value = previewItems.value.filter(item => nextIdSet.has(item.fontInfo.id))
renderedPreviewCount.value = Math.min(renderedPreviewCount.value, previewItems.value.length)
processedFontCount.value = Math.min(processedFontCount.value, nextPreviewFonts.length)
if (addedFonts.length > 0) {
if (previewItems.value.length === 0) {
isGenerating.value = true
}
try {
const addedBatch = await generatePreviewBatch(
addedFonts,
0,
addedFonts.length,
generationToken,
)
if (isStaleGeneration(generationToken)) {
return
}
const existingIds = new Set(previewItems.value.map(item => item.fontInfo.id))
const uniqueAddedItems = addedBatch.items.filter(item => !existingIds.has(item.fontInfo.id))
if (uniqueAddedItems.length > 0) {
// 新勾选字体插到顶部,避免全量刷新带来的闪烁。
previewItems.value = [...uniqueAddedItems, ...previewItems.value]
renderedPreviewCount.value = Math.min(
renderedPreviewCount.value + uniqueAddedItems.length,
previewItems.value.length,
)
}
if (addedBatch.errors.length > 0 && previewItems.value.length === 0) {
previewErrorMessage.value = `预览生成失败:${addedBatch.errors[0]}`
}
} catch (error) {
console.error('Failed to incrementally load preview fonts:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally {
if (!isStaleGeneration(generationToken)) {
isBatchGenerating.value = false
isGenerating.value = false
}
}
}
if (
!isStaleGeneration(generationToken) &&
previewItems.value.length === 0 &&
processedFontCount.value < activePreviewFonts.value.length &&
!isBatchGenerating.value
) {
isGenerating.value = true
try {
await loadNextPreviewBatch(generationToken)
} finally {
if (!isStaleGeneration(generationToken)) {
isGenerating.value = false
}
}
}
}
@@ -347,9 +556,12 @@ async function regeneratePreviews() {
processedFontCount.value = 0
previewItems.value = []
renderedPreviewCount.value = 0
previewErrorMessage.value = ''
if (!inputText.value || inputText.value.trim() === '' || nextPreviewFonts.length === 0) {
isGenerating.value = false
isBatchGenerating.value = false
batchOwnerToken = null
return
}
@@ -357,8 +569,26 @@ async function regeneratePreviews() {
try {
await loadNextPreviewBatch(generationToken)
if (
!isStaleGeneration(generationToken) &&
previewItems.value.length === 0 &&
processedFontCount.value < activePreviewFonts.value.length &&
!isBatchGenerating.value
) {
await loadNextPreviewBatch(generationToken)
}
if (
!isStaleGeneration(generationToken) &&
previewItems.value.length === 0 &&
nextPreviewFonts.length > 0 &&
inputText.value.trim() !== '' &&
!previewErrorMessage.value
) {
previewErrorMessage.value = '预览生成失败:服务未返回可用结果'
}
} catch (error) {
console.error('Failed to regenerate previews:', error)
previewErrorMessage.value = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
} finally {
if (!isStaleGeneration(generationToken)) {
isGenerating.value = false
@@ -367,12 +597,41 @@ async function regeneratePreviews() {
}
watch(
[previewFonts, inputText, fontSize, fillColor],
previewFonts,
(nextFonts, previousFonts) => {
if (!hasTriggeredInitialGenerate || !previousFonts) {
scheduleGeneratePreviews(false)
hasTriggeredInitialGenerate = true
return
}
const previousIds = new Set(previousFonts.map(font => font.id))
if (
nextFonts.length === previousFonts.length &&
nextFonts.every(font => previousIds.has(font.id))
) {
return
}
void syncPreviewFontsIncrementally(previousFonts)
hasTriggeredInitialGenerate = true
},
{ immediate: true },
)
watch(
[inputText, () => uiStore.letterSpacing],
() => {
scheduleGeneratePreviews(hasTriggeredInitialGenerate)
hasTriggeredInitialGenerate = true
},
{ immediate: true },
)
watch(
[fontSize, fillColor],
() => {
applyLocalPreviewStyles()
},
)
watch(
@@ -392,6 +651,7 @@ onBeforeUnmount(() => {
}
disconnectPreviewLazyLoadObserver()
previewGenerationToken += 1
batchOwnerToken = null
})
function toggleSelectItem(item: PreviewItemType) {
@@ -426,7 +686,19 @@ defineExpose({
<template>
<div class="flex flex-col gap-2">
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
{{ isGenerating ? '生成预览中...' : '请选择字体并输入内容' }}
{{
isGenerating
? '生成预览中...'
: (
previewErrorMessage
? previewErrorMessage
: (
inputText.trim() === '' || activePreviewFonts.length === 0
? '请选择字体并输入内容'
: (hasRenderableInput ? '暂无可显示预览' : '请选择字体并输入内容')
)
)
}}
</div>
<div v-else class="flex flex-col gap-2">

View File

@@ -13,20 +13,43 @@ interface FontListItem {
export function useFontLoader() {
const fontStore = useFontStore()
async function fetchFontListWithFallback(): Promise<FontListItem[]> {
const candidates = ['/frontend/public/fonts.json', '/fonts.json']
const errors: string[] = []
for (const url of candidates) {
const requestUrl = `${url}?_ts=${Date.now()}`
try {
console.log(`Fetching ${requestUrl}...`)
const response = await fetch(requestUrl, { cache: 'no-store' })
console.log(`${url} response:`, response.status, response.statusText)
if (!response.ok) {
errors.push(`${url}: HTTP ${response.status}`)
continue
}
const data = await response.json()
if (!Array.isArray(data)) {
errors.push(`${url}: JSON 不是数组`)
continue
}
return data as FontListItem[]
} catch (error) {
errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(errors.join(' | '))
}
async function loadFontList() {
console.log('Starting to load font list...')
fontStore.isLoadingFonts = true
try {
console.log('Fetching /fonts.json...')
const response = await fetch('/fonts.json')
console.log('Response status:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`Failed to load fonts.json: ${response.statusText}`)
}
const fontList: FontListItem[] = await response.json()
const fontList = await fetchFontListWithFallback()
console.log('Loaded font list:', fontList.length, 'fonts')
// 转换为 FontInfo
@@ -50,7 +73,7 @@ export function useFontLoader() {
console.log(`Successfully loaded ${fontList.length} fonts`)
} catch (error) {
console.error('Failed to load font list:', error)
alert('加载字体列表失败,请刷新页面重试')
alert(`加载字体列表失败${error instanceof Error ? error.message : '未知错误'}`)
} finally {
fontStore.isLoadingFonts = false
console.log('Font loading finished')

View File

@@ -0,0 +1,140 @@
import { MAX_CHARS_PER_LINE } from './text-layout'
const DEFAULT_RENDER_API_URL = '/api/render-svg'
const REQUEST_TIMEOUT_MS = 30000
interface RenderApiResponseData {
svg: string
width: number
height: number
fontName: string
fontId?: string
}
interface RenderApiResponseBody {
ok?: boolean
data?: RenderApiResponseData
error?: string
}
type BrowserWithRenderApiConfig = Window & {
__FONT2SVG_API_URL__?: string
}
export interface RenderSvgPayload {
fontId: string
text: string
fontSize?: number
fillColor?: string
letterSpacing?: number
maxCharsPerLine?: number
}
export interface RenderSvgResult {
svg: string
width: number
height: number
fontName: string
fontId: string
}
function resolveRenderApiUrl(): string {
const envUrl = (import.meta.env.VITE_RENDER_API_URL as string | undefined)?.trim()
if (envUrl) {
return envUrl
}
if (typeof window !== 'undefined') {
const globalUrl = (window as BrowserWithRenderApiConfig).__FONT2SVG_API_URL__
if (typeof globalUrl === 'string' && globalUrl.trim()) {
return globalUrl.trim()
}
}
return DEFAULT_RENDER_API_URL
}
function normalizeRenderResult(data: RenderApiResponseData | undefined): RenderSvgResult {
if (!data || typeof data !== 'object') {
throw new Error('渲染服务返回格式无效')
}
const svg = typeof data.svg === 'string' ? data.svg : ''
if (!svg.trim()) {
throw new Error('渲染服务未返回有效 SVG')
}
return {
svg,
width: Number(data.width) || 0,
height: Number(data.height) || 0,
fontName: data.fontName || 'Unknown',
fontId: data.fontId || '',
}
}
function normalizePayload(payload: RenderSvgPayload) {
const fontId = String(payload.fontId || '').trim()
const text = String(payload.text || '')
if (!fontId) {
throw new Error('缺少字体 ID')
}
if (!text.trim()) {
throw new Error('文本内容不能为空')
}
return {
fontId,
text,
fontSize: Number(payload.fontSize) || 120,
fillColor: payload.fillColor || '#000000',
letterSpacing: Number(payload.letterSpacing) || 0,
maxCharsPerLine: Number(payload.maxCharsPerLine) || MAX_CHARS_PER_LINE,
}
}
export async function renderSvgByApi(payload: RenderSvgPayload): Promise<RenderSvgResult> {
const requestBody = normalizePayload(payload)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let response: Response
try {
response = await fetch(resolveRenderApiUrl(), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('渲染服务请求超时')
}
throw new Error(`渲染服务请求失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
clearTimeout(timer)
}
let body: RenderApiResponseBody | null = null
try {
body = (await response.json()) as RenderApiResponseBody
} catch {
body = null
}
if (!response.ok) {
const errMsg = body && typeof body.error === 'string' && body.error.trim()
? body.error
: `渲染服务请求失败HTTP ${response.status}`
throw new Error(errMsg)
}
if (!body || !body.ok) {
throw new Error((body && body.error) || '渲染服务返回错误')
}
return normalizeRenderResult(body.data)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +0,0 @@
[
{
"id": "0001",
"name": "AlimamaDaoLiTi",
"filename": "AlimamaDaoLiTi.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/AlimamaDaoLiTi.ttf"
},
{
"id": "0002",
"name": "Hangeuljaemin4-Regular",
"filename": "Hangeuljaemin4-Regular.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/Hangeuljaemin4-Regular.ttf"
},
{
"id": "0003",
"name": "I.顏體",
"filename": "I.顏體.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/I.顏體.ttf"
},
{
"id": "0004",
"name": "XCDUANZHUANGSONGTI",
"filename": "XCDUANZHUANGSONGTI.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/XCDUANZHUANGSONGTI.ttf"
},
{
"id": "0005",
"name": "qiji-combo",
"filename": "qiji-combo.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/qiji-combo.ttf"
},
{
"id": "0006",
"name": "临海隶书",
"filename": "临海隶书.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/临海隶书.ttf"
},
{
"id": "0007",
"name": "京華老宋体_KingHwa_OldSong",
"filename": "京華老宋体_KingHwa_OldSong.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/京華老宋体_KingHwa_OldSong.ttf"
},
{
"id": "0008",
"name": "优设标题黑",
"filename": "优设标题黑.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/优设标题黑.ttf"
},
{
"id": "0009",
"name": "包图小白体",
"filename": "包图小白体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/包图小白体.ttf"
},
{
"id": "0010",
"name": "源界明朝",
"filename": "源界明朝.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/源界明朝.ttf"
},
{
"id": "0011",
"name": "演示佛系体",
"filename": "演示佛系体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/演示佛系体.ttf"
},
{
"id": "0012",
"name": "站酷快乐体",
"filename": "站酷快乐体.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/站酷快乐体.ttf"
},
{
"id": "0013",
"name": "问藏书房",
"filename": "问藏书房.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/问藏书房.ttf"
},
{
"id": "0014",
"name": "霞鹜臻楷",
"filename": "霞鹜臻楷.ttf",
"category": "其他字体",
"path": "/fonts/其他字体/霞鹜臻楷.ttf"
},
{
"id": "0015",
"name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf",
"category": "庞门正道",
"path": "/fonts/庞门正道/庞门正道标题体.ttf"
},
{
"id": "0016",
"name": "庞门正道标题体",
"filename": "庞门正道标题体.ttf",
"category": "庞门正道-测试",
"path": "/fonts/庞门正道-测试/庞门正道标题体.ttf"
},
{
"id": "0017",
"name": "王漢宗勘亭流繁",
"filename": "王漢宗勘亭流繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗勘亭流繁.ttf"
},
{
"id": "0018",
"name": "王漢宗新潮體",
"filename": "王漢宗新潮體.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗新潮體.ttf"
},
{
"id": "0019",
"name": "王漢宗波卡體空陰",
"filename": "王漢宗波卡體空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗波卡體空陰.ttf"
},
{
"id": "0020",
"name": "王漢宗細黑體繁",
"filename": "王漢宗細黑體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗細黑體繁.ttf"
},
{
"id": "0021",
"name": "王漢宗綜藝體雙空陰",
"filename": "王漢宗綜藝體雙空陰.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗綜藝體雙空陰.ttf"
},
{
"id": "0022",
"name": "王漢宗超明體繁",
"filename": "王漢宗超明體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗超明體繁.ttf"
},
{
"id": "0023",
"name": "王漢宗酷儷海報",
"filename": "王漢宗酷儷海報.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗酷儷海報.ttf"
},
{
"id": "0024",
"name": "王漢宗顏楷體繁",
"filename": "王漢宗顏楷體繁.ttf",
"category": "王漢宗",
"path": "/fonts/王漢宗/王漢宗顏楷體繁.ttf"
}
]

View File

@@ -1,5 +1,5 @@
{
"active": "A",
"active": "B",
"cooldownMinutes": 0,
"servers": {
"A": {

View File

@@ -104,6 +104,22 @@ function showExportError(title, error, fallback) {
})
}
function toPngSaveError(saveResult) {
if (!saveResult || saveResult.success) {
return null
}
if (saveResult.reason === 'private_api_banned') {
return new Error('当前小程序账号不支持“保存到相册”private api banned。请改用“导出 SVG”分享或更换支持该能力的账号/版本。')
}
if (saveResult.reason === 'auth_denied') {
return saveResult.error || new Error('保存失败,请检查相册权限')
}
return saveResult.error || new Error('保存 PNG 失败')
}
function writePngBufferToTempFile(pngBuffer, fontName) {
const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font'
const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png`
@@ -906,7 +922,7 @@ Page({
const saveResult = await savePngToAlbum(pngPath)
if (!saveResult.success) {
throw saveResult.error || new Error('保存 PNG 失败')
throw toPngSaveError(saveResult)
}
}
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
@@ -970,7 +986,7 @@ Page({
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
showExportError('导出 PNG 失败', toPngSaveError(saveResult), '保存 PNG 失败')
}
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')
@@ -1029,7 +1045,7 @@ Page({
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
showExportError('导出 PNG 失败', toPngSaveError(saveResult), '保存 PNG 失败')
}
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')

View File

@@ -104,7 +104,9 @@ async function savePngToAlbum(filePath) {
return { success: true }
} catch (error) {
const errMsg = String(error && error.errMsg ? error.errMsg : error)
const needAuth = errMsg.includes('auth deny') || errMsg.includes('authorize')
const lowerErrMsg = errMsg.toLowerCase()
const needAuth = lowerErrMsg.includes('auth deny') || lowerErrMsg.includes('authorize')
const privateApiBanned = lowerErrMsg.includes('private api banned')
if (needAuth) {
const modalRes = await showModal({
@@ -120,6 +122,10 @@ async function savePngToAlbum(filePath) {
return {
success: false,
needAuth,
privateApiBanned,
reason: privateApiBanned
? 'private_api_banned'
: (needAuth ? 'auth_denied' : 'save_failed'),
error,
}
}

13
run.sh
View File

@@ -1,10 +1,13 @@
source .venv/bin/activate
lsof -ti:5173,5174,5175,5176 | xargs kill -9 2>/dev/null;
python font2svg.py --fontdir font --text "星程紫微" --outdir svg
# 新增字体放在frontend/public/fonts目录下运行以下命令fonts.json会被更新
# 新增字体放在fonts目录下运行以下命令fonts.json会被更新
npm run prepare-fonts
# 查看请求日志 MacMini
tail -f /tmp/font2svg-api.log
# 查看请求日志 Linux
sudo journalctl -u font2svg-api -f

376
scripts/font-id-state.json Normal file
View File

@@ -0,0 +1,376 @@
{
"version": 1,
"nextId": 370,
"pathToId": {
"上首造字/No.1-上首锐博体.ttf": "0001",
"上首造字/No.10-上首叮当体.ttf": "0002",
"上首造字/No.100-上首纤宋体.ttf": "0003",
"上首造字/No.101-上首武士黑体.ttf": "0004",
"上首造字/No.102-上首玄藏体.ttf": "0005",
"上首造字/No.103-上首星际体.ttf": "0006",
"上首造字/No.104-上首鸿志手写体.ttf": "0007",
"上首造字/No.105-上首逍遥体.ttf": "0008",
"上首造字/No.106-上首简墨体.ttf": "0009",
"上首造字/No.107-上首松羽体.ttf": "0010",
"上首造字/No.108-上首燕尾体.ttf": "0011",
"上首造字/No.109-上首韶华体.ttf": "0012",
"上首造字/No.11-上首车车体.ttf": "0013",
"上首造字/No.110-上首烈宋体.ttf": "0014",
"上首造字/No.111-上首方舟体.ttf": "0015",
"上首造字/No.112-上首壹方体.ttf": "0016",
"上首造字/No.113-上首轩正体.ttf": "0017",
"上首造字/No.114-上首长城体.ttf": "0018",
"上首造字/No.115-上首音符体.ttf": "0019",
"上首造字/No.116-上首如意体.ttf": "0020",
"上首造字/No.117-上首酷峰体.ttf": "0021",
"上首造字/No.118-上首星耀体.ttf": "0022",
"上首造字/No.119-上首罗马体.ttf": "0023",
"上首造字/No.12-上首秀圆体.ttf": "0024",
"上首造字/No.120-上首宽言体.ttf": "0025",
"上首造字/No.121-上首刻宋体.ttf": "0026",
"上首造字/No.122-上首云润体.ttf": "0027",
"上首造字/No.123-上首方义手刷体.ttf": "0028",
"上首造字/No.124-上首艺圆体.ttf": "0029",
"上首造字/No.125-上首方尊体.ttf": "0030",
"上首造字/No.126-上首吉言体.ttf": "0031",
"上首造字/No.127-上首简一体.ttf": "0032",
"上首造字/No.128-上首玄黑体.ttf": "0033",
"上首造字/No.129-上首墨遥体.ttf": "0034",
"上首造字/No.13-上首华凤书法体.ttf": "0035",
"上首造字/No.130-上首追光手写体.ttf": "0036",
"上首造字/No.131-上首斗金体.ttf": "0037",
"上首造字/No.132-上首豆干体.ttf": "0038",
"上首造字/No.133-上首魔法体.ttf": "0039",
"上首造字/No.134-上首文化体.ttf": "0040",
"上首造字/No.135-上首龙吟体.ttf": "0041",
"上首造字/No.136-上首悠然体.ttf": "0042",
"上首造字/No.137-上首本草纲目体.ttf": "0043",
"上首造字/No.138-上首星斗体.ttf": "0044",
"上首造字/No.139-上首鼎宋体.ttf": "0045",
"上首造字/No.14-上首水滴体.ttf": "0046",
"上首造字/No.140-上首妖刀体.ttf": "0047",
"上首造字/No.141-上首高达体.ttf": "0048",
"上首造字/No.142-上首综艺体.ttf": "0049",
"上首造字/No.143-上首圆木体.ttf": "0050",
"上首造字/No.144-上首逍遥书法体.ttf": "0051",
"上首造字/No.145-上首高光体.ttf": "0052",
"上首造字/No.146-上首清雅宋体.ttf": "0053",
"上首造字/No.147-上首云隶体.ttf": "0054",
"上首造字/No.148-上首标榜体.ttf": "0055",
"上首造字/No.149-上首奶酪体.ttf": "0056",
"上首造字/No.15-上首润黑体.ttf": "0057",
"上首造字/No.150-上首秋刀体.ttf": "0058",
"上首造字/No.151-上首凤鸣体.ttf": "0059",
"上首造字/No.152-上首南枝体.ttf": "0060",
"上首造字/No.152上首南枝体.ttf": "0061",
"上首造字/No.153-上首可乐体.ttf": "0062",
"上首造字/No.154-上首长青体.ttf": "0063",
"上首造字/No.155-上首中正宋体.ttf": "0064",
"上首造字/No.156-上首曲奇体.ttf": "0065",
"上首造字/No.157-上首萌萌手写体.ttf": "0066",
"上首造字/No.158-上首朗润体.ttf": "0067",
"上首造字/No.159-上首财神体.ttf": "0068",
"上首造字/No.16-上首锐圆体.ttf": "0069",
"上首造字/No.160-上首混沌体.ttf": "0070",
"上首造字/No.161-上首刺云体.ttf": "0071",
"上首造字/No.162-上首幻视体.ttf": "0072",
"上首造字/No.163-上首万象体.ttf": "0073",
"上首造字/No.164-上首简正体.ttf": "0074",
"上首造字/No.165-上首刺秦体.ttf": "0075",
"上首造字/No.166-上首暗黑体.ttf": "0076",
"上首造字/No.167-上首萌虎体.ttf": "0077",
"上首造字/No.168-上首雪糕体.ttf": "0078",
"上首造字/No.169-上首逐浪书法体.ttf": "0079",
"上首造字/No.17-上首钝黑体.ttf": "0080",
"上首造字/No.170-上首方玉体.ttf": "0081",
"上首造字/No.171-上首龙猫体.ttf": "0082",
"上首造字/No.172-上首木兰体.ttf": "0083",
"上首造字/No.173-上首山水宋体.ttf": "0084",
"上首造字/No.174-上首扑克体.ttf": "0085",
"上首造字/No.175-上首潮玩体.ttf": "0086",
"上首造字/No.176-上首鲁班宋体.ttf": "0087",
"上首造字/No.177-上首奶糖体.ttf": "0088",
"上首造字/No.178-上首浓墨体.ttf": "0089",
"上首造字/No.179-上首朋克体.ttf": "0090",
"上首造字/No.18-上首疾风书法体.ttf": "0091",
"上首造字/No.180-上首风云书法体.ttf": "0092",
"上首造字/No.181-上首未来体.ttf": "0093",
"上首造字/No.182-上首象牙体.ttf": "0094",
"上首造字/No.183-上首悦圆体.ttf": "0095",
"上首造字/No.184-上首月牙体.ttf": "0096",
"上首造字/No.185-上首矩正体.ttf": "0097",
"上首造字/No.186-上首鸿运体.ttf": "0098",
"上首造字/No.187-上首童年体.ttf": "0099",
"上首造字/No.188-上首凌宋体.ttf": "0100",
"上首造字/No.189-上首品黑体.ttf": "0101",
"上首造字/No.19-上首简黑极细体.ttf": "0102",
"上首造字/No.190-上首波波手写体.ttf": "0103",
"上首造字/No.191-上首祥瑞体.ttf": "0104",
"上首造字/No.192-上首傲骨体.ttf": "0105",
"上首造字/No.193-上首轩隶体.ttf": "0106",
"上首造字/No.194-上首品宋体.ttf": "0107",
"上首造字/No.195-上首金枝体.ttf": "0108",
"上首造字/No.196-上首游龙体.ttf": "0109",
"上首造字/No.197-上首魔方体.ttf": "0110",
"上首造字/No.198-上首呆呆体.ttf": "0111",
"上首造字/No.199-上首新潮体.ttf": "0112",
"上首造字/No.2-上首维黑体.ttf": "0113",
"上首造字/No.20-上首简宋纤细体.ttf": "0114",
"上首造字/No.200-上首博雅体.ttf": "0115",
"上首造字/No.201-上首鹊桥体.ttf": "0116",
"上首造字/No.202-上首雅倩体.ttf": "0117",
"上首造字/No.203-上首祥瑞手写体.ttf": "0118",
"上首造字/No.204-上首茶颜体.ttf": "0119",
"上首造字/No.205-上首羽刃体.ttf": "0120",
"上首造字/No.206-上首幻灵体.ttf": "0121",
"上首造字/No.207-上首西游体.ttf": "0122",
"上首造字/No.208-上首古居体.ttf": "0123",
"上首造字/No.209-上首润泽体.ttf": "0124",
"上首造字/No.21-上首传奇书法体.ttf": "0125",
"上首造字/No.210-上首醒狮体.ttf": "0126",
"上首造字/No.211-上首冰刃体.ttf": "0127",
"上首造字/No.212-上首玄武体.ttf": "0128",
"上首造字/No.213-上首雪兔体.ttf": "0129",
"上首造字/No.214-上首朱雀体.ttf": "0130",
"上首造字/No.215-上首朗月体.ttf": "0131",
"上首造字/No.216-上首招财猫体.ttf": "0132",
"上首造字/No.217-上首星舰体.ttf": "0133",
"上首造字/No.218-上首正舟体.ttf": "0134",
"上首造字/No.219-上首白虎书法体.ttf": "0135",
"上首造字/No.22-上首简圆体.ttf": "0136",
"上首造字/No.220-上首梧桐体.ttf": "0137",
"上首造字/No.221-上首标题体.ttf": "0138",
"上首造字/No.222-上首阿呆体.ttf": "0139",
"上首造字/No.223-上首月兔体.ttf": "0140",
"上首造字/No.224-上首绫黑体.ttf": "0141",
"上首造字/No.225-上首福禄体.ttf": "0142",
"上首造字/No.226-上首机甲体.ttf": "0143",
"上首造字/No.227-上首晴竹体.ttf": "0144",
"上首造字/No.228-上首光折体.ttf": "0145",
"上首造字/No.229-上首时光体.ttf": "0146",
"上首造字/No.23-上首布丁体.ttf": "0147",
"上首造字/No.230-上首胖虎体.ttf": "0148",
"上首造字/No.231-上首奇妙体.ttf": "0149",
"上首造字/No.232-上首龙腾体.ttf": "0150",
"上首造字/No.233-上首怪兽体.ttf": "0151",
"上首造字/No.234-上首极限体.ttf": "0152",
"上首造字/No.235-上首御风书法体.ttf": "0153",
"上首造字/No.236-上首东坡体.ttf": "0154",
"上首造字/No.237-上首木鱼体.ttf": "0155",
"上首造字/No.238-上首妙趣体.ttf": "0156",
"上首造字/No.239-上首青云体.ttf": "0157",
"上首造字/No.24-上首软糖体.ttf": "0158",
"上首造字/No.240-上首雅隶体.ttf": "0159",
"上首造字/No.241-上首蔷薇体.ttf": "0160",
"上首造字/No.242-上首海浪体.ttf": "0161",
"上首造字/No.243-上首青柏体.ttf": "0162",
"上首造字/No.244-上首墩墩体.ttf": "0163",
"上首造字/No.245-上首无限体.ttf": "0164",
"上首造字/No.246-上首本墨体.ttf": "0165",
"上首造字/No.247-上首祥云体.ttf": "0166",
"上首造字/No.248-上首非凡体.ttf": "0167",
"上首造字/No.249-上首市井体.ttf": "0168",
"上首造字/No.25-上首锐圆极细体.ttf": "0169",
"上首造字/No.250-上首松柏体.ttf": "0170",
"上首造字/No.251-上首水波体.ttf": "0171",
"上首造字/No.252-上首大头手写体.ttf": "0172",
"上首造字/No.253-上首银蛇体.ttf": "0173",
"上首造字/No.254-上首琥珀体.ttf": "0174",
"上首造字/No.255-上首敦煌隶书体.ttf": "0175",
"上首造字/No.256-上首楼兰体.ttf": "0176",
"上首造字/No.257-上首福袋体.ttf": "0177",
"上首造字/No.258-上首惊鸿体.ttf": "0178",
"上首造字/No.259-上首果冻体.ttf": "0179",
"上首造字/No.26-上首悦风体.ttf": "0180",
"上首造字/No.260-上首山岳体.ttf": "0181",
"上首造字/No.261-上首惊云体.ttf": "0182",
"上首造字/No.262-上首江南宋体.ttf": "0183",
"上首造字/No.263-上首蜡笔体.ttf": "0184",
"上首造字/No.264-上首赛博体.ttf": "0185",
"上首造字/No.265-上首心动体.ttf": "0186",
"上首造字/No.266-上首千禧体.ttf": "0187",
"上首造字/No.267-上首柔黑体.ttf": "0188",
"上首造字/No.268-上首黑仪体.ttf": "0189",
"上首造字/No.269-上首涂鸦体.ttf": "0190",
"上首造字/No.27-上首锐棱体.ttf": "0191",
"上首造字/No.270-上首天坛体.ttf": "0192",
"上首造字/No.271-上首麒麟体.ttf": "0193",
"上首造字/No.272-上首青瓦体.ttf": "0194",
"上首造字/No.273-上首风云体.ttf": "0195",
"上首造字/No.274-上首战魂体.ttf": "0196",
"上首造字/No.275-上首招福体.ttf": "0197",
"上首造字/No.276-上首佛陀体.ttf": "0198",
"上首造字/No.277-上首大漠体.ttf": "0199",
"上首造字/No.278-上首绘梦体.ttf": "0200",
"上首造字/No.279-上首东方体.ttf": "0201",
"上首造字/No.28-上首星岩体.ttf": "0202",
"上首造字/No.280-上首英雄体.ttf": "0203",
"上首造字/No.281-上首破冰体.ttf": "0204",
"上首造字/No.282-上首玄机体.ttf": "0205",
"上首造字/No.283-上首舞狮体.ttf": "0206",
"上首造字/No.284-上首北斗体.ttf": "0207",
"上首造字/No.285-上首万福体.ttf": "0208",
"上首造字/No.286-上首云木体.ttf": "0209",
"上首造字/No.287-上首方印体.ttf": "0210",
"上首造字/No.288-上首几何体.ttf": "0211",
"上首造字/No.289-上首江湖书法体.ttf": "0212",
"上首造字/No.29-上首先锋体.ttf": "0213",
"上首造字/No.290-上首书印体.ttf": "0214",
"上首造字/No.291-上首淘气体.ttf": "0215",
"上首造字/No.292-上首暗格体.ttf": "0216",
"上首造字/No.293-上首逸尘体.ttf": "0217",
"上首造字/No.294-上首小新体.ttf": "0218",
"上首造字/No.295-上首荒原体.ttf": "0219",
"上首造字/No.296-上首松针体.ttf": "0220",
"上首造字/No.297-上首墨玄体.ttf": "0221",
"上首造字/No.298-上首沧海书法体.ttf": "0222",
"上首造字/No.299-上首王朝体.ttf": "0223",
"上首造字/No.3-上首方圆体.ttf": "0224",
"上首造字/No.30-上首倾城体.ttf": "0225",
"上首造字/No.300-上首古皇体.ttf": "0226",
"上首造字/No.301-上首言亭体.ttf": "0227",
"上首造字/No.302-上首流金体.ttf": "0228",
"上首造字/No.303-上首墨宋体.ttf": "0229",
"上首造字/No.304-上首龙虎体.ttf": "0230",
"上首造字/No.305-上首点墨体.ttf": "0231",
"上首造字/No.306-上首万圣体.ttf": "0232",
"上首造字/No.307-上首润元体.ttf": "0233",
"上首造字/No.308-上首极速体.ttf": "0234",
"上首造字/No.309-上首栏栅体.ttf": "0235",
"上首造字/No.31-上首简黑纤细体.ttf": "0236",
"上首造字/No.310-上首浮华体.ttf": "0237",
"上首造字/No.311-上首梦想体.ttf": "0238",
"上首造字/No.312-上首诗宋体.ttf": "0239",
"上首造字/No.313-上首亦方体.ttf": "0240",
"上首造字/No.314-上首心愿体.ttf": "0241",
"上首造字/No.315-上首西施体.ttf": "0242",
"上首造字/No.316-上首国美体.ttf": "0243",
"上首造字/No.317-上首润玉体.ttf": "0244",
"上首造字/No.318-上首炫酷体.ttf": "0245",
"上首造字/No.319-上首云烟体.ttf": "0246",
"上首造字/No.32-上首芊芊体.ttf": "0247",
"上首造字/No.320-上首蜀汉体.ttf": "0248",
"上首造字/No.321-上首盛唐体.ttf": "0249",
"上首造字/No.322-上首折扇体.ttf": "0250",
"上首造字/No.323-上首青竹体.ttf": "0251",
"上首造字/No.324-上首胭脂体.ttf": "0252",
"上首造字/No.33-上首方糖体.ttf": "0253",
"上首造字/No.34-上首简黑中细体.ttf": "0254",
"上首造字/No.35-上首积木体.ttf": "0255",
"上首造字/No.36-上首金牛体.ttf": "0256",
"上首造字/No.37-上首朗倩体.ttf": "0257",
"上首造字/No.38-上首漠云体.ttf": "0258",
"上首造字/No.39-上首至尊书法体.ttf": "0259",
"上首造字/No.4-上首秀黑体.ttf": "0260",
"上首造字/No.40-上首星芒体.ttf": "0261",
"上首造字/No.41-上首山川体.ttf": "0262",
"上首造字/No.42-上首芋圆体.ttf": "0263",
"上首造字/No.43-上首星辰体.ttf": "0264",
"上首造字/No.44-上首哥特体.ttf": "0265",
"上首造字/No.45-上首黑岩体 .ttf": "0266",
"上首造字/No.46-上首言黑体.ttf": "0267",
"上首造字/No.47-上首御宋体.ttf": "0268",
"上首造字/No.48-上首三国体.ttf": "0269",
"上首造字/No.49-上首熊猫体.ttf": "0270",
"上首造字/No.5-上首时尚体.ttf": "0271",
"上首造字/No.50-上首国潮体.ttf": "0272",
"上首造字/No.51-上首酷方体.ttf": "0273",
"上首造字/No.52-上首碑楷体.ttf": "0274",
"上首造字/No.53-上首逸飞体.ttf": "0275",
"上首造字/No.54-上首少年体.ttf": "0276",
"上首造字/No.55-上首锋芒体.ttf": "0277",
"上首造字/No.56-上首新艺体.ttf": "0278",
"上首造字/No.57-上首嘉木体.ttf": "0279",
"上首造字/No.58-上首力方体.ttf": "0280",
"上首造字/No.59-上首折言体.ttf": "0281",
"上首造字/No.6-上首宽窄体.ttf": "0282",
"上首造字/No.60-上首本刻体.ttf": "0283",
"上首造字/No.61-上首古宋体.ttf": "0284",
"上首造字/No.62-上首黑风体.ttf": "0285",
"上首造字/No.63-上首迎风手写体.ttf": "0286",
"上首造字/No.64-上首奕星体.ttf": "0287",
"上首造字/No.65-上首山河体.ttf": "0288",
"上首造字/No.66-上首和风体.ttf": "0289",
"上首造字/No.67-上首轩宋体.ttf": "0290",
"上首造字/No.68-上首正雅体.ttf": "0291",
"上首造字/No.69-上首太空体.ttf": "0292",
"上首造字/No.7-上首文正体.ttf": "0293",
"上首造字/No.70-上首夏木体.ttf": "0294",
"上首造字/No.71-上首锐锋体.TTF": "0295",
"上首造字/No.72-上首喵尾体.ttf": "0296",
"上首造字/No.73-上首粉笔体.ttf": "0297",
"上首造字/No.74-上首战戟体.ttf": "0298",
"上首造字/No.75-上首玉立体.ttf": "0299",
"上首造字/No.76-上首战刃体.ttf": "0300",
"上首造字/No.77-上首苍穹书法体.ttf": "0301",
"上首造字/No.78-上首梦黑体.ttf": "0302",
"上首造字/No.79-上首博瀚体.ttf": "0303",
"上首造字/No.8-上首京东体.ttf": "0304",
"上首造字/No.80-上首乐高体.ttf": "0305",
"上首造字/No.81-上首胖墩体.ttf": "0306",
"上首造字/No.82-上首简黑粗体.ttf": "0307",
"上首造字/No.83-上首粗楷体.ttf": "0308",
"上首造字/No.84-上首状元体.ttf": "0309",
"上首造字/No.85-上首元气体.ttf": "0310",
"上首造字/No.86-上首圆盾体.ttf": "0311",
"上首造字/No.87-上首乾坤体.ttf": "0312",
"上首造字/No.88-上首典宋体.ttf": "0313",
"上首造字/No.89-上首金刚体.ttf": "0314",
"上首造字/No.9-上首宽云体.ttf": "0315",
"上首造字/No.90-上首嘻哈体.ttf": "0316",
"上首造字/No.91-上首婷雅体.ttf": "0317",
"上首造字/No.92-上首虎啸体.ttf": "0318",
"上首造字/No.93-上首萌动体.ttf": "0319",
"上首造字/No.94-上首奥丁体.ttf": "0320",
"上首造字/No.95-上首昆仑体.ttf": "0321",
"上首造字/No.96-上首锦宋体.ttf": "0322",
"上首造字/No.97-上首国宋体.ttf": "0323",
"上首造字/No.98-上首趣泡手写体.ttf": "0324",
"上首造字/No.99-上首元正体.ttf": "0325",
"上首造字/Sounso Art.ttf": "0326",
"上首造字/Sounso Beauty.ttf": "0327",
"上首造字/Sounso Cecily.ttf": "0328",
"上首造字/Sounso Diamond.ttf": "0329",
"上首造字/Sounso Earnestly.ttf": "0330",
"上首造字/上首兰亭体.ttf": "0331",
"上首造字/上首凤凰体.ttf": "0332",
"上首造字/上首刀锋体.ttf": "0333",
"上首造字/上首华光体.ttf": "0334",
"上首造字/上首华木体.ttf": "0335",
"上首造字/上首南城体.ttf": "0336",
"上首造字/上首品尚体.ttf": "0337",
"上首造字/上首战马体.ttf": "0338",
"上首造字/上首星语体.ttf": "0339",
"上首造字/上首熔岩体.ttf": "0340",
"上首造字/上首纤云体.ttf": "0341",
"上首造字/上首纯元体.ttf": "0342",
"上首造字/上首苍兰体.ttf": "0343",
"上首造字/上首轩辕体.ttf": "0344",
"上首造字/上首锦云体.ttf": "0345",
"上首造字/上首雅居体.ttf": "0346",
"其他字体/AlimamaDaoLiTi.ttf": "0347",
"其他字体/Hangeuljaemin4-Regular.ttf": "0348",
"其他字体/I.顏體.ttf": "0349",
"其他字体/XCDUANZHUANGSONGTI.ttf": "0350",
"其他字体/qiji-combo.ttf": "0351",
"其他字体/临海隶书.ttf": "0352",
"其他字体/京華老宋体_KingHwa_OldSong.ttf": "0353",
"其他字体/优设标题黑.ttf": "0354",
"其他字体/包图小白体.ttf": "0355",
"其他字体/源界明朝.ttf": "0356",
"其他字体/演示佛系体.ttf": "0357",
"其他字体/站酷快乐体.ttf": "0358",
"其他字体/问藏书房.ttf": "0359",
"其他字体/霞鹜臻楷.ttf": "0360",
"庞门正道-测试/庞门正道标题体.ttf": "0016",
"庞门正道/庞门正道标题体.ttf": "0361",
"王漢宗/王漢宗勘亭流繁.ttf": "0362",
"王漢宗/王漢宗新潮體.ttf": "0363",
"王漢宗/王漢宗波卡體空陰.ttf": "0364",
"王漢宗/王漢宗細黑體繁.ttf": "0365",
"王漢宗/王漢宗綜藝體雙空陰.ttf": "0366",
"王漢宗/王漢宗超明體繁.ttf": "0367",
"王漢宗/王漢宗酷儷海報.ttf": "0368",
"王漢宗/王漢宗顏楷體繁.ttf": "0369"
}
}

View File

@@ -1,15 +1,176 @@
#!/usr/bin/env python3
"""
生成字体清单文件
生成字体清单文件(稳定 ID 版本)
扫描 fonts/ 目录下的所有字体文件,同时生成:
1. frontend/public/fonts.json
2. miniprogram/assets/fonts.json
3. miniprogram/assets/fonts.js
ID 分配规则:
1. 已存在字体(按 relativePath 识别)保持原 ID 不变。
2. 新增字体按“上次游标”分配新 ID。
3. ID 从 0001 递增到 10000之后循环分配跳过当前正在使用的 ID
"""
import os
import json
from pathlib import Path
from urllib.parse import unquote, urlparse
FONT_DIR = Path('fonts')
WEB_FONTS_JSON = Path('frontend/public/fonts.json')
MP_FONTS_JSON = Path('miniprogram/assets/fonts.json')
MP_FONTS_JS = Path('miniprogram/assets/fonts.js')
ID_STATE_FILE = Path('scripts/font-id-state.json')
ID_MIN = 1
ID_MAX = 10000
def format_font_id(num):
return str(num).zfill(4)
def parse_font_id(raw):
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
return None
if ID_MIN <= value <= ID_MAX:
return value
return None
def increment_id(num):
return ID_MIN if num >= ID_MAX else num + 1
def normalize_relative_path(raw_path):
if not raw_path:
return None
text = str(raw_path).strip()
if not text:
return None
parsed = urlparse(text)
if parsed.scheme and parsed.netloc:
text = parsed.path
text = unquote(text)
text = text.split('?', 1)[0].split('#', 1)[0]
text = text.replace('\\', '/')
if '/fonts/' in text:
text = text.split('/fonts/', 1)[1]
elif text.startswith('fonts/'):
text = text[len('fonts/'):]
elif text.startswith('/fonts'):
text = text[len('/fonts'):]
text = text.lstrip('/')
return text or None
def load_manifest_id_map(manifest_file):
mapping = {}
if not manifest_file.exists():
return mapping
try:
with open(manifest_file, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return mapping
if not isinstance(data, list):
return mapping
for item in data:
if not isinstance(item, dict):
continue
relative_path = normalize_relative_path(
item.get('path') or item.get('relativePath')
)
id_num = parse_font_id(item.get('id'))
if relative_path and id_num:
mapping[relative_path] = format_font_id(id_num)
return mapping
def load_id_state(state_file):
default_state = {
'version': 1,
'nextId': ID_MIN,
'pathToId': {},
}
if not state_file.exists():
return default_state
try:
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return default_state
if not isinstance(data, dict):
return default_state
path_to_id = {}
raw_map = data.get('pathToId')
if isinstance(raw_map, dict):
for path_key, raw_id in raw_map.items():
relative_path = normalize_relative_path(path_key)
id_num = parse_font_id(raw_id)
if relative_path and id_num:
path_to_id[relative_path] = format_font_id(id_num)
next_id = parse_font_id(data.get('nextId')) or ID_MIN
return {
'version': 1,
'nextId': next_id,
'pathToId': path_to_id,
}
def save_id_state(state, state_file):
state_file.parent.mkdir(parents=True, exist_ok=True)
normalized_map = dict(sorted(state['pathToId'].items(), key=lambda x: x[0]))
data = {
'version': 1,
'nextId': state['nextId'],
'pathToId': normalized_map,
}
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write('\n')
def bootstrap_state_mappings(state):
# 优先级state 文件 > 小程序现有清单 > web 现有清单
for manifest_path in (MP_FONTS_JSON, WEB_FONTS_JSON):
mapping = load_manifest_id_map(manifest_path)
for relative_path, font_id in mapping.items():
state['pathToId'].setdefault(relative_path, font_id)
if parse_font_id(state.get('nextId')) is None:
state['nextId'] = ID_MIN
if state['nextId'] == ID_MIN and state['pathToId']:
max_id = max(parse_font_id(v) for v in state['pathToId'].values())
state['nextId'] = increment_id(max_id)
def allocate_new_id(start_id, used_ids):
candidate = start_id
for _ in range(ID_MAX):
if candidate not in used_ids:
return candidate, increment_id(candidate)
candidate = increment_id(candidate)
raise RuntimeError('可用 ID 已耗尽0001-10000 全部被当前字体占用)')
def scan_fonts(font_dir='fonts'):
"""扫描字体目录,返回字体信息列表"""
@@ -45,14 +206,48 @@ def scan_fonts(font_dir='fonts'):
fonts.append(font_info)
# 统一排序后分配 4 位数字 id0001、0002...
fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename']))
for index, font in enumerate(fonts, start=1):
font['id'] = f"{index:04d}"
# 固定顺序,保证分配冲突时结果稳定
fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename'], x['relativePath']))
return fonts
def assign_stable_ids(fonts):
state = load_id_state(ID_STATE_FILE)
bootstrap_state_mappings(state)
cursor = parse_font_id(state.get('nextId')) or ID_MIN
used_ids = set()
new_count = 0
for font in fonts:
relative_path = font['relativePath']
existing_id = state['pathToId'].get(relative_path)
existing_num = parse_font_id(existing_id)
if existing_num and existing_num not in used_ids:
assigned_num = existing_num
else:
if existing_num and existing_num in used_ids:
print(
f"警告: 发现 ID 冲突,路径 {relative_path} 原 ID {existing_id} 将重新分配。"
)
assigned_num, cursor = allocate_new_id(cursor, used_ids)
state['pathToId'][relative_path] = format_font_id(assigned_num)
new_count += 1
used_ids.add(assigned_num)
font['id'] = format_font_id(assigned_num)
state['nextId'] = cursor
save_id_state(state, ID_STATE_FILE)
return {
'newCount': new_count,
'nextId': format_font_id(cursor),
'stateFile': str(ID_STATE_FILE),
}
def build_manifest(fonts, path_prefix):
"""根据路径前缀构建对外清单"""
prefix = f"/{str(path_prefix or '').strip('/')}"
@@ -91,18 +286,25 @@ def write_fonts_js(fonts, output_file):
def main():
"""主函数"""
# 扫描字体(唯一来源:仓库根目录 fonts/
fonts = scan_fonts('fonts')
fonts = scan_fonts(str(FONT_DIR))
print(f"找到 {len(fonts)} 个字体文件")
allocation_result = assign_stable_ids(fonts)
print(
f"ID 分配完成:新增 {allocation_result['newCount']} 个,"
f"下次起始 ID {allocation_result['nextId']}"
f"状态文件 {allocation_result['stateFile']}"
)
# Web 清单:统一指向根目录 fonts
web_fonts = build_manifest(fonts, '/fonts')
write_fonts_json(web_fonts, 'frontend/public/fonts.json')
write_fonts_json(web_fonts, str(WEB_FONTS_JSON))
# 小程序清单:同样指向根目录 fonts与 web 共用一份字体目录)
miniprogram_fonts = build_manifest(fonts, '/fonts')
write_fonts_json(miniprogram_fonts, 'miniprogram/assets/fonts.json')
write_fonts_js(miniprogram_fonts, 'miniprogram/assets/fonts.js')
write_fonts_json(miniprogram_fonts, str(MP_FONTS_JSON))
write_fonts_js(miniprogram_fonts, str(MP_FONTS_JS))
# 统计信息
categories = {}