refactor: 回退内存闸门为固定并发(保留分组调度) + 前端改进

后端:
- subset_queue.ts: 移除基于内存的门控(LLRT GC 不释放 RSS),
  回退到固定并发模型,保留分组调度优化(相同字体批处理重用缓存)
- config.ts: 恢复 subsetConcurrency / subsetQueueTimeoutSeconds,
  移除 subsetMemSoftLimitMB
- routes/subset.ts, app.ts, routes/config.ts: 同步恢复
- api.ts, README: 同步更新

前端:
- FontSelector.vue: 下拉项和触发器标记临时字体(橙色徽章)
- CodeBlock.vue: 新增轻量代码块组件,内置 CSS/HTML/JS 分词高亮(0依赖)
- Home.vue: 集成 CodeBlock 替换原始 <pre> 代码块
This commit is contained in:
崮生(子虚) 2026-07-30 23:11:54 +08:00
parent d2f6717348
commit 23728ba02b
11 changed files with 387 additions and 138 deletions

View File

@ -125,8 +125,8 @@ services:
- SUBSET_CACHE_MAX_SIZE=10485760
# Temp font retention (seconds), auto-deleted if unused. Default: 10800 (3h)
- TEMP_RETENTION_SECONDS=10800
# Memory soft limit (MB) for subsetting queue. Default: 600
- SUBSET_MEM_SOFT_LIMIT_MB=600
# Max concurrent font subsetting. Default: 4
- SUBSET_CONCURRENCY=4
```
## API Reference

View File

@ -125,8 +125,8 @@ services:
- SUBSET_CACHE_MAX_SIZE=10485760
# 临时字体保留时限(秒),超时未使用自动删除,默认 108003小时
- TEMP_RETENTION_SECONDS=10800
# 子集化内存水位阈值(MB)RSS 超此值时排队等待,默认 600
- SUBSET_MEM_SOFT_LIMIT_MB=600
# 字体裁剪最大并发数,默认 4内存受限环境建议 2-3
- SUBSET_CONCURRENCY=4
```
## API

View File

@ -14,7 +14,7 @@ import { handleFontDetail } from "./routes/font_detail";
import { handleFontMeta } from "./routes/font_meta";
import { startTempCleaner } from "./temp_cleaner";
import { initMemoryGate } from "./subset_queue";
import { subsetMemSoftLimitMB } from "./config";
import { subsetConcurrency } from "./config";
import "./server/node";
import "./server/llrt";
@ -209,8 +209,8 @@ async function main() {
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
/** 初始化内存水位闸门(子集化排队控制 */
initMemoryGate(subsetMemSoftLimitMB);
/** 初始化子集化并发队列(含字体分组调度 */
initMemoryGate(0, subsetConcurrency);
/** 启动临时字体定时清理器 */
startTempCleaner();

View File

@ -22,16 +22,16 @@ export const tempRetentionSeconds = parseInt(env.TEMP_RETENTION_SECONDS ?? "1080
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
/**
* MB
*
*
* RSS
* + GC RSS
* 600 900M 300M
* CPU/ LLRT OOM
* 4 900M
* 2
*/
export const subsetMemSoftLimitMB = parseInt(env.SUBSET_MEM_SOFT_LIMIT_MB ?? "600", 10) || 600;
export const subsetConcurrency = Math.max(1, parseInt(env.SUBSET_CONCURRENCY ?? "4", 10) || 4);
/**
* 503
* 503
*/
export const subsetQueueTimeoutSeconds = Math.max(5, parseInt(env.SUBSET_QUEUE_TIMEOUT ?? "30", 10) || 30);

View File

@ -1,5 +1,5 @@
import { jsonResponse } from "../shared";
import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
import { enableTempUpload, adminApiKey, tempRetentionSeconds, subsetConcurrency, subsetQueueTimeoutSeconds } from "../config";
/** GET /api/config — 返回公开配置 */
export async function handleGetConfig(req: Request, _res: Response) {
@ -11,8 +11,8 @@ export async function handleGetConfig(req: Request, _res: Response) {
supportedOutTypes: ["woff2", "ttf"],
/** 临时字体保留时限(秒) */
tempRetentionSeconds,
/** 子集化内存水位阈值MBRSS 超此值时排队 */
subsetMemSoftLimitMB,
/** 字体子集化最大并发数 */
subsetConcurrency,
/** 队列等待超时(秒) */
subsetQueueTimeoutSeconds,
}),

View File

@ -3,7 +3,7 @@ import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
import { markFontUsed } from "../temp_cleaner";
import { withMemoryGate } from "../subset_queue";
import { subsetMemSoftLimitMB, subsetQueueTimeoutSeconds } from "../config";
import { subsetConcurrency, subsetQueueTimeoutSeconds } from "../config";
/**
*
@ -116,7 +116,7 @@ export async function handleFontSubset(req: Request, res: Response) {
* RSS softLimit
* + GC RSS OOM
*/
const subsetResult = await withMemoryGate(subsetMemSoftLimitMB, async () => {
const subsetResult = await withMemoryGate(subsetConcurrency, async () => {
return fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,

View File

@ -1,107 +1,58 @@
/**
*
*
*
* LLRT ~900M
* RSS
* - RSS < softLimit
* - RSS softLimit + GC
* - /
* OOM
*
* /proc/self/statmLinux LLRT process.memoryUsage
* GC LLRT __gc()
* groupKey
* 使 buffer /
*/
/** /proc/self/statm 文件描述符(启动时打开,反复读取不需每次 open/close */
let statmFd: number | null = null;
/**
* RSS MB
*
* Node 使 process.memoryUsage().rssLLRT API /proc/self/statm
* /proc/self/statm size resident shared text lib data dt
* resident × (4096) = RSS
*/
function getRssMB(): number {
try {
/** Node 环境优先使用 process.memoryUsage() */
if (typeof process !== "undefined" && process.memoryUsage) {
return Math.round(process.memoryUsage().rss / 1024 / 1024);
}
/** LLRT 环境:读取 /proc/self/statm */
if (statmFd === null) {
const { openSync } = require("fs");
statmFd = openSync("/proc/self/statm", "r");
}
const { readSync } = require("fs");
const buf = new Uint8Array(256);
const n = readSync(statmFd, buf, 0, 256, 0);
const parts = new TextDecoder().decode(buf.subarray(0, n)).trim().split(" ");
return Math.round((parseInt(parts[1]) * 4096) / 1024 / 1024);
} catch {
/** 两个方案都不可用,返回 0 表示「无限制」 */
return 0;
}
}
/**
*
*
* LLRT __gc()
* LLRT GC
*/
function gc(): void {
try {
(globalThis as any).__gc?.();
} catch {
/** __gc 不存在Node 环境)时静默跳过 */
}
}
/** 等待队列项resolver + groupKey用于同字体分组连续唤醒 */
interface QueueItem {
/** 唤醒函数 */
resolve: () => void;
/** 分组键(通常为 fontPath同组优先连续处理以复用字体缓存 */
groupKey: string;
}
/** 等待队列:checkAndNotify 按分组优先级唤醒 */
/** 等待队列release 按分组优先级唤醒 */
const waitQueue: QueueItem[] = [];
/** 当前正在执行的子集化数量(用于 stats 展示) */
/** 当前正在执行的子集化数量 */
let activeCount = 0;
/** 最大并发数(由 initConcurrency 设置) */
let maxConcurrency = 4;
/** 最近一次执行的 groupKey——同组请求优先连续唤醒 */
let lastGroupKey = "";
/** softLimit 引用checkAndNotify 需要用) */
let softLimitRef = 0;
/**
*
*
*
* - RSS softLimit
* - RSS softLimit GC RSS
* - active < maxConcurrency
* - active maxConcurrency groupKey
* - null 503
*
* groupKey
* 使 buffer / GC
* 使 buffer /
*
* @param softLimitMB MBRSS
* withMemoryGate subset.ts
*
* @param _softLimitMB 使
* @param task
* @param queueTimeoutMs
* @param groupKey fontPath
* @returns null
*/
export async function withMemoryGate<T>(
softLimitMB: number,
_softLimitMB: number,
task: () => Promise<T>,
queueTimeoutMs: number,
groupKey = "",
): Promise<T | null> {
/** RSS=0 表示无法读取(开发环境),跳过闸门直接执行 */
if (softLimitMB > 0 && getRssMB() >= softLimitMB) {
/** 内存超阈值,进入排队 */
/** 并发已满,进入排队 */
if (activeCount >= maxConcurrency) {
const acquired = await waitForSlot(queueTimeoutMs, groupKey);
if (!acquired) return null;
}
@ -112,17 +63,12 @@ export async function withMemoryGate<T>(
return await task();
} finally {
activeCount--;
/**
* GC
* RSS groupKey
*/
gc();
checkAndNotify();
release();
}
}
/**
*
*
*
* groupKey
* false
@ -138,7 +84,6 @@ function waitForSlot(timeoutMs: number, groupKey: string): Promise<boolean> {
if (idx !== -1) waitQueue.splice(idx, 1);
resolve(false);
}, timeoutMs);
/** 覆盖 resolve 以便唤醒时清 timer */
item.resolve = () => {
clearTimeout(timer);
resolve(true);
@ -163,50 +108,27 @@ function pickNext(): QueueItem | undefined {
}
/**
*
*
*
* 1. RSS < softLimit groupKey
* 2. RSS GC
* 3. 5 RSS softLimit×1.2饿
* groupKey
*/
function checkAndNotify(): void {
function release(): void {
if (waitQueue.length === 0) return;
/** RSS 未知(开发环境)→ 直接唤醒 */
if (softLimitRef === 0) {
if (activeCount < maxConcurrency) {
pickNext()?.resolve();
return;
}
/** RSS 已回落到阈值以下 → 唤醒下一个 */
if (getRssMB() < softLimitRef) {
pickNext()?.resolve();
/** 唤醒后递归检查:可能还有内存余量给更多等待者 */
checkAndNotify();
return;
}
/**
* RSS
* 5 RSS softLimit×1.2 饿
*/
setTimeout(() => {
if (waitQueue.length > 0 && getRssMB() < softLimitRef * 1.2) {
pickNext()?.resolve();
}
}, 5000);
}
/**
* config.ts
* config
*
* withMemoryGate
* @param softLimitMB MB
* @param _softLimitMB
* @param concurrency
*/
export function initMemoryGate(softLimitMB: number): void {
softLimitRef = softLimitMB;
const rss = getRssMB();
if (rss > 0) {
console.log(`[memgate] RSS=${rss}MB, softLimit=${softLimitMB}MB`);
export function initMemoryGate(_softLimitMB: number, concurrency?: number): void {
if (concurrency && concurrency > 0) {
maxConcurrency = concurrency;
}
console.log(`[subset-queue] maxConcurrency=${maxConcurrency}`);
}
/** 获取当前队列状态(用于 stats / 日志) */
@ -214,6 +136,5 @@ export function getQueueStats() {
return {
active: activeCount,
waiting: waitQueue.length,
rssMB: getRssMB(),
};
}

View File

@ -43,6 +43,9 @@ const filteredFonts = computed(() => {
});
});
/** 当前选中的字体对象(用于判断是否临时字体) */
const selectedFontInfo = computed(() => props.fonts.find((f) => f.name === props.selectedFont));
/** 当前选中字体的显示名(无选中时显示占位文字) */
const selectedLabel = computed(() => props.selectedFont || t("pleaseSelect"));
@ -135,8 +138,12 @@ function handleOutTypeChange(e: Event) {
style="width: 100%; border: none; outline: none; font-size: 14px; background: transparent; padding: 0; color: #000"
/>
<!-- 关闭时显示选中的字体名 -->
<span v-else :style="{ color: selectedFont ? '#000' : '#bbb', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">
{{ selectedLabel }}
<span v-else :style="{ color: selectedFont ? '#000' : '#bbb', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '6px' }">
<span style="overflow: hidden; text-overflow: ellipsis">{{ selectedLabel }}</span>
<span
v-if="selectedFontInfo?.temporary"
style="flex-shrink: 0; font-size: 10px; padding: 1px 5px; border-radius: 3px; background: #fff7e6; color: #fa8c16; border: 1px solid #ffd591"
>临时</span>
</span>
<!-- 下拉箭头 -->
<svg style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); transition: transform 0.2s" :style="{ transform: dropdownOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)' }" width="12" height="12" viewBox="0 0 12 12">
@ -161,11 +168,15 @@ function handleOutTypeChange(e: Event) {
:key="f.name"
@click="selectFont(f.name)"
@mouseenter="($event.currentTarget as HTMLElement).style.background = '#f5f5f5'"
@mouseleave="($event.currentTarget as HTMLElement).style.background = '#fff'"
style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
@mouseleave="($event.currentTarget as HTMLElement).style.background = f.name === selectedFont ? '#e6f4ff' : '#fff'"
style="padding: 8px 12px; font-size: 14px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px"
:style="{ background: f.name === selectedFont ? '#e6f4ff' : '#fff', color: f.name === selectedFont ? '#1677ff' : '#333', fontWeight: f.name === selectedFont ? '500' : 'normal' }"
>
{{ f.name }}
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis">{{ f.name }}</span>
<span
v-if="f.temporary"
style="flex-shrink: 0; font-size: 10px; padding: 1px 5px; border-radius: 3px; background: #fff7e6; color: #fa8c16; border: 1px solid #ffd591"
>临时</span>
</div>
</div>
</div>

View File

@ -11,8 +11,8 @@ export interface ServerConfig {
supportedOutTypes: ("woff2" | "ttf")[];
/** 临时字体保留时限(秒) */
tempRetentionSeconds?: number;
/** 子集化内存水位阈值MBRSS 超此值时排队 */
subsetMemSoftLimitMB?: number;
/** 字体子集化最大并发数 */
subsetConcurrency?: number;
/** 队列等待超时(秒) */
subsetQueueTimeoutSeconds?: number;
}

View File

@ -0,0 +1,306 @@
<script setup lang="ts">
/**
* 轻量代码块组件
*
* 内置简单语法高亮0 依赖通过分词器把源码拆成 token 数组
* 每个 token 渲染为带颜色的 <span>避免正则反复替换导致互相干扰
*
* 支持 CSS / HTML / JS 三种语言的关键词着色
*/
import { computed } from "vue";
const props = withDefaults(defineProps<{
code: string;
lang?: "css" | "html" | "js" | "auto";
}>(), {
lang: "auto",
});
/** token 类型 → 颜色 */
const COLORS: Record<string, string> = {
comment: "#999",
tag: "#e45649",
attr: "#4078f2",
string: "#50a14f",
keyword: "#a626a4",
property: "#4078f2",
number: "#c18401",
function: "#4078f2",
atrule: "#a626a4",
plain: "#333",
};
interface Token {
type: keyof typeof COLORS;
value: string;
}
/**
* CSS 分词器
*
* 状态机逐字符扫描区分注释 / 字符串 / @规则 / 属性名 / 属性值 / 选择器
*/
function tokenizeCss(code: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const len = code.length;
while (i < len) {
/** 注释 /* ... *\/ */
if (code[i] === "/" && code[i + 1] === "*") {
let j = i + 2;
while (j < len && !(code[j] === "*" && code[j + 1] === "/")) j++;
j = Math.min(j + 2, len);
tokens.push({ type: "comment", value: code.slice(i, j) });
i = j;
continue;
}
/** 字符串 "..." 或 '...' */
if (code[i] === '"' || code[i] === "'") {
const quote = code[i];
let j = i + 1;
while (j < len && code[j] !== quote) j++;
j = Math.min(j + 1, len);
tokens.push({ type: "string", value: code.slice(i, j) });
i = j;
continue;
}
/** @规则 */
if (code[i] === "@") {
let j = i + 1;
while (j < len && /[\w-]/.test(code[j])) j++;
tokens.push({ type: "atrule", value: code.slice(i, j) });
i = j;
continue;
}
/** 属性名: (行首缩进后,单词+冒号) */
const propMatch = /^(\s*)([\w-]+)(\s*:)/.exec(code.slice(i));
if (propMatch && (i === 0 || code[i - 1] === "\n")) {
if (propMatch[1]) tokens.push({ type: "plain", value: propMatch[1] });
tokens.push({ type: "property", value: propMatch[2] });
tokens.push({ type: "plain", value: propMatch[3] });
i += propMatch[0].length;
continue;
}
/** url( 等函数 */
const funcMatch = /^([\w-]+)\s*\(/.exec(code.slice(i));
if (funcMatch) {
tokens.push({ type: "function", value: funcMatch[1] });
i += funcMatch[1].length;
continue;
}
/** 数字+单位 */
const numMatch = /^(\d+(\.\d+)?)(px|em|rem|%|s|ms|pt|deg|vh|vw)?/.exec(code.slice(i));
if (numMatch && numMatch[1] && /^\d/.test(numMatch[0])) {
tokens.push({ type: "number", value: numMatch[0] });
i += numMatch[0].length;
continue;
}
/** 其他字符原样输出 */
tokens.push({ type: "plain", value: code[i] });
i++;
}
return tokens;
}
/**
* HTML 分词器
*
* 状态机区分文本 / 标签 / 属性 / 字符串 / 注释
*/
function tokenizeHtml(code: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const len = code.length;
while (i < len) {
/** 注释 <!-- ... --> */
if (code.slice(i, i + 4) === "<!--") {
let j = code.indexOf("-->", i);
j = j === -1 ? len : j + 3;
tokens.push({ type: "comment", value: code.slice(i, j) });
i = j;
continue;
}
/** 标签区域 <tag ...> 或 </tag> */
if (code[i] === "<") {
let j = i + 1;
/** 闭合标签的 / */
const closing = code[j] === "/";
if (closing) j++;
/** 标签名 */
let tagName = "";
while (j < len && /[\w-]/.test(code[j])) {
tagName += code[j];
j++;
}
if (tagName) {
tokens.push({ type: "plain", value: "<" + (closing ? "/" : "") });
tokens.push({ type: "tag", value: tagName });
i = j;
/** 扫描属性直到 > */
while (i < len && code[i] !== ">") {
/** 属性间的空白 */
const ws = /^\s+/.exec(code.slice(i));
if (ws) {
tokens.push({ type: "plain", value: ws[0] });
i += ws[0].length;
continue;
}
/** 属性名 */
const attrMatch = /^([\w-]+)/.exec(code.slice(i));
if (attrMatch && code[i] !== '"' && code[i] !== "'") {
tokens.push({ type: "attr", value: attrMatch[1] });
i += attrMatch[1].length;
continue;
}
/** 字符串值="..." */
if (code[i] === "=") {
tokens.push({ type: "plain", value: "=" });
i++;
continue;
}
if (code[i] === '"' || code[i] === "'") {
const quote = code[i];
let k = i + 1;
while (k < len && code[k] !== quote) k++;
k = Math.min(k + 1, len);
tokens.push({ type: "string", value: code.slice(i, k) });
i = k;
continue;
}
/** 其他字符 */
tokens.push({ type: "plain", value: code[i] });
i++;
}
/** 闭合 > 或 /> */
if (i < len && code[i] === ">") {
const selfClose = code[i - 1] === "/";
if (selfClose) {
/** 把上一个 / 改为 plain */
tokens.push({ type: "plain", value: ">" });
} else {
tokens.push({ type: "plain", value: ">" });
}
i++;
}
continue;
}
}
/** 普通文本(到下一个 < 为止) */
let j = code.indexOf("<", i);
if (j === -1) j = len;
tokens.push({ type: "plain", value: code.slice(i, j) });
i = j;
}
return tokens;
}
/**
* JS 分词器
*/
const JS_KEYWORDS = new Set([
"const", "let", "var", "function", "return", "new", "if", "else",
"for", "while", "true", "false", "null", "undefined", "async", "await",
"import", "export", "from", "default", "class", "extends", "this",
"typeof", "instanceof", "try", "catch", "throw", "break", "continue",
]);
function tokenizeJs(code: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const len = code.length;
while (i < len) {
/** 注释 // ... */
if (code[i] === "/" && code[i + 1] === "/") {
let j = code.indexOf("\n", i);
j = j === -1 ? len : j;
tokens.push({ type: "comment", value: code.slice(i, j) });
i = j;
continue;
}
/** 注释 /* ... *\/ */
if (code[i] === "/" && code[i + 1] === "*") {
let j = i + 2;
while (j < len && !(code[j] === "*" && code[j + 1] === "/")) j++;
j = Math.min(j + 2, len);
tokens.push({ type: "comment", value: code.slice(i, j) });
i = j;
continue;
}
/** 字符串 */
if (code[i] === '"' || code[i] === "'" || code[i] === "`") {
const quote = code[i];
let j = i + 1;
while (j < len && code[j] !== quote) {
if (code[j] === "\\") j++;
j++;
}
j = Math.min(j + 1, len);
tokens.push({ type: "string", value: code.slice(i, j) });
i = j;
continue;
}
/** 标识符(关键词 / 函数名 / 普通变量) */
const idMatch = /^[$\w]+/.exec(code.slice(i));
if (idMatch) {
const word = idMatch[0];
if (JS_KEYWORDS.has(word)) {
tokens.push({ type: "keyword", value: word });
} else if (code[i + word.length] === "(") {
tokens.push({ type: "function", value: word });
} else {
tokens.push({ type: "plain", value: word });
}
i += word.length;
continue;
}
/** 数字 */
const numMatch = /^\d+(\.\d+)?/.exec(code.slice(i));
if (numMatch) {
tokens.push({ type: "number", value: numMatch[0] });
i += numMatch[0].length;
continue;
}
/** 其他字符 */
tokens.push({ type: "plain", value: code[i] });
i++;
}
return tokens;
}
/**
* 自动检测语言
*/
function detectLang(code: string): "css" | "html" | "js" {
if (/<\/?\w/.test(code)) return "html";
if (/@font-face|@import|@media|^\s*[.#]?[\w-]+\s*\{/m.test(code)) return "css";
if (/\b(function|const|let|var|=>|WebFont\.)\b/.test(code)) return "js";
return "css";
}
/** HTML 转义 */
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 分词结果 */
const tokens = computed<Token[]>(() => {
const lang = props.lang === "auto" ? detectLang(props.code) : props.lang;
switch (lang) {
case "css": return tokenizeCss(props.code);
case "html": return tokenizeHtml(props.code);
case "js": return tokenizeJs(props.code);
default: return [{ type: "plain", value: props.code }];
}
});
</script>
<template>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin: 0"><code><span
v-for="(token, idx) in tokens"
:key="idx"
:style="{ color: COLORS[token.type] }"
>{{ token.value }}</span></code></pre>
</template>

View File

@ -35,6 +35,7 @@ import UploadSection from "../UploadSection.vue";
import StatsPanel from "../StatsPanel.vue";
import SelectorRow from "../FontSelector.vue";
import FontDebugPreview from "../FontDebugPreview.vue";
import CodeBlock from "../components/CodeBlock.vue";
const text = ref("天地无极,乾坤借法");
const fonts = ref<FontInfo[]>([]);
@ -102,6 +103,16 @@ const cssStyle = computed(() => {
}`;
});
/** 基础用法代码示例(依赖 origin需 computed */
const basicUsageCode = computed(() => {
return '<style>\n@font-face {\n font-family: "MyFont";\n src: url("' + origin.value + '/api?font=\u5b57\u4f53\u540d&text=\u4f60\u7684\u6587\u5b57") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<h1 class="title">\u4f60\u7684\u6587\u5b57</h1>';
});
/** JS SDK 代码示例 */
const jsSdkCode = computed(() => {
return '<script src="' + origin.value + '/webfont-sdk.js"><\/script>\n<script>\n WebFont.loadFont({\n fontName: "\u5b57\u4f53\u6587\u4ef6\u540d.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n<\/script>';
});
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
function onTextChange(value: string) {
@ -243,7 +254,7 @@ async function refreshFonts() {
</button>
</div>
</div>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin: 0">{{ cssStyle }}</pre>
<CodeBlock :code="cssStyle" lang="css" />
</section>
<FontDebugPreview v-if="isDev" />
@ -255,9 +266,9 @@ async function refreshFonts() {
<section style="margin-bottom: 28px; font-size: 12px; color: #aaa; line-height: 1.8">
<p><b>{{ t('principle') }}</b>{{ t('principleText') }}</p>
<p><b>{{ t('basicUsage') }}</b>{{ t('basicUsageText') }}</p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;style&gt;\n@font-face {\n font-family: "MyFont";\n src: url("${origin}/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n&lt;/style&gt;\n&lt;h1 class="title"&gt;你的文字&lt;/h1&gt;` }}</pre>
<div style="margin-top: 4px"><CodeBlock :code="basicUsageCode" lang="html" /></div>
<p style="margin-top: 12px"><b>{{ t('jsSdk') }}</b>{{ t('jsSdkText') }}<a href="/webfont-sdk.js" download="webfont-sdk.js">{{ t('downloadSdk') }}</a></p>
<pre style="background: #f7f7f8; padding: 16px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Menlo, Consolas, monospace; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-top: 4px">{{ `&lt;script src="${origin}/webfont-sdk.js"&gt;&lt;/script&gt;\n&lt;script&gt;\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n&lt;/script&gt;` }}</pre>
<div style="margin-top: 4px"><CodeBlock :code="jsSdkCode" lang="html" /></div>
<p style="margin-top: 8px">{{ t('sdkModes') }}<code>WebFont.observeFont()</code>{{ t('observeFont') }}<code>WebFont.loadText()</code>{{ t('loadText') }}</p>
</section>