mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
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:
parent
d2f6717348
commit
23728ba02b
@ -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
|
||||
|
||||
@ -125,8 +125,8 @@ services:
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760
|
||||
# 临时字体保留时限(秒),超时未使用自动删除,默认 10800(3小时)
|
||||
- TEMP_RETENTION_SECONDS=10800
|
||||
# 子集化内存水位阈值(MB),RSS 超此值时排队等待,默认 600
|
||||
- SUBSET_MEM_SOFT_LIMIT_MB=600
|
||||
# 字体裁剪最大并发数,默认 4(内存受限环境建议 2-3)
|
||||
- SUBSET_CONCURRENCY=4
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
/** 子集化内存水位阈值(MB),RSS 超此值时排队 */
|
||||
subsetMemSoftLimitMB,
|
||||
/** 字体子集化最大并发数 */
|
||||
subsetConcurrency,
|
||||
/** 队列等待超时(秒) */
|
||||
subsetQueueTimeoutSeconds,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,107 +1,58 @@
|
||||
/**
|
||||
* 字体子集化内存水位闸门
|
||||
* 字体子集化并发队列(带字体分组调度)
|
||||
*
|
||||
* LLRT 运行时内存受限(~900M),字体裁剪是内存密集操作。
|
||||
* 不用固定并发数,而是实时监控进程 RSS:
|
||||
* - RSS < softLimit:直接执行(小请求可高并发)
|
||||
* - RSS ≥ softLimit:排队等待,直到前面的请求完成 + GC 释放内存
|
||||
* - 无硬限制/拒绝:所有请求最终都会执行
|
||||
* 通过固定并发数限制同时执行的子集化任务,防止 OOM 崩溃。
|
||||
*
|
||||
* 内存监控通过 /proc/self/statm(Linux 唯一可用方式,LLRT 无 process.memoryUsage)。
|
||||
* GC 通过 LLRT 内置的 __gc() 主动触发(请求完成后调用,加速内存回收)。
|
||||
* 分组优化:排队中的请求按字体(groupKey)分组,同字体的请求优先连续处理,
|
||||
* 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。
|
||||
*/
|
||||
|
||||
/** /proc/self/statm 文件描述符(启动时打开,反复读取不需每次 open/close) */
|
||||
let statmFd: number | null = null;
|
||||
|
||||
/**
|
||||
* 读取当前进程 RSS(常驻内存),单位 MB
|
||||
*
|
||||
* Node 环境使用 process.memoryUsage().rss;LLRT 无此 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 内存软限制(MB),RSS 超过此值时新请求排队
|
||||
* 函数名保留 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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -11,8 +11,8 @@ export interface ServerConfig {
|
||||
supportedOutTypes: ("woff2" | "ttf")[];
|
||||
/** 临时字体保留时限(秒) */
|
||||
tempRetentionSeconds?: number;
|
||||
/** 子集化内存水位阈值(MB),RSS 超此值时排队 */
|
||||
subsetMemSoftLimitMB?: number;
|
||||
/** 字体子集化最大并发数 */
|
||||
subsetConcurrency?: number;
|
||||
/** 队列等待超时(秒) */
|
||||
subsetQueueTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
306
src/components/CodeBlock.vue
Normal file
306
src/components/CodeBlock.vue
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 分词结果 */
|
||||
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>
|
||||
@ -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">{{ `<style>\n@font-face {\n font-family: "MyFont";\n src: url("${origin}/api?font=字体名&text=你的文字") format("woff2");\n}\n.title { font-family: "MyFont"; }\n</style>\n<h1 class="title">你的文字</h1>` }}</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">{{ `<script src="${origin}/webfont-sdk.js"></script>\n<script>\n WebFont.loadFont({\n fontName: "字体文件名.ttf",\n selector: ".my-element",\n family: "MyFont",\n interval: 1000,\n });\n</script>` }}</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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user