Compare commits

...

No commits in common. "master" and "webfont@1.5.0" have entirely different histories.

219 changed files with 23924 additions and 14934 deletions

58
.gitignore vendored
View File

@ -1,39 +1,37 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.cache
node_modules
dist
asset/font
asset/dynamically
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
llrt
font/*
tjs
app
*.tar
dist_backend
.pilot
verify_font_baseline
benchmark_results
.claude

View File

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -1,3 +0,0 @@
{
"deno.enable": false
}

7
CLAUDE.md Normal file
View File

@ -0,0 +1,7 @@
pnpm dev 可以同时启动前后端,并且都会修改代码后自动重载
- pnpm release 构建并发布当前项目的docker镜像
## 浏览器测试vite-plugin-pilot
已安装。`npx pilot run '代码'` 执行 JS返回结果+日志+快照)、`npx pilot page` 页面状态
`npx pilot help` 查看pilot所有功能
常用:`__pilot_clickByText("文本")` 点击、`__pilot_typeByPlaceholder("提示文字", "值")` 输入、`__pilot_waitFor("文本")` 等待、`__pilot_findByText("文本")` 查找。snapshot 中 `#N` 是元素索引。
多 tab 时用 `npx pilot status` 查看实例列表,`npx pilot run '代码' instance:前缀` 指定目标实例(支持 ID 前缀模糊匹配)。

6
Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM scratch
WORKDIR /home/
COPY dist_backend/app.lrt /home/app.lrt
COPY llrt /home/llrt
COPY dist/ /home/dist/
CMD ["/home/llrt", "/home/app.lrt"]

143
README.md
View File

@ -1,104 +1,97 @@
# web font 字体裁剪工具
之前的版本请查看 master 分支,为了能使用 llrt ,我进行了重写,之后只维护此分支
![](./doc/启动内存占用.png)
上面的内存占用是空载状态下,在执行字体裁剪时会将字体加载到内存中,所以会占用更多的内存,不过 llrt 也具有 gc 功能,在内存不够用时会自动释放。
虽然 llrt 内存占用低但它运行速度慢不到node的1/2。有运行速度要求的建议使用node/bun运行
## 起因
ui 需要展现一些特定的字体,但直接引入字体包又过大,于是想到了裁剪字体,一开始想的使用「字蛛」但他是针对静态网站的,而且实际他会多出许多英文的,估计是直接将源码中存在的文字都算进去了。
后来又找到阿里的「webfont」 但他的字体有限,项目又不开源,所以自己写了这个
ui 需要展现一些特定的字体但直接引入字体包又过大于是想到了裁剪字体一开始想的使用「字蛛」但他是针对静态网站的而且实际他会多出许多英文的估计是直接将源码中存在的文字都算进去了。后来又找到阿里的「webfont」 但他的字体有限,项目又不开源,所以自己写了这个
## 尝试
## 在线尝试
- [web font 在线站点](https://webfont.shenzilong.cn/)
- 基于 [malagu](https://github.com/cellbang/malagu) 搭建的 [web font **serverless** 版在线尝试地址](http://webfontserverless.shenzilong.cn/) [serverless代码分支](https://github.com/2234839/web-font/tree/serverless)
请注意,由于这个服务器比较差,所以访问可能比较慢,且因为服务器空间问题我会不定时的清空生成的资源,所以请不要使用这个站点生成的在线资源,如有需要应当自行布设
## 目的与功能
1.裁剪字体包使其仅包含选中的字体
例如 如下图生成的字体包仅包含 「天地无极乾坤借法」
![界面预览](./doc_img/页面截图.jpg)
<video src="./doc_img/功能演示.mkv" controls="controls" width:100% height:auto></video>
其体积自然十分之小
![体积展示](./doc_img/体积展示.jpg)
1.裁剪字体包使其仅包含选中的字体,其体积自然十分之小
2.另外可以生成 css 直接复制可用,部署在公网便可永久访问
3.支持字体文件上传(临时上传和管理员上传两种模式)
4.支持下载裁剪后的字体文件
5.字体名称支持模糊匹配(精确 > 前缀 > 包含)
例如
```css
@font-face {
font-family: "QIJIC";
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot"); /* IE9 */
src: url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.eot?#iefix") format("embedded-opentype"), /* IE6-IE8 */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.woff") format("woff"), /* chrome, firefox */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.ttf") format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
url("http://127.0.0.1:3000/asset/font/1584680576469/令东齐伋复刻体.svg#QIJIC") format("svg"); /* iOS 4.1- */
font-style: normal;
font-weight: normal;
}
## 安装与使用
### 使用 node / llrt 等运行时
拉取项目,并将字体文件放到项目内的 font 目录下,然后运行:
pnpm install && pnpm build && pnpm build_backend
node ./dist_backend/app.cjs
llrt ./dist_backend/app.cjs
### 使用 docker 安装
此镜像使用 llrt 运行时
https://hub.docker.com/repository/docker/llej0/web-font 很小的包体积 ![alt text](doc/image.png)
docker compose.yml
```yml
version: '3'
services:
app:
image: docker.io/llej0/web-font:latest
ports:
- "8087:8087"
volumes:
- ./data:/home/font # 挂载本机字体目录
environment:
- ENABLE_TEMP_UPLOAD=true # 开启临时上传(默认 false
- TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10
- TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200即 200MB
- ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用
deploy:
resources:
limits:
memory: 900M # 设置内存限制为900MB根据实际需求来设置
restart: on-failure # 设置重启策略为 on-failure
```
3.将 ttf 的字体包放置在 ./asset/font_src/ 目录下自然可以检测到新的可用字体,无需重启服务
![路径预览](./doc_img/路径展示.jpg)
4.提供 zip 的整体下载方案
![下载展示](./doc_img/下载展示.jpg)
其中 font 目录替换成你的字体文件存放目录
## 提供的服务
### 查询可用字体列表
### API 接口
![font_list](./doc_img/api/font_list.jpg)
| 接口 | 说明 |
|------|------|
| `GET /api?font=字体名&text=文字` | 裁剪字体,字体名支持模糊匹配 |
| `GET /api/fonts` | 列出所有可用字体 |
| `GET /api/config` | 获取公开配置(是否开启上传等) |
| `POST /api/upload?mode=temp` | 临时上传字体文件(需开启 `ENABLE_TEMP_UPLOAD` |
| `POST /api/upload?mode=admin` | 管理员上传字体文件(需 `Authorization: Bearer <API_KEY>` |
### 生成压缩字体包
### 上传功能
![fontmin](./doc_img/api/fontmin.jpg)
如图可见每个返回的字体资源,访问即可下载。另外在访问该目录下的 asset.zip 可以直接下载全部的文件,生成的资源目录结构见下图
![fontmin](./doc_img/api/fontmin_post.jpg)
注意,此接口是还支持 post 方式访问的,这样可以一次请求多个类型的字体文件,而且不会如同 get 方法那样有长度限制
![生成的资源.jpg](./doc_img/生成的资源.jpg)
### 动态生成字体
![generate_fonts_dynamically](./doc_img/api/generate_fonts_dynamically.jpg)
#### 请注意
只支持生成 .ttf .eot .woff .svg 这几种格式
## 写项目时遇到的问题
1. 使用 svelte https://github.com/DeMoorJasper/parcel-plugin-svelte 通过这个插件使用 parcel 然后报 new 的错 需要限制 编译的版本在package.json browserslist 字段限制一下版本就好
2. parcel 对 post purgecss 支持好像有问题,需要修改 postcss.config.js 文件他才能正确的删除样式
## 启动
```bash
npm i
npm run build
npm run start
```
默认的访问地址是 http://127.0.0.1:3000
- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件(`TEMP_MAX_FILES`),总大小限制 200MB`TEMP_MAX_TOTAL_SIZE`超出后自动删除最早上传的FIFO
- **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证
- 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2`
## 鸣谢
[kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core)
[字体天下](http://www.fonts.net.cn/commercial-free-32767/fonts-zh-1.html)
[fontmin](https://github.com/ecomfe/fontmin)
## License

Binary file not shown.

Binary file not shown.

394
backend/app.ts Normal file
View File

@ -0,0 +1,394 @@
/** 解析请求 URLreq.url 只有路径,需要补全协议和主机才能用 URL API */
function parseUrl(req: Request): URL {
return new URL(req.url, "http://localhost");
}
import { fontSubset } from "./font_util/font";
import type { FontEditor } from "../vendor/fonteditor-core/lib/ttf/font.js";
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
import { enableTempUpload, adminApiKey, fontDirs } from "./config";
import { parseMultipart } from "./multipart";
import { handleTempUpload, handleAdminUpload } from "./upload";
let release_name = globalThis?.process?.release?.name;
let runtimeReady: Promise<void>;
if (release_name === "node" || release_name === "llrt") {
runtimeReady = import("./server/node").then(() => {});
} else {
runtimeReady = Promise.resolve();
}
if (release_name === "llrt") {
runtimeReady = runtimeReady.then(() => import("./server/llrt").then(() => {}));
}
const ROOT_DIR = "dist";
/** 启动时确保必要目录存在 */
async function ensureDirectories() {
for (const dir of ["font/temp", "font/admin"]) {
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
}
/**
*
* > >
* @returns null
*/
async function findFontPath(filename: string): Promise<string | null> {
// 先尝试精确匹配
for (const dir of fontDirs) {
const filePath = path_join(dir, filename);
try {
const s = await stat(filePath);
if (s.isFile()) return filePath;
} catch {
// 继续搜索
}
}
// 收集所有字体文件名(不含扩展名)和完整路径
const allFonts: Array<{ basename: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({
basename: entry.name.replace(/\.[^.]+$/, ""),
path: path_join(dir, entry.name),
});
}
}
} catch {
// 目录不存在,跳过
}
}
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
// 前缀匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().startsWith(query)) return f.path;
}
// 包含匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().includes(query)) return f.path;
}
return null;
}
/** JSON 响应工具 */
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
const logMiddleware: cMiddleware = async (req, res, next) => {
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
const url = parseUrl(req);
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
return r;
};
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
let newRes: Response;
if (req.method === "GET") {
const url = parseUrl(req);
const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname);
/** 防止路径穿越:规范化后必须仍在 dist 目录内 */
if (!filePath.startsWith(ROOT_DIR + "/") && filePath !== ROOT_DIR) {
newRes = new Response("403 Forbidden", {
status: 403,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
return next(req, newRes);
}
try {
const stats = await stat(filePath);
if (stats.isFile()) {
const fileContent = await readFile(filePath);
const extname = filePath.split(".").pop() ?? "";
newRes = new Response(fileContent, {
status: 200,
headers: {
"Content-Type": mimeTypes[extname] || "application/octet-stream",
"Content-Length": `${stats.size}`,
},
});
} else {
newRes = new Response("404 Not Found", {
status: 404,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} catch (err) {
console.log("[err]", err);
newRes = new Response("500 Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} else {
newRes = new Response("Method Not Allowed", {
status: 405,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
return next(req, newRes);
};
const corsMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "OPTIONS") {
return {
req,
res: new Response("", {
status: 204,
headers: {
"Content-Length": "0",
},
}),
};
} else {
const newRes = await next(req, res);
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
return newRes;
}
};
/** GET /api/fonts — 列出所有可用字体 */
async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; dir: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({ name: entry.name, dir });
}
}
} catch {
// 目录不存在,跳过
}
}
return { req, res: jsonResponse(allFonts) };
}
/** GET /api/config — 返回公开配置 */
async function handleGetConfig(req: Request, res: Response) {
return {
req,
res: jsonResponse({
enableTempUpload,
adminUploadEnabled: !!adminApiKey,
supportedOutTypes: ["woff2", "ttf"],
}),
};
}
/** POST /api/upload?mode=temp|admin — 上传字体 */
async function handleUpload(req: Request, res: Response) {
const url = parseUrl(req);
const mode = url.searchParams.get("mode") ?? "temp";
const contentType = req.headers.get("Content-Type") ?? "";
console.log("[upload] mode:", mode, "contentType:", contentType);
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
if (!body || body.byteLength === 0) {
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
}
console.log("[upload] body size:", body.byteLength);
let parsed;
try {
parsed = parseMultipart(contentType, body);
console.log("[upload] parsed files:", parsed.files.length);
} catch (err) {
console.log("[upload] parse error:", err);
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
}
if (!parsed.files || parsed.files.length === 0) {
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
}
const file = parsed.files[0];
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
let result;
if (mode === "admin") {
const authHeader = req.headers.get("Authorization") ?? "";
const apiKey = authHeader.replace("Bearer ", "");
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
console.log("[upload] admin result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
}
// 默认:临时上传
result = await handleTempUpload({ data: file.data, filename: file.filename });
console.log("[upload] temp result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer */
const fontBufferCache = new Map<string, ArrayBuffer>();
const FONT_CACHE_MAX = 3;
/** 从缓存或磁盘读取字体 buffer */
async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
const cached = fontBufferCache.get(fontPath);
if (cached) {
/** LRU命中时移到末尾最近使用 */
fontBufferCache.delete(fontPath);
fontBufferCache.set(fontPath, cached);
return cached;
}
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
if (fontBufferCache.size >= FONT_CACHE_MAX) {
/** 淘汰最久未使用的条目 */
const oldest = fontBufferCache.keys().next().value!;
fontBufferCache.delete(oldest);
}
fontBufferCache.set(fontPath, buffer);
return buffer;
}
/** GET /api?font=...&text=... — 字体裁剪 */
async function handleFontSubset(req: Request, res: Response) {
const url = parseUrl(req);
const params = new URLSearchParams(url.search);
const font = params.get("font") || "";
const text = params.get("text") || "";
if (text.length === 0) {
return { req, res };
}
const fontPath = await findFontPath(font);
if (!fontPath) {
return {
req,
res: new Response(`Font not found: ${font}`, {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = await readFontBuffer(fontPath);
} catch {
return {
req,
res: new Response(`Font read error: ${font}`, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
/** 默认 ttf兼容性最好 */
const outTypeParam = params.get("outType") || "";
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
const newFont = await fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
});
const contentTypes: Record<string, string> = {
ttf: "font/ttf",
woff2: "font/woff2",
};
return {
req,
res: new Response(newFont, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
},
}),
};
}
/** 统一的 API 路由中间件 */
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
const url = parseUrl(req);
if (!url.pathname.startsWith("/api")) return next(req, res);
if (url.pathname === "/api/fonts" && req.method === "GET") {
return handleListFonts(req, res);
}
if (url.pathname === "/api/config" && req.method === "GET") {
return handleGetConfig(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res);
}
if (url.pathname === "/api" && req.method === "GET") {
return handleFontSubset(req, res);
}
return next(req, res);
};
/** 上传文件大小限制 50MB */
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "POST" && parseUrl(req).pathname === "/api/upload") {
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
if (contentLength > MAX_UPLOAD_SIZE) {
return {
req,
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
};
}
}
return next(req, res);
};
async function main() {
await runtimeReady;
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });
server.use(
logMiddleware,
corsMiddleware,
uploadSizeMiddleware,
fontApiMiddleware,
staticFileMiddleware,
);
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
}
main();

19
backend/config.ts Normal file
View File

@ -0,0 +1,19 @@
/**
*
*/
const env = globalThis.process?.env ?? {};
/** 临时上传开关 */
export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true";
/** 管理员 API Key为空则管理员上传不可用 */
export const adminApiKey: string = env.ADMIN_API_KEY ?? "";
/** 临时上传目录最大文件数 */
export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
/** 临时上传目录总体积上限(字节),默认 200MB */
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
/** 字体搜索目录按优先级排序admin > 普通 > 临时) */
export const fontDirs = ["font/admin", "font", "font/temp"] as const;

77
backend/font_util/font.ts Normal file
View File

@ -0,0 +1,77 @@
import { Font } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
/**
*
* 便使
*/
/** 从字符串提取 Unicode 码点数组 */
export const textToCodePoints = (text: string) =>
[...text].map((char) => char.codePointAt(0)!);
/**
* subset
*/
export const createSubsetFont = (
fontBuffer: ArrayBuffer,
codePoints: number[],
sourceType: FontEditor.FontType,
) =>
Font.create(fontBuffer, {
type: sourceType,
subset: codePoints,
});
/**
*
* subset TTFReader.resolveGlyf compound2simple
* optimizettf _unicodeSorted=truesortGlyf
*/
export const optimizeFont = (font: ReturnType<typeof Font.create>) => {
const optimized = font.optimize();
return optimized;
};
/** woff2 wasm 初始化 Promise延迟初始化只执行一次 */
let woff2InitPromise: Promise<void> | null = null;
/** 确保 woff2 wasm 已初始化,首次调用时加载 711KB wasm */
async function ensureWoff2Init(): Promise<void> {
if (!woff2InitPromise) {
const woff2Module = await import("../../vendor/fonteditor-core/woff2/index.js");
const mod = (woff2Module as any).default || woff2Module;
woff2InitPromise = mod.init().then(() => {});
}
return woff2InitPromise;
}
/** 序列化为指定格式的二进制数据 */
export const writeFont = async (
font: ReturnType<ReturnType<typeof Font.create>["optimize"]>,
outType: FontEditor.FontType,
): Promise<Uint8Array> => {
if (outType === "woff2") {
await ensureWoff2Init();
}
const result = font.write({ type: outType });
if (typeof result !== "string") {
return new Uint8Array(result);
}
return new TextEncoder().encode(result);
};
/**
*
* -> subset -> ->
*/
export const fontSubset = async (
fontBuffer: ArrayBuffer,
subString: string,
option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType },
) => {
const codePoints = textToCodePoints(subString);
const font = createSubsetFont(fontBuffer, codePoints, option.sourceType);
const optimized = optimizeFont(font);
return writeFont(optimized, option.outType);
};

67
backend/interface.ts Normal file
View File

@ -0,0 +1,67 @@
export let stat: (path: string) => Promise<{
isFile: () => boolean;
size: number;
}>;
export let readFile: (path: string) => Promise<Uint8Array>;
export let writeFile: (path: string, data: Uint8Array) => Promise<void>;
export let readdir: (path: string) => Promise<{
isFile: () => boolean;
name: string;
}[]>;
export let mkdir: (path: string) => Promise<void>;
export let unlink: (path: string) => Promise<void>;
export const implInterface = (options: {
stat: typeof stat;
readFile: typeof readFile;
writeFile: typeof writeFile;
readdir: typeof readdir;
mkdir: typeof mkdir;
unlink: typeof unlink;
}) => {
stat = options.stat;
readFile = options.readFile;
writeFile = options.writeFile;
readdir = options.readdir;
mkdir = options.mkdir;
unlink = options.unlink;
};
export function path_join(...paths: string[]) {
const sep = "/";
function trimSlashes(p: string) {
return p.replace(/\/+$/, "").replace(/^\/+/, "");
}
/** 将路径按 / 分割并解析 . 和 .. 段 */
function normalizeSegments(segments: string[]) {
const resolved: string[] = [];
for (const seg of segments) {
if (seg === "..") {
resolved.pop();
} else if (seg !== "." && seg !== "") {
resolved.push(seg);
}
}
return resolved;
}
const isAbsolute = paths[0] && paths[0].startsWith(sep);
const segments = paths
.map((path) => trimSlashes(path))
.join(sep)
.split(sep);
const resolved = normalizeSegments(segments);
if (!resolved.length) return isAbsolute ? sep : ".";
const result = resolved.join(sep);
return isAbsolute ? sep + result : result;
}

98
backend/multipart.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* multipart/form-data
*/
export interface MultipartFile {
/** 表单字段名 */
name: string;
/** 原始文件名 */
filename: string;
/** 文件二进制数据 */
data: Uint8Array;
}
export interface MultipartParseResult {
files: MultipartFile[];
}
export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartParseResult {
if (!body || body.byteLength === 0) {
return { files: [] };
}
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
if (!boundaryMatch) throw new Error("No boundary found");
const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1");
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8");
const bodyBytes = new Uint8Array(body);
const delimiter = encoder.encode("\r\n--" + boundary);
/** 在字节数组中查找子串位置 */
function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number {
for (let i = offset; i <= haystack.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) {
match = false;
break;
}
}
if (match) return i;
}
return -1;
}
const files: MultipartFile[] = [];
// 跳过起始边界 "--boundary\r\n"
const startBoundary = encoder.encode("--" + boundary + "\r\n");
let pos = 0;
const sbPos = findBytes(bodyBytes, startBoundary, pos);
if (sbPos === -1) throw new Error("Invalid multipart: no start boundary");
pos = sbPos + startBoundary.length;
while (pos < bodyBytes.length) {
// 查找 headers 和 body 的分界 "\r\n\r\n"
const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos);
if (headerEnd === -1) break;
const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd));
const bodyStart = headerEnd + 4;
// 查找下一个 boundary
const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart);
if (nextBoundary === -1) break;
// part body 去掉末尾的 "\r\n"
const partBody = bodyBytes.slice(bodyStart, nextBoundary);
const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13
? partBody.slice(0, partBody.length - 2)
: partBody;
// 从 Content-Disposition 中解析字段名和文件名
const nameMatch = headerText.match(/name="([^"]*)"/);
const filenameMatch = headerText.match(/filename="([^"]*)"/);
if (nameMatch) {
files.push({
name: nameMatch[1],
filename: filenameMatch?.[1] ?? "",
data: actualBody,
});
}
pos = nextBoundary + delimiter.length;
// 检查 boundary 后面是否紧跟 "--"(结束标记)
if (pos + 2 <= bodyBytes.length && bodyBytes[pos] === 45 && bodyBytes[pos + 1] === 45) {
break;
}
// 跳过 boundary 后的 "\r\n"
if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) {
pos += 2;
}
}
return { files };
}

1
backend/server/llrt.ts Normal file
View File

@ -0,0 +1 @@
import "web-streams-polyfill/polyfill";

View File

@ -0,0 +1,16 @@
// MIME 类型映射
export const mimeTypes: Record<string, string> = {
"html": "text/html",
"css": "text/css",
"js": "application/javascript; charset=utf-8",
"json": "application/json",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"svg": "image/svg+xml",
"woff": "font/woff",
"woff2": "font/woff2",
"ttf": "font/ttf",
"otf": "font/otf",
};

27
backend/server/node.ts Normal file
View File

@ -0,0 +1,27 @@
import { implInterface } from "../interface";
import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
implInterface({
async stat(path) {
const r = await stat(path);
return r;
},
readFile(path) {
return readFile(path);
},
writeFile(path, data) {
return writeFile(path, data);
},
async readdir(path) {
const entries = await readdir(path, { withFileTypes: true });
return entries.map((entry) => ({
isFile: () => entry.isFile(),
name: entry.name,
}));
},
async mkdir(path) {
await mkdir(path, { recursive: true });
},
unlink(path) {
return unlink(path);
},
});

10
backend/server/req_res.ts Normal file
View File

@ -0,0 +1,10 @@
// 请求和响应模型
export type cRequest = Request;
export type cResponse = Response;
export type cNext = (
req: cRequest,
res: cResponse,
) => { req: cRequest; res: cResponse } | Promise<{ req: cRequest; res: cResponse }>;
// 中间件函数类型
export type cMiddleware = (req: cRequest, res: cResponse, next: cNext) => ReturnType<cNext>;

300
backend/server/server.ts Normal file
View File

@ -0,0 +1,300 @@
import { cMiddleware, cRequest, cResponse, type cNext } from "./req_res";
// 配置
// 路由器类
export class cRouter {
private middleware: cMiddleware[] = [];
use(middleware: cMiddleware) {
this.middleware.push(middleware);
return this;
}
async handle(req: cRequest, res: cResponse) {
let index = -1;
const next = async (req: cRequest, res: cResponse) => {
index += 1;
// console.log(`开始执行第 ${index} ${this.middleware[index]?.name} 中间件`);
const r = (await this.middleware[index]?.(req, res, next)) ?? { req, res };
// console.log(`执行完毕第 ${index} 中间件`);
return r;
};
return next(req, res);
}
}
// 实现一个简化的 HTTP 服务器
export class SimpleHttpServer {
private router: cRouter = new cRouter();
constructor(options: { port: number; hostname?: string }) {
const release_name = globalThis?.process?.release?.name;
console.log("[release.name]", release_name);
if (release_name === "llrt" || release_name === "node") {
import("./tcp_server").then((m) => {
const server = m.createTcpServer((socket) => {
connectionHandle(socket, (req, res) => this.router.handle(req, res));
});
server.listen(options.port, options.hostname, () => {
console.log(`Server is listening on port ${options.port}`);
});
});
}
}
use(...middlewares: cMiddleware[]) {
middlewares.forEach((middleware) => this.router.use(middleware));
return this;
}
}
const decoder = new TextDecoder("utf-8");
const encoder = new TextEncoder();
/** 合并多个 Uint8Array 为单个 ArrayBuffer */
function mergeChunks(chunks: Uint8Array[], totalLength: number): ArrayBuffer {
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
}
// 请求头终止符
const target = encoder.encode("\r\n\r\n");
async function connectionHandle(
connection: {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
close: () => void;
},
handle: cNext,
) {
try {
const { header, body } = await createStreamAfterTarget(connection.readable, target);
if (!header) {
return;
}
const httpHeaderText = decoder.decode(header);
const httpHeader = parseHttpRequest(httpHeaderText);
const hasBody = httpHeader.method !== "GET" && httpHeader.method !== "HEAD";
/** 大小写不敏感查找 header */
const getHeader = (name: string) => {
const lower = name.toLowerCase();
for (const key of Object.keys(httpHeader.headers)) {
if (key.toLowerCase() === lower) return httpHeader.headers[key];
}
return undefined;
};
/** 读取请求体 */
let bodyArrayBuffer: ArrayBuffer | undefined;
if (hasBody && body) {
const contentLength = parseInt(getHeader("Content-Length") ?? "0", 10);
if (contentLength > 0) {
/** 根据 Content-Length 读取指定长度的 body */
const chunks: Uint8Array[] = [];
let received = 0;
for await (const chunk of body) {
chunks.push(chunk);
received += chunk.length;
if (received >= contentLength) break;
}
body.cancel?.();
bodyArrayBuffer = mergeChunks(chunks, received);
} else if (getHeader("Transfer-Encoding") === "chunked") {
/** 解码 chunked transfer encoding */
const chunks: Uint8Array[] = [];
let totalLength = 0;
const chunkBuf: number[] = [];
let state: "size" | "data" | "crlf_after_data" = "size";
let chunkSize = 0;
let dataRead = 0;
let goto_done = false;
for await (const rawChunk of body) {
for (const byte of rawChunk) {
switch (state) {
case "size": {
if (byte === 13) continue; // \r
if (byte === 10) {
// \n — size 行结束
const sizeStr = new TextDecoder().decode(new Uint8Array(chunkBuf)).trim();
chunkSize = parseInt(sizeStr, 16);
chunkBuf.length = 0;
if (chunkSize === 0) {
state = "crlf_after_data"; // 最后一个空行
} else {
state = "data";
dataRead = 0;
}
} else {
chunkBuf.push(byte);
}
break;
}
case "data": {
chunkBuf.push(byte);
dataRead++;
if (dataRead >= chunkSize) {
const data = new Uint8Array(chunkBuf);
chunks.push(data);
totalLength += data.length;
chunkBuf.length = 0;
state = "crlf_after_data";
}
break;
}
case "crlf_after_data": {
// 跳过 trailing \r\n
if (byte === 10) {
if (chunkSize === 0) {
// 结束标记后的 \n
state = "size";
goto_done = true;
} else {
state = "size";
}
}
break;
}
}
if (goto_done) break;
}
if (goto_done) break;
}
if (totalLength > 0) {
bodyArrayBuffer = mergeChunks(chunks, totalLength);
}
body.cancel?.();
} else {
/** 无 Content-Length 且非 chunked暂不处理 */
}
}
const rawReq = new Request("http://" + (getHeader("Host") ?? "localhost") + httpHeader.url, {
method: httpHeader.method,
headers: httpHeader.headers,
});
/** 将 body 数据挂到 request 对象上,供中间件直接读取 */
(rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer;
const rawRes = new Response();
const { req, res } = await handle(rawReq, rawRes);
const resWriter = connection.writable.getWriter();
let headerText: string[] = [];
res.headers.forEach((value, key) => {
headerText.push(`${key}: ${value}`);
});
const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`;
await resWriter.write(encoder.encode(resHeaertText));
if (res.body) {
/** node 运行时 */
resWriter.releaseLock();
await res.body?.pipeTo(connection.writable);
} else {
/** llrt 运行时 */
const buffer = new Uint8Array(await (await res.blob()).arrayBuffer());
await resWriter.write(buffer);
}
if (!resWriter.closed) {
await resWriter.close();
}
connection.close();
} catch (err) {
console.log("[connectionHandle error]", err);
try { connection.close(); } catch { /* ignore */ }
}
}
function parseHttpRequest(requestText: string) {
const lines = requestText.trim().split("\n");
if (lines.length === 0) {
throw new Error("Invalid HTTP request");
}
// 解析请求行
const [method, url, httpVersion] = lines[0].split(" ");
// 解析头部
const headers: Record<string, string> = {};
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === "" || line === "\r") break; // 空行表示头部结束
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
url,
httpVersion,
headers,
};
}
function createStreamAfterTarget(
originalStream: ReadableStream<Uint8Array>,
target: Uint8Array,
): Promise<{ header: Uint8Array | null; body: ReadableStream<Uint8Array> }> {
const reader = originalStream.getReader();
let buffer = new Uint8Array();
function containsTarget(buf: Uint8Array, tgt: Uint8Array): number {
for (let i = 0; i <= buf.length - tgt.length; i++) {
if (buf.slice(i, i + tgt.length).every((value, index) => value === tgt[index])) {
return i;
}
}
return -1;
}
return new Promise((resolve) => {
function pump() {
reader.read().then(({ done, value }) => {
if (done) {
resolve({ header: null, body: new ReadableStream<Uint8Array>() });
return;
}
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
const targetIndex = containsTarget(buffer, target);
if (targetIndex === -1) {
pump();
return;
}
const start = targetIndex + target.length;
const header = buffer.slice(0, start);
const remainingData = buffer.slice(start);
/** body stream先写入剩余数据然后在后台继续从 originalStream 读取 */
let controller!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
if (remainingData.length > 0) {
controller.enqueue(remainingData);
}
/** 后台继续读取 originalStream 并转发到 body stream */
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) { controller.close(); return; }
controller.enqueue(value);
}
} catch (err) {
/** body stream 被消费方 cancel 时预期会抛错 */
console.log("[createStreamAfterTarget] body stream closed:", err);
}
})();
},
});
resolve({ header, body });
});
}
pump();
});
}

View File

@ -0,0 +1,56 @@
import { createServer } from "net";
// 创建 TCP 服务器
export function createTcpServer(
onSocket: (socket: {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
close: () => void;
}) => void,
) {
const server = createServer((socket) => {
const readable = new ReadableStream<Uint8Array>({
start(controller) {
socket.on("data", (chunk) => {
controller.enqueue(new Uint8Array(chunk));
});
socket.on("error", (err) => {
controller.error(err);
});
socket.on("close", () => {
controller.close();
});
},
cancel() {
socket.destroy();
},
});
// 创建 WritableStream
const writable = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise((resolve, reject) => {
socket.write(chunk, (err) => {
if (err) reject(err);
else resolve();
});
});
},
close() {
socket.end();
},
abort(reason) {
socket.destroy(reason);
},
});
// 实现 close 方法
function close() {
socket.end();
socket.destroy();
}
onSocket({ readable, writable, close });
});
return server;
}

20
backend/test.js Normal file
View File

@ -0,0 +1,20 @@
// console.log("[global.tjs.engine]", global.tjs.engine.gc.enabled);
// global.tjs.engine.gc.threshold = 100;
// console.log("[global.tjs.engine]", global.tjs.engine.gc.threshold);
function runGCTests() {
let objects = [];
for (let i = 0; i < 100000; i++) {
objects[i] = { index: i, data: new Array(1000).fill(i) };
}
objects = null; // Dereference the objects to make them eligible for garbage collection
}
runGCTests();
setInterval(() => {
if (global.tjs) {
console.log("[global.tjs.engine]", global.tjs.engine.gc.run());
}
}, 3000);
setTimeout(() => {}, 1000000);
// 168064

129
backend/upload.ts Normal file
View File

@ -0,0 +1,129 @@
import { writeFile, unlink, readdir, stat, path_join } from "./interface";
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
/** 允许的字体文件扩展名 */
const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"];
function isAllowedFontFile(filename: string): boolean {
const lower = filename.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
/** 清理文件名,移除路径分隔符、危险字符和路径穿越成分 */
function sanitizeFilename(filename: string) {
let name = filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, "");
/** 移除所有 .. 防止路径穿越 */
name = name.replace(/\.\./g, "");
/** 取基础文件名,防止以 . 开头的隐藏文件 */
name = name.replace(/^\./, "");
if (!name) return "unnamed.ttf";
return name;
}
/** 确保目录存在,不存在则创建 */
async function ensureDir(dir: string) {
const { stat, mkdir } = await import("./interface");
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
export interface UploadResult {
success: boolean;
error?: string;
}
/**
* temp
* readdir FIFO
*/
async function getTempFontFiles(): Promise<Array<{ name: string; size: number }>> {
const entries = await readdir("font/temp");
const files: Array<{ name: string; size: number }> = [];
for (const entry of entries) {
if (entry.isFile() && isAllowedFontFile(entry.name)) {
const s = await stat(path_join("font/temp", entry.name));
files.push({ name: entry.name, size: s.size });
}
}
return files;
}
/**
* temp 使
* @param incomingSize
*/
async function evictIfNeeded(incomingSize: number) {
const files = await getTempFontFiles();
/** 数量超限时删除最早的文件 */
while (files.length >= tempMaxFiles) {
const oldest = files.shift();
if (!oldest) break;
try { await unlink(path_join("font/temp", oldest.name)); } catch { /* 删除失败不影响上传 */ }
}
/** 总体积超限时继续删除,直到腾出足够空间 */
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const needed = totalSize + incomingSize - tempMaxTotalSize;
if (needed > 0) {
let freed = 0;
for (const file of files) {
if (freed >= needed) break;
try { await unlink(path_join("font/temp", file.name)); } catch { /* 删除失败不影响上传 */ }
freed += file.size;
}
}
}
export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise<UploadResult> {
if (!enableTempUpload) {
return { success: false, error: "临时上传功能未启用" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/temp");
const filename = sanitizeFilename(fileData.filename);
const filePath = path_join("font/temp", filename);
/** 同名文件直接覆盖(覆盖不算新增),否则检查限制并清理 */
try {
const existing = await stat(filePath);
/** 覆盖时,新文件可能比旧文件大,仍需检查总体积 */
await evictIfNeeded(fileData.data.length - existing.size);
} catch {
await evictIfNeeded(fileData.data.length);
}
await writeFile(filePath, fileData.data);
return { success: true };
}
export async function handleAdminUpload(
fileData: { data: Uint8Array; filename: string },
apiKey: string,
): Promise<UploadResult> {
if (!adminApiKey) {
return { success: false, error: "管理员上传功能未启用" };
}
if (apiKey !== adminApiKey) {
return { success: false, error: "API Key 无效" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/admin");
const filename = sanitizeFilename(fileData.filename);
await writeFile(path_join("font/admin", filename), fileData.data);
return { success: true };
}

BIN
doc/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
doc/启动内存占用.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="在线字体裁剪工具 — 服务端按需裁剪字体子集,大小无限制,免费开源。支持自定义裁剪、增量加载 SDK轻松嵌入任何网站。" />
<title>WebFont — 在线字体裁剪 | 按需加载 | 免费开源</title>
</head>
<body>
<div id="root"></div>
<script src="/webfont-sdk.js"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -1,4 +0,0 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

14099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +1,36 @@
{
"name": "webfont",
"version": "0.0.1",
"description": "",
"author": "",
"license": "MIT",
"private": true,
"version": "1.5.0",
"type": "module",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build && parcel build ./static/index.html",
"build:font": "nest build && parcel build ./static/index.html --public-url /font/",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start:web": "parcel ./static/index.html",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "tslint -p tsconfig.json -c tslint.json",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"dev": "pnpx tsx scripts/dev-all.ts",
"dev:frontend": "vite",
"dev:backend": "pnpx tsx backend/app.ts",
"build": "vite build",
"build_backend": "pnpx tsx scripts/build-backend.ts",
"docker_build": "docker build -t llej0/web-font:${npm_package_version} -t llej0/web-font:latest .",
"docker_push": "docker push llej0/web-font:${npm_package_version} && docker push llej0/web-font:latest",
"preview": "vite preview",
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
},
"dependencies": {
"@babel/polyfill": "^7.8.7",
"@fullhuman/postcss-purgecss": "^2.1.0",
"@nestjs/common": "^6.7.2",
"@nestjs/core": "^6.7.2",
"@nestjs/platform-express": "^6.7.2",
"@nestjs/serve-static": "^2.1.0",
"font-spider": "^1.3.5",
"fontmin": "^0.9.8",
"parcel-bundler": "^1.12.4",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.0",
"rxjs": "^6.5.3",
"svelte": "^3.20.1",
"tailwindcss": "^1.2.0",
"zip-a-folder": "0.0.12",
"zip-folder": "^1.0.0"
"solid-js": "^1.9.12",
"web-streams-polyfill": "^4.2.0"
},
"devDependencies": {
"@nestjs/cli": "^6.9.0",
"@nestjs/schematics": "^6.7.0",
"@nestjs/testing": "^6.7.1",
"@types/express": "^4.17.1",
"@types/jest": "^24.0.18",
"@types/node": "^12.7.5",
"@types/supertest": "^2.0.8",
"cssnano": "^4.1.10",
"jest": "^24.9.0",
"parcel-plugin-svelte": "^4.0.6",
"prettier": "^1.18.2",
"supertest": "^4.0.2",
"ts-jest": "^24.1.0",
"ts-loader": "^6.1.1",
"ts-node": "^8.4.1",
"tsconfig-paths": "^3.9.0",
"tslint": "^5.20.0",
"typescript": "^3.6.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"browserslist": "last 10 chrome versions"
}
"@types/node": "^25.5.2",
"@xmldom/xmldom": "^0.9.9",
"jsdom": "^29.0.2",
"pngjs": "^7.0.0",
"puppeteer": "^24.40.0",
"skia-canvas": "^3.0.8",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"undici": "^8.0.2",
"vite": "^8.0.7",
"vite-plugin-pilot": "^1.0.19",
"vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.3"
}
}

3282
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1 @@
approveBuilds: puppeteer

View File

@ -1,23 +0,0 @@
const tailwindcss = require('tailwindcss');
const purgecss = require('@fullhuman/postcss-purgecss')({
// Specify the paths to all of the template files in your project
content: [
'./static/**/*.svelte',
'./static/**/*.js',
'./static/**/*.ts',
'./static/*.svelte',
'./static/*.js',
'./static/*.ts',
`111 `
],
// Include any special characters you're using in this regular expression
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
});
module.exports = {
plugins: [
tailwindcss,
...(process.env.NODE_ENV === 'development' ? [] : [purgecss]),
],
};

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

390
public/webfont-sdk.js Normal file
View File

@ -0,0 +1,390 @@
/**
* WebFont SDK 按需增量加载字体片段无闪烁
*
* 架构核心增量引擎 + 多种触发方式
* - 核心FontLoader fontKey 管理已加载字符集只生成增量 CSS
* - 触发器loadFont轮询observeFontDOM 事件loadText手动传文本
* - 同一 fontKey 下所有触发器共享字符集绝不会重复请求
*
* 用法
* // 轮询模式
* WebFont.loadFont({ fontName, selector, family, interval });
*
* // 事件驱动模式
* var obs = WebFont.observeFont({ fontName, selector, family });
* obs.dispose();
*
* // 直接传文本模式
* var loader = WebFont.loadText({ fontName, text: "你好世界", family });
* loader.update("追加文字");
* loader.dispose();
*
* // 清理全部
* WebFont.disposeAll();
*/
var WebFont = (function () {
/* ============================================================
* 核心增量引擎 fontKey 管理已加载字符集生成增量 CSS
* ============================================================ */
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */
var loaders = {};
/**
* 生成 fontKey同一字体+family 归入同一组
*/
function fontKey(fontName, family) {
return fontName + "|" + family;
}
/**
* 获取或创建对应 fontKey 的加载器
*/
function getLoader(fontName, baseUrl, family, outType) {
var key = fontKey(fontName, family);
if (!loaders[key]) {
loaders[key] = {
loadedChars: {},
injectedStyles: [],
applied: false,
fontName: fontName,
family: family,
baseUrl: baseUrl,
outType: outType || "woff2"
};
}
return loaders[key];
}
/**
* 差量加载新字符生成 unicode-range CSS 并注入
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string[]} newChars - 待加载的新字符数组
*/
function loadChars(loader, newChars) {
if (newChars.length === 0) return;
var fontName = loader.fontName;
var family = loader.family;
var baseUrl = loader.baseUrl;
var loadedChars = loader.loadedChars;
var text = newChars.join("");
var outType = loader.outType || "woff2";
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text) + "&outType=" + outType;
var formatStr = outType === "woff2" ? "woff2" : "truetype";
var unicodeRanges = newChars
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
.join(", ");
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + url + '") format("' + formatStr + '");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
loader.injectedStyles.push(style);
}
/**
* 从字符集中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {Object.<string,boolean>} charSet - 待检查的字符集
* @returns {boolean} 是否有新字符被加载
*/
function processChars(loader, charSet) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var c in charSet) {
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 从字符串中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string} text - 待检查的文本
* @returns {boolean} 是否有新字符被加载
*/
function processText(loader, text) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 销毁加载器及其所有注入的样式
*/
function destroyLoader(key) {
var loader = loaders[key];
if (!loader) return;
for (var i = 0; i < loader.injectedStyles.length; i++) {
loader.injectedStyles[i].remove();
}
delete loaders[key];
}
/* ============================================================
* 辅助函数
* ============================================================ */
/**
* 获取元素的文本内容
*/
function getText(el) {
var tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return el.value || "";
}
return el.textContent || "";
}
/**
* 收集选择器匹配元素中的所有字符
*/
function collectChars(selector) {
var charSet = {};
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
var text = getText(elements[i]);
for (var j = 0; j < text.length; j++) {
charSet[text[j]] = true;
}
}
return charSet;
}
/**
* 应用字体到元素
*/
function applyFamily(selector, family) {
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
elements[i].style.fontFamily = '"' + family + '", sans-serif';
}
}
/* ============================================================
* 任务管理 各触发器的清理
* ============================================================ */
/** 按 selector 索引的 loadFont 任务 */
var pollTasks = {};
/** 按选择器索引的 observeFont 任务 */
var observeTasks = {};
/* ============================================================
* 1. loadFont 定时器轮询模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.interval=1000] - 轮询间隔ms
*/
function loadFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var interval = options.interval || 1000;
/* 清理同一选择器的旧任务 */
if (pollTasks[selector]) {
clearInterval(pollTasks[selector].timer);
}
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
var applied = false;
function tick() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
applyFamily(selector, family);
}
}
tick();
var timer = setInterval(tick, interval);
pollTasks[selector] = { timer: timer };
}
/* ============================================================
* 2. observeFont MutationObserver 事件驱动模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.debounceMs=50] - 防抖间隔ms
* @returns {{ dispose: function }}
*/
function observeFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var debounceMs = options.debounceMs || 50;
/* 清理同一选择器的旧任务 */
if (observeTasks[selector]) {
observeTasks[selector].dispose();
}
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
var applied = false;
var debounceTimer = null;
function doLoad() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
applyFamily(selector, family);
}
}
function debouncedLoad() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(doLoad, debounceMs);
}
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].type === "childList" || mutations[i].type === "characterData") {
debouncedLoad();
return;
}
}
});
var inputHandler = function () { debouncedLoad(); };
var elements = document.querySelectorAll(selector);
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
el.addEventListener("input", inputHandler);
}
}
doLoad();
var disposed = false;
var task = {
dispose: function () {
if (disposed) return;
disposed = true;
observer.disconnect();
for (var j = 0; j < elements.length; j++) {
var el2 = elements[j];
if (el2.tagName === "INPUT" || el2.tagName === "TEXTAREA") {
el2.removeEventListener("input", inputHandler);
}
}
if (debounceTimer) clearTimeout(debounceTimer);
delete observeTasks[selector];
}
};
observeTasks[selector] = task;
return task;
}
/* ============================================================
* 3. loadText 直接传文本模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.text
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @returns {{ update: function(string): void, dispose: function(): void }}
*/
function loadText(options) {
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var outType = options.outType || "woff2";
var loader = getLoader(fontName, baseUrl, family, outType);
processText(loader, options.text);
var disposed = false;
return {
update: function (text) {
if (disposed) return;
processText(loader, text);
},
dispose: function () {
if (disposed) return;
disposed = true;
/** 移除该 loader 注入的所有 @font-face 样式,避免同名 family 的 CSS 优先级冲突 */
destroyLoader(fontKey(fontName, family));
}
};
}
/* ============================================================
* 公共 API
* ============================================================ */
/**
* 清理所有任务和加载器页面卸载时调用
*/
function disposeAll() {
for (var sel in pollTasks) {
clearInterval(pollTasks[sel].timer);
}
for (var oid in observeTasks) {
observeTasks[oid].dispose();
}
pollTasks = {};
observeTasks = {};
for (var key in loaders) {
destroyLoader(key);
}
}
return {
loadFont: loadFont,
observeFont: observeFont,
loadText: loadText,
disposeAll: disposeAll
};
})();

129
scripts/build-backend.ts Normal file
View File

@ -0,0 +1,129 @@
/**
*
* 1. LLRT
* 2. tsup
* 3. 使 LLRT compile .lrt
*/
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { arch, platform } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
/** 自动读取 http_proxy/https_proxy 环境变量配置全局代理 */
const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY
|| process.env.http_proxy || process.env.HTTP_PROXY;
if (proxyUrl) {
setGlobalDispatcher(new EnvHttpProxyAgent());
console.log(`Using proxy: ${proxyUrl}`);
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, "..");
const LLRT_BIN = join(ROOT_DIR, "llrt");
/** 映射 node arch 到 LLRT arch */
function getLlrtArch() {
const a = arch();
if (a === "x64") return "x64";
if (a === "arm64") return "arm64";
throw new Error(`Unsupported architecture: ${a}`);
}
/** 映射 node platform 到 LLRT platform */
function getLlrtPlatform() {
const p = platform();
if (p === "linux") return "linux";
if (p === "darwin") return "darwin";
if (p === "win32") return "windows";
throw new Error(`Unsupported platform: ${p}`);
}
/** 确保 llrt 二进制文件存在,不存在则下载 */
async function ensureLlrt() {
if (existsSync(LLRT_BIN)) {
console.log("LLRT binary found, skipping download.");
return;
}
const llrtArch = getLlrtArch();
const llrtPlatform = getLlrtPlatform();
console.log("Fetching latest LLRT release version...");
const res = await fetch("https://api.github.com/repos/awslabs/llrt/releases/latest");
/** 响应数据 */
const data = await res.json() as { tag_name: string };
const version = data.tag_name;
if (!version) {
throw new Error("Failed to fetch latest LLRT version from GitHub");
}
console.log(`Latest LLRT version: ${version}`);
const downloadUrl = `https://github.com/awslabs/llrt/releases/download/${version}/llrt-${llrtPlatform}-${llrtArch}.zip`;
console.log(`Downloading from ${downloadUrl} ...`);
const zipRes = await fetch(downloadUrl);
if (!zipRes.ok) {
throw new Error(`Failed to download LLRT: ${zipRes.status} ${zipRes.statusText}`);
}
/** 下载 zip 到临时文件 */
const tmpDir = join(ROOT_DIR, ".tmp-llrt");
await mkdir(tmpDir, { recursive: true });
const zipPath = join(tmpDir, "llrt.zip");
const arrayBuffer = await zipRes.arrayBuffer();
await writeFile(zipPath, Buffer.from(arrayBuffer));
/** 使用 unzip 命令解压Node 内置没有 zip 解压) */
const binaryName = platform() === "win32" ? "llrt.exe" : "llrt";
execSync(`unzip -o -j "${zipPath}" "${binaryName}" -d "${ROOT_DIR}"`, {
stdio: "inherit",
});
/** linux/mac 需要可执行权限 */
if (platform() !== "win32") {
execSync(`chmod +x "${LLRT_BIN}"`);
}
await rm(tmpDir, { recursive: true });
console.log(`LLRT ${version} installed successfully.`);
}
/** 运行 tsup 编译 */
function runTsup() {
console.log("\n--- Running tsup build ---");
execSync("pnpm tsup", { stdio: "inherit", cwd: ROOT_DIR });
}
/** woff2 已使用纯 JS 实现vendor/fonteditor-core/woff2/index.js无需复制 wasm */
/** 使用 LLRT compile 生成 .lrt 文件 */
function runLlrtCompile() {
console.log("\n--- Running LLRT compile ---");
execSync(`${LLRT_BIN} compile ./dist_backend/backend/app.cjs ./dist_backend/app.lrt`, {
stdio: "inherit",
cwd: ROOT_DIR,
});
execSync(`${LLRT_BIN} compile "./dist_backend/基准测试_llrt.cjs" ./dist_backend/llrt_bench.lrt`, {
stdio: "inherit",
cwd: ROOT_DIR,
});
console.log("\nBackend build completed successfully!");
}
/** 主流程 */
async function main() {
await ensureLlrt();
runTsup();
runLlrtCompile();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

42
scripts/check_ttf.ts Normal file
View File

@ -0,0 +1,42 @@
import { readFile } from "node:fs/promises";
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
const raw = await readFile("font/temp/YiShanBeiZhuanTi.ttf");
const buf = new Uint8Array(raw).buffer;
const text = "你好世界";
const subset = [...text].map(c => c.codePointAt(0));
const font = Font.create(buf, { type: "ttf", subset });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const data = new Uint8Array(result);
console.log("=== TTF Header ===");
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
console.log("sfVersion:", "0x" + view.getUint32(0, false).toString(16));
console.log("numTables:", view.getUint16(4, false));
const tables: Array<{ tag: string; offset: number; length: number }> = [];
for (let i = 0; i < view.getUint16(4, false); i++) {
const offset = 12 + i * 16;
const tag = String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
const toffset = view.getUint32(offset + 8, false);
const tlength = view.getUint32(offset + 12, false);
tables.push({ tag, offset: toffset, length: tlength });
console.log(" ", tag, "offset=" + toffset, "length=" + tlength);
}
const headEntry = tables.find(t => t.tag === "head");
if (headEntry) {
const magic = view.getUint32(headEntry.offset + 12, false);
console.log("\nhead magicNumber:", "0x" + magic.toString(16), magic === 0x5F0F3CF5 ? "OK" : "INVALID!");
}
console.log("\nfile size:", data.length);
console.log("last table end:", Math.max(...tables.map(t => t.offset + t.length)));
/** 和原始字体对比 */
const origView = new DataView(buf);
console.log("\n=== 原始字体 ===");
console.log("sfVersion:", "0x" + origView.getUint32(0, false).toString(16));
console.log("numTables:", origView.getUint16(4, false));

46
scripts/dev-all.ts Normal file
View File

@ -0,0 +1,46 @@
/**
* (vite) (tsx backend/app.ts)
* Ctrl+C
*/
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = join(__dirname, "..");
/** 子进程列表,用于退出时统一清理 */
const children: ReturnType<typeof spawn>[] = [];
/** 清理所有子进程 */
function cleanup() {
for (const child of children) {
child.kill("SIGTERM");
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
console.log("Starting frontend and backend dev servers...\n");
const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], {
cwd: ROOT_DIR,
stdio: "inherit",
shell: true,
env: { ...process.env, ENABLE_TEMP_UPLOAD: "true", ADMIN_API_KEY: "dev-key" },
});
children.push(backend);
const frontend = spawn("pnpm", ["dev:frontend"], {
cwd: ROOT_DIR,
stdio: "inherit",
shell: true,
});
children.push(frontend);

View File

@ -0,0 +1,71 @@
/**
*
* 运行: pnpm tsx scripts/test_font_valid.ts
*/
import { Font } from "../vendor/fonteditor-core/lib/ttf/font.js";
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
const OUTPUT_DIR = "benchmark_results/font_test";
await mkdir(OUTPUT_DIR, { recursive: true });
/** 在所有字体目录中查找字体 */
import { stat } from "node:fs/promises";
const fontDirs = ["font/admin", "font", "font/temp"];
async function findFonts(): Promise<Array<{ name: string; path: string }>> {
const all: Array<{ name: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
const name = typeof entry === "string" ? entry : entry.name;
if (/\.(ttf|otf|woff|woff2)$/i.test(name)) {
const fullPath = `${dir}/${name}`;
try {
const s = await stat(fullPath);
if (s.isFile()) {
all.push({ name, path: fullPath });
}
} catch { /* skip */ }
}
}
} catch { /* skip */ }
}
return all;
}
const fonts = await findFonts();
const testText = "你好世界";
const codePoints = [...testText].map(c => c.codePointAt(0)!);
console.log("\n=== 字体裁剪验证 ===\n");
console.log(`测试文本: "${testText}"\n`);
for (const f of fonts) {
try {
const raw = await readFile(f.path);
const buf = new Uint8Array(raw).buffer;
const font = Font.create(buf, { type: "ttf", subset: codePoints });
const optimized = font.optimize().sort();
const result = optimized.write({ type: "ttf" });
const data = typeof result === "string"
? new TextEncoder().encode(result)
: new Uint8Array(result);
const outPath = `${OUTPUT_DIR}/${f.name.replace(/\.[^.]+$/, "")}_subset.ttf`;
await writeFile(outPath, data);
/** 检查 TTF 文件头 */
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const sfVersion = view.getUint32(0, false);
const numTables = view.getUint16(4, false);
console.log(` ${f.name}: ${data.length.toLocaleString()} bytes, sfVersion=0x${sfVersion.toString(16)}, numTables=${numTables}`);
} catch (e: any) {
console.log(` ${f.name}: ERROR - ${e.message}`);
}
}
console.log(`\n输出目录: ${OUTPUT_DIR}/`);
console.log("请在 Windows 字体查看器中打开验证");

312
src/App.tsx Normal file
View File

@ -0,0 +1,312 @@
import { createMemo, createSignal, createEffect, onMount, Show, For } from "solid-js";
import { fetchFonts, fetchConfig, type FontInfo, type ServerConfig } from "./api";
import UploadSection from "./UploadSection";
import { SelectorRow } from "./FontSelector";
const s = {
wrap: {
"max-width": "720px",
margin: "0 auto",
padding: "48px 24px",
"font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
color: "#1a1a1a",
"line-height": "1.6",
} as const,
h1: {
"font-size": "22px",
"font-weight": 600,
margin: "0 0 4px 0",
} as const,
desc: {
"font-size": "24px",
color: "#888",
margin: "0 0 36px 0",
} as const,
label: {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const,
select: {
width: "100%",
padding: "8px 12px",
"font-size": "15px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const,
textarea: {
width: "100%",
padding: "8px 12px",
"font-size": "32px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
resize: "none",
"box-sizing": "border-box",
outline: "none",
color: "#e74c3c",
"line-height": "1.4",
} as const,
pre: {
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",
} as const,
section: {
"margin-bottom": "28px",
} as const,
card: {
padding: "16px",
border: "1px solid #e8e8e8",
"border-radius": "8px",
"margin-bottom": "16px",
} as const,
btn: {
padding: "6px 20px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
cursor: "pointer",
background: "#fff",
color: "#333",
} as const,
input: {
padding: "6px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const,
};
function App() {
const [text, set_text] = createSignal("天地无极,乾坤借法");
const [fonts, set_fonts] = createSignal<FontInfo[]>([]);
const [selectedFont, set_selectedFont] = createSignal("");
const [outType, set_outType] = createSignal<"woff2" | "ttf">("ttf");
const [serverConfig, set_serverConfig] = createSignal<ServerConfig>({
enableTempUpload: false,
adminUploadEnabled: false,
supportedOutTypes: ["woff2", "ttf"],
});
const SLOGAN = "如清风似闪电,超级快的字体子集化裁剪";
onMount(async () => {
const [fontList, config] = await Promise.all([fetchFonts().catch(() => []), fetchConfig().catch(() => ({ enableTempUpload: false, adminUploadEnabled: false }))]);
set_fonts(fontList);
set_serverConfig(config);
/** 服务端不支持当前 outType 时自动回退 */
if (!config.supportedOutTypes?.includes(outType())) {
set_outType(config.supportedOutTypes?.[0] || "ttf");
}
if (fontList.length > 0) {
/** 标语随机使用一个可用字体展示 */
const usableFonts = fontList.filter((f) => /\.(ttf|otf)$/i.test(f.name));
const randomFont = usableFonts[Math.floor(Math.random() * usableFonts.length)];
(globalThis as any).WebFont?.loadText({
fontName: randomFont.name,
text: SLOGAN,
family: "SloganFont",
});
const sloganEl = document.getElementById("slogan");
if (sloganEl) {
sloganEl.style.fontFamily = '"SloganFont", sans-serif';
sloganEl.title = randomFont.name;
}
set_selectedFont(fontList[0].name);
}
});
const cssStyle = createMemo(() => {
const font = selectedFont();
const ot = outType();
if (!font) return "";
const formatStr = ot === "woff2" ? "woff2" : "truetype";
return `@font-face {
font-family: "CustomFont";
src: url("${location.origin}/api?font=${font}&text=${encodeURIComponent(text())}&outType=${ot}") format("${formatStr}");
}
.custom-font {
color: red;
font-family: "CustomFont";
}`;
});
/** loadText loader 引用,字体或文本变化时增量加载 */
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 文本变化时增量加载字体 */
const onTextChange = (value: string) => {
set_text(value);
if (!textLoader) return;
textLoader.update(value);
};
/** 根据文本行数动态计算 textarea 高度 */
const textareaRows = createMemo(() => {
const lines = text().split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
/** 记录上次加载的 font 和 outType避免重复加载 */
let lastLoadKey = "";
/** 字体切换或格式切换时重新加载字体 */
const reloadFont = (font: string, ot: "woff2" | "ttf") => {
const key = `${font}|${ot}`;
if (!font || key === lastLoadKey) return;
lastLoadKey = key;
if (textLoader) textLoader.dispose();
textLoader = (globalThis as any).WebFont?.loadText({
fontName: font,
text: text(),
family: "CustomFont",
outType: ot,
}) ?? null;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
};
const onFontChange = (font: string) => {
set_selectedFont(font);
};
/** 字体或输出格式变化时重新加载 */
createEffect(() => {
reloadFont(selectedFont(), outType());
});
async function refreshFonts() {
const fontList = await fetchFonts();
set_fonts(fontList);
if (fontList.length > 0 && !selectedFont()) {
onFontChange(fontList[0].name);
}
}
return (
<div style={s.wrap}>
<div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}>
<h1 style={s.h1}>Web Font</h1>
<a
href="https://github.com/2234839/web-font"
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", "align-items": "center", "gap": "4px", "font-size": "13px", color: "#888", "text-decoration": "none", border: "1px solid #d9d9d9", "border-radius": "6px", padding: "4px 10px" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Star on GitHub
</a>
</div>
<p id="slogan" style={s.desc}>{SLOGAN}</p>
<section style={s.section}>
<SelectorRow
fonts={fonts()}
selectedFont={selectedFont()}
onFontChange={onFontChange}
supportedOutTypes={serverConfig().supportedOutTypes || ["woff2", "ttf"]}
outType={outType()}
onOutTypeChange={set_outType}
/>
</section>
<section style={s.section}>
<label style={s.label}></label>
<textarea
id="webfont-preview"
style={{
...s.textarea,
}}
rows={textareaRows()}
value={text()}
onInput={(e) => onTextChange(e.target.value)}
placeholder="在此输入文本..."
/>
</section>
<Show when={selectedFont()}>
<section style={s.section}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "margin-bottom": "6px" }}>
<label style={{ ...s.label, margin: "0" }}>CSS </label>
<div style={{ display: "flex", gap: "6px" }}>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={() => {
const ot = outType();
const a = document.createElement("a");
a.href = `/api?font=${selectedFont()}&text=${encodeURIComponent(text())}&outType=${ot}`;
a.download = selectedFont().replace(/\.[^.]+$/, "") + `_subset.${ot}`;
a.click();
}}
>
</button>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={async (e) => {
const btn = e.currentTarget;
try {
await navigator.clipboard.writeText(cssStyle());
btn.textContent = "已复制";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
} catch {
btn.textContent = "复制失败";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
}
}}
>
CSS
</button>
</div>
</div>
<pre style={s.pre}>{cssStyle()}</pre>
</section>
</Show>
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
<p><b></b> text URL </p>
<p><b></b> CSS text </p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<style>
@font-face {
font-family: "MyFont";
src: url("${location.origin}/api?font=字体名&text=你的文字") format("woff2");
}
.title { font-family: "MyFont"; }
</style>
<h1 class="title"></h1>`}</pre>
<p style={{ "margin-top": "12px" }}><b>JS SDK</b><a href="/webfont-sdk.js" download="webfont-sdk.js"> SDK</a></p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<script src="${location.origin}/webfont-sdk.js"><\/script>
<script>
WebFont.loadFont({
fontName: "字体文件名.ttf",
selector: ".my-element",
family: "MyFont",
interval: 1000,
});
<\/script>`}</pre>
<p style={{ "margin-top": "8px" }}> <code>WebFont.observeFont()</code>MutationObserver <code>WebFont.loadText()</code>使SDK </p>
</section>
<footer style={{ "margin-top": "48px", "padding-top": "16px", "border-top": "1px solid #eee", "font-size": "12px", color: "#999", "text-align": "center" }}>
<p> <a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style={{ color: "#999" }}> 100 </a> </p>
</footer>
</div>
);
}
export default App;

118
src/FontSelector.tsx Normal file
View File

@ -0,0 +1,118 @@
import { For } from "solid-js";
interface FontSelectorProps {
fonts: Array<{ name: string; dir: string }>;
selectedFont: string;
onFontChange: (font: string) => void;
}
interface OutTypeSelectorProps {
supportedOutTypes: ("woff2" | "ttf")[];
outType: "woff2" | "ttf";
onOutTypeChange: (type: "woff2" | "ttf") => void;
}
const outTypeLabels: Record<string, string> = {
woff2: "WOFF2 体积更小",
ttf: "TTF 速度更快",
};
const outTypeDescs: Record<string, string> = {
woff2: "约压缩 50%,适合生产",
ttf: "无编码开销,适合开发",
};
const s = {
wrap: {
display: "flex",
gap: "12px",
} as const,
col: {
flex: 1,
} as const,
label: {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const,
select: {
width: "100%",
padding: "8px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
cursor: "pointer",
appearance: "none",
"background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E\")",
"background-repeat": "no-repeat",
"background-position": "right 10px center",
"padding-right": "28px",
} as const,
desc: {
"font-size": "11px",
color: "#bbb",
"margin-top": "4px",
} as const,
};
export function FontSelector(props: FontSelectorProps) {
return (
<div style={s.col}>
<label style={s.label}></label>
<select
style={s.select}
value={props.selectedFont}
onChange={(e) => props.onFontChange(e.target.value)}
>
<option value="">-- --</option>
<For each={props.fonts}>
{(f) => (
<option value={f.name}>
{f.name}
</option>
)}
</For>
</select>
</div>
);
}
export function OutTypeSelector(props: OutTypeSelectorProps) {
return (
<div style={{ width: "160px" }}>
<label style={s.label}></label>
<select
style={s.select}
value={props.outType}
onChange={(e) => props.onOutTypeChange(e.target.value as "woff2" | "ttf")}
>
<For each={props.supportedOutTypes}>
{(t) => (
<option value={t}>{outTypeLabels[t]}</option>
)}
</For>
</select>
<p style={s.desc}>{outTypeDescs[props.outType]}</p>
</div>
);
}
export function SelectorRow(props: FontSelectorProps & OutTypeSelectorProps) {
return (
<div style={s.wrap}>
<FontSelector
fonts={props.fonts}
selectedFont={props.selectedFont}
onFontChange={props.onFontChange}
/>
<OutTypeSelector
supportedOutTypes={props.supportedOutTypes}
outType={props.outType}
onOutTypeChange={props.onOutTypeChange}
/>
</div>
);
}

224
src/UploadSection.tsx Normal file
View File

@ -0,0 +1,224 @@
import { createSignal, Show } from "solid-js";
import { uploadFont, type UploadResult, type ServerConfig } from "./api";
const ACCEPT = ".ttf,.otf,.woff,.woff2";
const UPLOAD_TIP = "支持 .ttf 和 .otf 格式,建议上传 .ttf 字体文件以获得最佳兼容性";
const btn = {
padding: "6px 20px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
cursor: "pointer",
background: "#fff",
color: "#333",
} as const;
const card = {
padding: "16px",
border: "1px solid #e8e8e8",
"border-radius": "8px",
"margin-bottom": "16px",
} as const;
const label = {
display: "block",
"font-size": "13px",
color: "#555",
"margin-bottom": "6px",
} as const;
const input = {
padding: "6px 12px",
"font-size": "14px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
outline: "none",
"box-sizing": "border-box",
} as const;
const section = {
"margin-bottom": "28px",
} as const;
/** 通用文件上传行:选择文件 + 文件名 + 上传按钮 */
function FileUploader(props: {
accept?: string;
disabled?: boolean;
uploading?: boolean;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={{ display: "flex", "gap": "8px", "align-items": "center" }}>
<label style={{ ...btn, display: "inline-flex", "align-items": "center", cursor: "pointer" }}>
<input
type="file"
accept={props.accept ?? ACCEPT}
style={{ display: "none" }}
onChange={(e) => e.target.files?.[0] && props.onFileSelect(e.target.files[0])}
/>
</label>
<span style={{ "font-size": "13px", color: "#666" }}>
{props.fileName ?? "未选择文件"}
</span>
<button
style={{ ...btn, opacity: !props.disabled && !props.uploading ? 1 : 0.5, cursor: !props.disabled && !props.uploading ? "pointer" : "not-allowed" }}
disabled={props.disabled || props.uploading}
onClick={props.onUpload}
>
{props.uploading ? "..." : "上传"}
</button>
</div>
);
}
function useUpload(onSuccess: () => void) {
const [file, set_file] = createSignal<File | null>(null);
const [apiKey, set_apiKey] = createSignal("");
const [uploading, set_uploading] = createSignal(false);
const [msg, set_msg] = createSignal<{ ok: boolean; text: string } | null>(null);
function showMsg(ok: boolean, text: string) {
set_msg({ ok, text });
setTimeout(() => set_msg(null), 3000);
}
async function upload(mode: "temp" | "admin", key?: string) {
const f = file();
if (!f) return;
set_uploading(true);
const result: UploadResult = await uploadFont(f, mode, key);
set_uploading(false);
if (result.success) {
showMsg(true, "上传成功");
set_file(null);
onSuccess();
} else {
showMsg(false, result.error ?? "上传失败");
}
}
return { file, set_file, apiKey, set_apiKey, uploading, msg, showMsg, upload };
}
/** 游客上传区域 */
function TempUploadCard(props: {
uploading: boolean;
msg: { ok: boolean; text: string } | null;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
10 200MB
</div>
<FileUploader
disabled={!props.fileName}
uploading={props.uploading}
onFileSelect={props.onFileSelect}
fileName={props.fileName}
onUpload={props.onUpload}
/>
</div>
);
}
/** 管理员上传区域 */
function AdminUploadCard(props: {
uploading: boolean;
msg: { ok: boolean; text: string } | null;
apiKey: string;
onApiKeyInput: (value: string) => void;
onFileSelect: (file: File) => void;
fileName: string | undefined;
onUpload: () => void;
}) {
return (
<div style={card}>
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}></div>
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
API Key
</div>
<input
type="text"
autocomplete="off"
style={{ ...input, width: "100%", "margin-bottom": "10px" }}
value={props.apiKey}
onInput={(e) => props.onApiKeyInput(e.target.value)}
placeholder="API Key"
/>
<FileUploader
disabled={!props.fileName || !props.apiKey}
uploading={props.uploading}
onFileSelect={props.onFileSelect}
fileName={props.fileName}
onUpload={props.onUpload}
/>
</div>
);
}
export default function UploadSection(props: { config: ServerConfig; onUploaded: () => void }) {
const temp = useUpload(props.onUploaded);
const admin = useUpload(props.onUploaded);
const canUpload = () => props.config.enableTempUpload || props.config.adminUploadEnabled;
return (
<Show when={canUpload()}>
<section style={section}>
<label style={{ ...label, "font-size": "14px", "font-weight": 500, "margin-bottom": "12px" }}></label>
<div style={{ "font-size": "12px", color: "#e6a700", "margin-bottom": "12px" }}>
{UPLOAD_TIP}
</div>
<Show when={temp.msg()}>
{(m) => (
<div
style={{
padding: "8px 12px",
"margin-bottom": "12px",
"border-radius": "6px",
"font-size": "13px",
background: m().ok ? "#f0faf0" : "#fef2f2",
color: m().ok ? "#166534" : "#b91c1c",
border: `1px solid ${m().ok ? "#bbf7d0" : "#fecaca"}`,
}}
>
{m().text}
</div>
)}
</Show>
<Show when={props.config.enableTempUpload}>
<TempUploadCard
uploading={temp.uploading()}
msg={temp.msg()}
onFileSelect={(f) => temp.set_file(f)}
fileName={temp.file()?.name}
onUpload={() => temp.upload("temp")}
/>
</Show>
<Show when={props.config.adminUploadEnabled}>
<AdminUploadCard
uploading={admin.uploading()}
msg={admin.msg()}
apiKey={admin.apiKey()}
onApiKeyInput={(v) => admin.set_apiKey(v)}
onFileSelect={(f) => admin.set_file(f)}
fileName={admin.file()?.name}
onUpload={() => admin.upload("admin", admin.apiKey())}
/>
</Show>
</section>
</Show>
);
}

46
src/api.ts Normal file
View File

@ -0,0 +1,46 @@
export interface FontInfo {
name: string;
dir: string;
}
export interface ServerConfig {
enableTempUpload: boolean;
adminUploadEnabled: boolean;
supportedOutTypes: ("woff2" | "ttf")[];
}
export interface UploadResult {
success: boolean;
error?: string;
}
export async function fetchFonts(): Promise<FontInfo[]> {
const res = await fetch("/api/fonts");
return res.json();
}
export async function fetchConfig(): Promise<ServerConfig> {
const res = await fetch("/api/config");
return res.json();
}
export async function uploadFont(
file: File,
mode: "temp" | "admin",
apiKey?: string,
): Promise<UploadResult> {
const formData = new FormData();
formData.append("font", file);
const headers: Record<string, string> = {};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const res = await fetch(`/api/upload?mode=${mode}`, {
method: "POST",
body: formData,
headers,
});
return res.json();
}

View File

@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -1,85 +0,0 @@
import {
Controller,
Get,
Param,
Query,
Response,
Res,
Req,
Post,
Body,
} from '@nestjs/common';
import { AppService } from './app.service';
import { join } from 'path';
import { promises as fs } from 'fs';
import { Request } from 'express';
import { Stream } from 'stream';
import { req_par } from './req.decorator';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
/** 压缩字体 */
@Get('fontmin')
font_min(@Query('text') text, @Query('font') font,@req_par('host_url') host_url:string) {
return this.appService.font_min(text, font,host_url);
}
@Post('fontmin')
font_min_post(@Body() body: { text: string; font: string }[],@req_par('host_url') host_url:string) {
const res = body.map(par => {
return new Promise((resolve, reject) => {
this.appService
.font_min(par.text, par.font,host_url)
.then(r => {
resolve({
font: par.font,
css:r,
status: 'success',
});
})
.catch(e => {
resolve({
font: par.font,
status: 'failure',
});
});
});
});
return Promise.all(res);
}
/** 返回字体列表 */
@Get('font_list')
font_list() {
return this.appService.font_list();
}
/** 压缩字体 */
@Get('generate_fonts_dynamically*')
async generate_fonts_dynamically(
@Req() req: Request,
@Res() res,
@Query('text') text: string,
@Query('font') font: string,
@Query('temp') temp: string,
) {
const type = req.url.match(/\.(.*)\?/)[1];
res.set({
'Content-Type': `font/${type}`,
});
if (!text) return ' ';
const file = await this.appService.generate_fonts_dynamically(
text,
font,
temp,
type,
);
const bufferStream = new Stream.PassThrough();
bufferStream.end(file);
bufferStream.pipe(res);
}
}
function promise_execute_all<T>(params: Promise<T>[]) {}

View File

@ -1,21 +0,0 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
console.log( join(__dirname, '..'));
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..'),
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../../asset'),
serveRoot: '/asset',
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,142 +0,0 @@
import { Injectable } from '@nestjs/common';
import Fontmin from 'fontmin';
const { zip } = require('zip-a-folder');
import { join } from 'path';
import crypto from 'crypto';
import { config } from './config';
import { promises as fs } from 'fs';
import { req_par } from './req.decorator';
const font_src="./asset/font_src/"
@Injectable()
export class AppService {
font_list() {
const font_dir = join(__dirname, `../../${font_src}`);
return fs.readdir(font_dir);
}
async font_min(text: string, font: string,server_url:string) {
/** 因为 text 为 空或者是空格之类的 会导致 fontmin 运算很久 */
text+='●'
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
const outPath = `asset/font/${Date.now()}/`;
const destPath = `./${outPath}`; // 输出路径
// 初始化
const fontmin = new Fontmin()
.src(srcPath) // 输入配置
.use(
Fontmin.glyph({
text, // 所需文字
}),
)
.use(Fontmin.ttf2eot()) // eot 转换插件
.use(Fontmin.ttf2woff()) // woff 转换插件
.use(Fontmin.ttf2svg()) // svg 转换插件
.use(Fontmin.css({ fontPath: `${server_url}${outPath}` })) // css 生成插件
.dest(destPath); // 输出配置
// 执行
return new Promise((resolve, reject) => {
fontmin.run(function(err, files, stream) {
if (err) {
// 异常捕捉
reject(err);
} else {
const css = files
.filter(f =>
(f.history[f.history.length - 1] as string).endsWith('.css'),
)
.map(f => f._contents.toString())[0];
zip(
join(__dirname, '../../', destPath),
join(__dirname, '../../', destPath, 'asset.zip'),
);
// resolve({code:0,fil:files.map(f=>f._contents.toString())}); // 成功
// resolve({code:0,files}); // 成功
resolve(css); // 成功
}
});
});
}
async generate_fonts_dynamically(
text: string,
font: string,
temp: string,
type: string,
) {
text+='●'
const hash = crypto.createHash('md5');
hash.update(`${type}${font}${text}`);
const hash_str = hash.digest('hex');
const srcPath = `${font_src}${font}.ttf`; // 字体源文件
const outPath = `asset/dynamically/${hash_str}`;
const destPath = `./${outPath}`; // 输出路径
const full_path = join(__dirname, '../../', destPath, `${font}.${type}`);
/** 需要持久化 */
if (temp !== 'true') {
try {
return await fs.readFile(full_path);
} catch (error) {
console.log(`开始生成 ${full_path}`,error);
}
}
// 初始化
const fontmin = new Fontmin()
.src(srcPath) // 输入配置
.use(
Fontmin.glyph({
text, // 所需文字
}),
);
if ('eot' === type) {
fontmin.use(Fontmin.ttf2eot()); // eot 转换插件
}
if ('woff' === type) {
fontmin.use(Fontmin.ttf2woff()); // eot 转换插件
}
if ('svg' === type) {
fontmin.use(Fontmin.ttf2svg()); // eot 转换插件
}
/** 缓存数据 */
if (temp !== 'true') {
fontmin.dest(destPath)
}
// 执行
return new Promise((resolve, reject) => {
fontmin.run(async function(err, files, stream) {
if (err) {
// 异常捕捉
reject(err);
} else {
const buffer = files.filter(f =>
/** 筛选需要的类型 */
(f.history[f.history.length - 1] as string).endsWith(type),
)[0]._contents;
resolve(buffer); // 成功
/** 存个日志 */
if (temp !== 'true') {
const content_path = join(
__dirname,
'../../',
destPath,
`content.txt`,
);
try {
await fs.appendFile(
content_path,
`type:${type}\nfont:${font}\ntext:${text}`,
);
} catch (error) {
console.error(`${content_path} 失败`);
}
}
}
});
});
}
}

1
src/assets/solid.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,3 +0,0 @@
export const config={
web_font_path:"//127.0.0.1:3000/"
}

8
src/index.tsx Normal file
View File

@ -0,0 +1,8 @@
/* @refresh reload */
import { render } from 'solid-js/web'
import App from './App'
const root = document.getElementById('root')
render(() => <App />, root!)

View File

@ -1,15 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import serveStatic from 'serve-static';
import { Response } from 'express';
import { logger } from './middleware/logger.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
app.use(logger);
await app.listen(3000);
}
bootstrap();

View File

@ -1,26 +0,0 @@
import { Response, Request } from 'express';
const NS_PER_SEC = 1e9;
export async function logger(req: Request, res: Response, next) {
const time = process.hrtime();
next();
res.once('finish', () => {
const diff = process.hrtime(time);
console.log(
`[${req.headers['x-forwarded-for'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.remoteAddress}][${(diff[0] * NS_PER_SEC + diff[1]) /
1000000}]`,
decodeURI_catch(req.url),
);
});
}
function decodeURI_catch(url: string) {
try {
return decodeURI(url);
} catch (error) {
return url;
}
}

View File

@ -1,9 +0,0 @@
import { createParamDecorator } from '@nestjs/common';
import { Request } from 'express';
export const req_par = createParamDecorator((data: string, req:Request) => {
if('host_url'===data){
return `//${req.headers.host}/`
}
return req
});

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -1,140 +0,0 @@
<script>
import { writable } from 'svelte/store';
import { get_font, get_font_list,server,post_fontmin } from './req';
/** 可用的字体列表 {id:number,name:string:selected:undefined | boolen,css:undefined|string}*/
$: font_list = [];
get_font_list().then(r => {
font_list = r.map(ttf => ({ name: ttf.replace(/\.ttf$/, '') }));
});
/** 选择的文字 */
let text = '在此输入需要提取的文字\n在右侧选择字体\n然后点击下方的生成字体按钮';
/** 请求方式 */
let request_method="post"
/** 用于测试动态生成接口 */
let generate_fonts_dynamically=`<style>
@font-face {
font-family: "test";
src:
url("${server}generate_fonts_dynamically.ttf?temp=true&font=优设标题黑&text=优设标题黑(直接改这里和前面的字体名看效果)") format("truetype");
font-style: normal;
font-weight: normal;
}
</style>`
$: selected_font = font_list.filter(font => font.selected);
function generate_font() {
if('post'===request_method){
/** 使用 post 请求,单请求方式 */
post_fontmin(
selected_font.map(f=>({
font:f.name, text
}))
).then(res=>{
selected_font.forEach(font=>{
let r=res.find(o=>o.font===font.name).css
font_processing(font,r)
})
})
}
if('get'===request_method){
/** 使用 get 请求,多请求方式 */
selected_font.forEach(font => {
get_font(font.name, text)
.then(r => {
font_processing(font,r)
})
});
}
function font_processing(font,r) {
r=r.replace(/\/\/.*?\//g,server)
const family = r.match(/font-family: "(.*)"/)[1];
font.css = r;
font.family = family;
font.zip=server+r.match(/(asset\/font\/\d+\/)/)[0]+'asset.zip'
/** 因为要触发其他更新则必须对这个变量重新赋值 */
font_list = font_list;
}
}
function copy(str) {
var input = document.getElementById("copy_box");
input.value=str
input.focus();
input.setSelectionRange(0, -1); // 全选
document.execCommand("copy")
alert(`复制成功\n${str}`)
}
</script>
{#each font_list as font, i}
{@html "<style>"+font.css+'.'+font.name+"{font-family:"+font.family+"}</style>"}
{/each}
<h1 class="text-lg text-center mb-3 font-bold">web font 字体裁剪工具</h1>
<textarea id="copy_box" class="w-0 h-0 fixed -m-24" />
<div class="flex justify-evenly">
<textarea
bind:value={text}
class="border flex-1 m-1"
placeholder="在此输入需要提取的文字 在右侧选择字体 然后点击下方的生成字体按钮"
cols="40"
rows="3" />
<div class="flex-1 m-1 flex flex-wrap">
{#each font_list as font, i}
<div
on:click={e => (font.selected = !font.selected)}
class="c-label {font.selected ? 'c-label-selected' : ''}
{font.name}">
{font.name}
</div>
{/each}
</div>
</div>
<div class="flex">
<div on:click={generate_font} class="bg-red-200 text-red-600 rounded-md px-2 hover:bg-red-400 hover:text-white duration-75 flex items-center shadow-md">
生成字体
</div>
<div class="flex border ml-2 items-end">
<div
on:click={e => request_method="post"}
class="c-label {request_method==="post" ? 'c-label-selected' : ''}">
使用 post 请求
</div>
<div
on:click={e => request_method="get"}
class="c-label {request_method==="get" ? 'c-label-selected' : ''}">
使用 get 请求
</div>
<div class="text-sm">* 具体区别请打开控制台查看请求</div>
</div>
</div>
{#each selected_font as font, i}
<div class={font.name}>
<div style="font-size:2rem">{text}</div>
<div class="flex justify-end items-center text-xs">
{#if font.css}
<a class="text-blue-400 underline" href="/{font.zip}">下载压缩资源</a>
<div class="c-label mx-1 text-xs" on:click={copy(font.css)}>复制css</div>
{/if}
<div>{font.name}</div>
</div>
</div>
{/each}
<h2 class="text-lg text-center my-3 font-bold"> 动态生成字体generate_fonts_dynamically 接口)</h2>
<p class="ml-1">使用如下的方式引入,则可以直接使用</p>
<textarea
bind:value={generate_fonts_dynamically}
class="border flex-1 m-1 w-full text-lg"
placeholder="在此输入需要提取的文字"
rows="13"
style="font-family:test;" />
{@html generate_fonts_dynamically}

View File

@ -1,7 +0,0 @@
.c-label {
@apply border m-1 rounded-md px-1 items-center h-6 text-sm;
}
.c-label-selected {
@apply bg-red-600 text-white;
}

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>web font</title>
<link rel="stylesheet" href="./index.css">
</head>
<body class="p-4">
<div class="c-app"></div>
<!-- This script tag points to the source of the JS file we want to load and bundle -->
<script src="./index.js"></script>
</body>
</html>

View File

@ -1,6 +0,0 @@
// import '@babel/polyfill';
import App from './App.svelte';
import "./app.css";
new App({
target: document.querySelector('.c-app'),
});

View File

@ -1,49 +0,0 @@
export const server = '//' + location.host + location.pathname;
/** get 方式压缩字体 */
export function get_font(font: string, text: string) {
return new Promise((rs, re) => {
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(this.responseText);
}
});
xhr.open(
'GET',
`${server}fontmin?font=${encodeURIComponent(
font,
)}&text=${encodeURIComponent(text)}`,
);
xhr.onerror = re;
xhr.send();
});
}
/** post 方式压缩字体 */
export function post_fontmin(par:{font:string,text:string}[]) {
return new Promise((rs, re) => {
var data = JSON.stringify(par);
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(JSON.parse(this.responseText) );
}
});
xhr.open('POST', `${server}fontmin`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onerror = re;
xhr.send(data);
});
}
export function get_font_list(font: string, text: string) {
return new Promise((rs, re) => {
var xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', function() {
if (this.readyState === 4) {
rs(JSON.parse(this.responseText));
}
});
xhr.open('GET', `${server}font_list`);
xhr.onerror = re;
xhr.send();
});
}

3
static/svelte.d.ts vendored
View File

@ -1,3 +0,0 @@
declare module '*.svelte' {
export default any;
}

View File

@ -1,6 +0,0 @@
module.exports = {
theme: {
},
variants: {},
plugins: [],
};

23
task.md Normal file
View File

@ -0,0 +1,23 @@
/loop 持续优化字体子集化性能和提高ssim评分可以大胆放开手脚的去做但是优化完一定要通过`pnpx tsx ./基准测试.test.ts`。中途不要切换到其他模式,比如计划模式也不要询问我,你直接做就行了,请你持续的去优化,不要去询问我,不要去中断,好吧
把基准测试结果文档保存在本地 benchmark_results/ 这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果benchmark_results/OPTIMIZATION_LOG.md这样我能方便看到你使用了哪些优化方法得到了什么样的优化效果。
=== 字体裁剪基准测试 ===
8个汉字: avg=23.6ms min=18.4ms max=37.2ms 输出=16,508 bytes ssim=1.0000
拉丁+数字: avg=16.4ms min=13.7ms max=18.2ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=59.4ms min=47.3ms max=76.5ms 输出=161,344 bytes ssim=1.0000
=== 一晚上优化后的 字体裁剪基准测试 ===
8个汉字: avg=7.7ms min=3.8ms max=20.7ms 输出=16,572 bytes ssim=1.0000
拉丁+数字: avg=3.2ms min=1.6ms max=6.6ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=11.7ms min=6.8ms max=21.7ms 输出=161,816 bytes ssim=1.0000
## 其他方向
woff2 格式是不是更优越可以新增这种格式吗然后ttf的也还支持但是默认使用这个
就是有一个纯前端的优化咱们提供的js SDK好像是通过定时器扫描的吧这当然是一种方式也是最省心的一种方式但是咱们是不是还可以考虑另外一种方式就是通过配置来启用定时扫描还是由用户自己的事件来触发甚至由用户直接传递文本这样的话对于首页上的demo来说可能会有更高的及时性响应

View File

@ -1,24 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

30
tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,16 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"esModuleInterop": true,
"baseUrl": "./",
"incremental": true
},
"exclude": ["node_modules", "dist"]
}
"files": [],
"compilerOptions": {},
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.scripts.json"
}
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": [
"ES2023","DOM"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"vite.config.ts",
"backend/*.ts",
"backend/**/*.ts",
]
}

11
tsconfig.scripts.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"esModuleInterop": true,
"strict": true
},
"include": ["scripts"]
}

View File

@ -1,18 +0,0 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended"],
"jsRules": {
"no-unused-expression": true
},
"rules": {
"quotemark": [true, "single"],
"member-access": [false],
"ordered-imports": [false],
"max-line-length": [true, 150],
"member-ordering": [false],
"interface-name": [false],
"arrow-parens": false,
"object-literal-sort-keys": false
},
"rulesDirectory": []
}

11
tsup.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["backend/app.ts", "基准测试_llrt.ts"],
splitting: false,
sourcemap: true,
clean: true,
bundle: true,
noExternal: [/.*/],
outDir: "dist_backend",
});

21
vendor/fonteditor-core/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 ecomfe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

200
vendor/fonteditor-core/README.md vendored Normal file
View File

@ -0,0 +1,200 @@
# fonteditor-core
**FontEditor core functions**
[![NPM version][npm-image]][npm-url]
[![Downloads][downloads-image]][npm-url]
## Feature
Read and write sfnt font like ttf, woff, woff2, eot, svg, otf.
- sfnt parse
- read, write, transform fonts
- ttf (read and write)
- woff (read and write)
- woff2 (read and write)
- eot (read and write)
- svg (read and write)
- otf (only read and convert to ttf)
- ttf glyph adjust
- svg to glyph
- ESM compatibility for modern bundlers (Webpack, Rollup, Vite, Next.js, etc.)
- TypeScript support with type definitions
## Usage
```javascript
// read font file
import {createFont} from 'fonteditor-core';
import fs from 'fs';
const buffer = fs.readFileSync('font.ttf');
// read font data, support format:
// - for ttf, otf, woff, woff2, support ArrayBuffer, Buffer
// - for svg, support string or Document(parsed svg)
const font = createFont(buffer, {
// support ttf, woff, woff2, eot, otf, svg
type: 'ttf',
// only read `a`, `b` glyphs
subset: [65, 66],
// read font hinting tables, default false
hinting: true,
// read font kerning tables, default false
kerning: true,
// transform ttf compound glyph to simple
compound2simple: true,
// inflate function for woff
inflate: undefined,
// for svg path
combinePath: false,
});
const fontObject = font.get();
console.log(Object.keys(fontObject));
/* => [ 'version',
'numTables',
'searchRenge',
'entrySelector',
'rengeShift',
'head',
'maxp',
'glyf',
'cmap',
'name',
'hhea',
'post',
'OS/2',
'fpgm',
'cvt',
'prep'
]
*/
// write font file
const buffer = font.write({
// support ttf, woff, woff2, eot, svg
type: 'woff',
// save font hinting tables, default false
hinting: false,
// save font kerning tables, default false
kerning: false,
// write glyf data when simple glyph has no contours, default false
writeZeroContoursGlyfData: false,
// deflate function for woff, eg. pako.deflate
deflate: undefined,
// for user to overwrite head.xMin, head.xMax, head.yMin, head.yMax, hhea etc.
support: {head: {}, hhea: {}}
});
fs.writeFileSync('font.woff', buffer);
// to base64 str
font.toBase64({
// support ttf, woff, woff2, eot, svg
type: 'ttf'
});
// optimize glyphs
font.optimize()
// compound2simple
font.compound2simple()
// sort glyphs
font.sort()
// find glyphs
const result = font.find({
unicode: [65]
});
const result = font.find({
filter: function (glyf) {
return glyf.name === 'icon'
}
});
// merge another font object
font.merge(font1, {
scale: 1
});
```
### Modern ES Module Usage
This library supports both CommonJS and ES Modules. For detailed information on using with modern bundlers, please see [ESM_USAGE.md](./ESM_USAGE.md).
```javascript
// ESM import
import fonteditorCore, { createFont, woff2 } from 'fonteditor-core';
createFont(buffer, options);
```
### woff2
**Notice:** woff2 use wasm build of google woff2, before read and write `woff2`, we should first call `woff2.init()`.
```javascript
import {createFont, woff2} from 'fonteditor-core';
// in nodejs
woff2.init().then(() => {
// read woff2
const font = createFont(buffer, {
type: 'woff2'
});
// write woff2
const buffer = font.write({type: 'woff2'});
});
// in browser
woff2.init('/assets/woff2.wasm').then(() => {
// read woff2
const font = createFont();
// write woff2
const arrayBuffer = font.write({type: 'woff2'});
});
```
## Demo
```
npm run dev
```
## build
```
npm run build
```
## test
```
npm run test
```
## support
Node.js:>= 12.0
Browser: Chrome, Safari
## Related
- [fonteditor](https://github.com/ecomfe/fonteditor)
- [fontmin](https://github.com/ecomfe/fontmin)
- [fonteditor online](https://kekee000.github.io/fonteditor/index.html)
## License
MIT © Fonteditor
[downloads-image]: http://img.shields.io/npm/dm/fonteditor-core.svg
[npm-url]: https://npmjs.org/package/fonteditor-core
[npm-image]: http://img.shields.io/npm/v/fonteditor-core.svg
[travis-url]: https://travis-ci.org/kekee000/fonteditor-core
[travis-image]: http://img.shields.io/travis/kekee000/fonteditor-core.svg

1249
vendor/fonteditor-core/index.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
/**
* @file DOM解析器兼容node端和浏览器端
* @author mengke01(kekee000@gmail.com)
*/
/* eslint-disable no-undef */
var _default = exports.default = typeof window !== 'undefined' && window.DOMParser ? window.DOMParser : require('@xmldom/xmldom').DOMParser;

View File

@ -0,0 +1,92 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
/**
* @file 用于国际化的字符串管理类
* @author mengke01(kekee000@gmail.com)
*/
function appendLanguage(store, languageList) {
languageList.forEach(function (item) {
var language = item[0];
store[language] = Object.assign(store[language] || {}, item[1]);
});
return store;
}
/**
* 管理国际化字符根据lang切换语言版本
*
* @class I18n
* @param {Array} languageList 当前支持的语言列表
* @param {string=} defaultLanguage 默认语言
* languageList = [
* 'en-us', // 语言名称
* langObject // 语言字符串列表
* ]
*/
var I18n = exports.default = /*#__PURE__*/function () {
function I18n(languageList, defaultLanguage) {
_classCallCheck(this, I18n);
this.store = appendLanguage({}, languageList);
this.setLanguage(defaultLanguage || typeof navigator !== 'undefined' && navigator.language && navigator.language.toLowerCase() || 'en-us');
}
/**
* 设置语言
*
* @param {string} language 语言
* @return {this}
*/
return _createClass(I18n, [{
key: "setLanguage",
value: function setLanguage(language) {
if (!this.store[language]) {
language = 'en-us';
}
this.lang = this.store[this.language = language];
return this;
}
/**
* 添加一个语言字符串
*
* @param {string} language 语言
* @param {Object} langObject 语言对象
* @return {this}
*/
}, {
key: "addLanguage",
value: function addLanguage(language, langObject) {
appendLanguage(this.store, [[language, langObject]]);
return this;
}
/**
* 获取当前语言字符串
*
* @param {string} path 语言路径
* @return {string} 语言字符串
*/
}, {
key: "get",
value: function get(path) {
var ref = path.split('.');
var refObject = this.lang;
var level;
while (refObject != null && (level = ref.shift())) {
refObject = refObject[level];
}
return refObject != null ? refObject : '';
}
}]);
}();

View File

@ -0,0 +1,80 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = ajaxFile;
exports.loadFile = loadFile;
/**
* @file ajax获取文本数据
* @author mengke01(kekee000@gmail.com)
*/
/**
* ajax获取数据
*
* @param {Object} options 参数选项
* @param {string=} options.type 类型
* @param {string=} options.method method
* @param {Function=} options.onSuccess 成功回调
* @param {Function=} options.onError 失败回调
* @param {Object=} options.params 参数集合
*/
function ajaxFile(options) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var status = xhr.status;
if (status >= 200 && status < 300 || status === 304) {
if (options.onSuccess) {
if (options.type === 'binary') {
var buffer = xhr.responseBlob || xhr.response;
options.onSuccess(buffer);
} else if (options.type === 'xml') {
options.onSuccess(xhr.responseXML);
} else if (options.type === 'json') {
options.onSuccess(JSON.parse(xhr.responseText));
} else {
options.onSuccess(xhr.responseText);
}
}
} else if (options.onError) {
options.onError(xhr, xhr.status);
}
}
};
var method = (options.method || 'GET').toUpperCase();
var params = null;
if (options.params) {
var str = [];
Object.keys(options.params).forEach(function (key) {
str.push(key + '=' + encodeURIComponent(options.params[key]));
});
str = str.join('&');
if (method === 'GET') {
options.url += (options.url.indexOf('?') === -1 ? '?' : '&') + str;
} else {
params = str;
}
}
xhr.open(method, options.url, true);
if (options.type === 'binary') {
xhr.responseType = 'arraybuffer';
}
xhr.send(params);
}
function loadFile(url) {
var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'binary';
return new Promise(function (resolve, reject) {
ajaxFile({
type: type,
url: url,
onSuccess: function onSuccess(buffer) {
resolve(buffer);
},
onError: function onError(e) {
reject(e);
}
});
});
}

View File

@ -0,0 +1,234 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.clone = clone;
exports.curry = curry;
exports.debounce = debounce;
exports.equals = equals;
exports.generic = generic;
exports.isArray = isArray;
exports.isDate = isDate;
exports.isEmptyObject = isEmptyObject;
exports.isFunction = isFunction;
exports.isObject = isObject;
exports.isString = isString;
exports.overwrite = overwrite;
exports.throttle = throttle;
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
/**
* @file 语言相关函数
* @author mengke01(kekee000@gmail.com)
*/
function isArray(obj) {
return obj != null && toString.call(obj).slice(8, -1) === 'Array';
}
function isObject(obj) {
return obj != null && toString.call(obj).slice(8, -1) === 'Object';
}
function isString(obj) {
return obj != null && toString.call(obj).slice(8, -1) === 'String';
}
function isFunction(obj) {
return obj != null && toString.call(obj).slice(8, -1) === 'Function';
}
function isDate(obj) {
return obj != null && toString.call(obj).slice(8, -1) === 'Date';
}
function isEmptyObject(object) {
for (var name in object) {
// eslint-disable-next-line no-prototype-builtins
if (object.hasOwnProperty(name)) {
return false;
}
}
return true;
}
/**
* 为函数提前绑定前置参数柯里化
*
* @see http://en.wikipedia.org/wiki/Currying
* @param {Function} fn 要绑定的函数
* @param {...Array} cargs cargs
* @return {Function}
*/
function curry(fn) {
for (var _len = arguments.length, cargs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
cargs[_key - 1] = arguments[_key];
}
return function () {
for (var _len2 = arguments.length, rargs = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
rargs[_key2] = arguments[_key2];
}
var args = cargs.concat(rargs);
// eslint-disable-next-line no-invalid-this
return fn.apply(this, args);
};
}
/**
* 方法静态化, 反绑定延迟绑定
*
* @param {Function} method 待静态化的方法
* @return {Function} 静态化包装后方法
*/
function generic(method) {
return function () {
for (var _len3 = arguments.length, fargs = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
fargs[_key3] = arguments[_key3];
}
return Function.call.apply(method, fargs);
};
}
/**
* 设置覆盖相关的属性值
*
* @param {Object} thisObj 覆盖对象
* @param {Object} thatObj 值对象
* @param {Array.<string>} fields 字段
* @return {Object} thisObj
*/
function overwrite(thisObj, thatObj, fields) {
if (!thatObj) {
return thisObj;
}
// 这里`fields`未指定则仅overwrite自身可枚举的字段指定`fields`则不做限制
fields = fields || Object.keys(thatObj);
fields.forEach(function (field) {
// 拷贝对象
if (thisObj[field] && _typeof(thisObj[field]) === 'object' && thatObj[field] && _typeof(thatObj[field]) === 'object') {
overwrite(thisObj[field], thatObj[field]);
} else {
thisObj[field] = thatObj[field];
}
});
return thisObj;
}
/**
* 深复制对象仅复制数据
*
* @param {Object} source 源数据
* @return {Object} 复制的数据
*/
function clone(source) {
if (!source || _typeof(source) !== 'object') {
return source;
}
var cloned = source;
if (isArray(source)) {
cloned = source.slice().map(clone);
} else if (isObject(source) && 'isPrototypeOf' in source) {
cloned = {};
for (var _i = 0, _Object$keys = Object.keys(source); _i < _Object$keys.length; _i++) {
var key = _Object$keys[_i];
cloned[key] = clone(source[key]);
}
}
return cloned;
}
// Returns a function, that, when invoked, will only be triggered at most once
// during a given window of time.
// @see underscore.js
function throttle(func, wait) {
var context;
var args;
var timeout;
var result;
var previous = 0;
var later = function later() {
previous = new Date();
timeout = null;
result = func.apply(context, args);
};
return function () {
var now = new Date();
var remaining = wait - (now - previous);
// eslint-disable-next-line no-invalid-this
context = this;
if (remaining <= 0) {
clearTimeout(timeout);
timeout = null;
previous = now;
for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) {
args[_key4] = arguments[_key4];
}
result = func.apply(context, args);
} else if (!timeout) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// @see underscore.js
function debounce(func, wait, immediate) {
var timeout;
var result;
return function () {
for (var _len5 = arguments.length, args = new Array(_len5), _key5 = 0; _key5 < _len5; _key5++) {
args[_key5] = arguments[_key5];
}
// eslint-disable-next-line no-invalid-this
var context = this;
var later = function later() {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
}
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
result = func.apply(context, args);
}
return result;
};
}
/**
* 判断两个对象的字段是否相等
*
* @param {Object} thisObj 要比较的对象
* @param {Object} thatObj 参考对象
* @param {Array} fields 指定字段
* @return {boolean} 是否相等
*/
function equals(thisObj, thatObj, fields) {
if (thisObj === thatObj) {
return true;
}
if (thisObj == null && thatObj == null) {
return true;
}
if (thisObj == null && thatObj != null || thisObj != null && thatObj == null) {
return false;
}
// 这里`fields`未指定则仅overwrite自身可枚举的字段指定`fields`则不做限制
fields = fields || (_typeof(thisObj) === 'object' ? Object.keys(thisObj) : []);
if (!fields.length) {
return thisObj === thatObj;
}
var equal = true;
for (var i = 0, l = fields.length, field; equal && i < l; i++) {
field = fields[i];
if (thisObj[field] && _typeof(thisObj[field]) === 'object' && thatObj[field] && _typeof(thatObj[field]) === 'object') {
equal = equal && equals(thisObj[field], thatObj[field]);
} else {
equal = equal && thisObj[field] === thatObj[field];
}
}
return equal;
}

View File

@ -0,0 +1,94 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
/**
* @file 字符串相关的函数
* @author mengke01(kekee000@gmail.com)
*/
var _default = exports.default = {
/**
* HTML解码字符串
*
* @param {string} source 源字符串
* @return {string}
*/
decodeHTML: function decodeHTML(source) {
var str = String(source).replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// 处理转义的中文和实体字符
return str.replace(/&#([\d]+);/g, function ($0, $1) {
return String.fromCodePoint(parseInt($1, 10));
});
},
/**
* HTML编码字符串
*
* @param {string} source 源字符串
* @return {string}
*/
encodeHTML: function encodeHTML(source) {
return String(source).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
},
/**
* 获取string字节长度
*
* @param {string} source 源字符串
* @return {number} 长度
*/
getLength: function getLength(source) {
// eslint-disable-next-line no-control-regex
return String(source).replace(/[^\x00-\xff]/g, '11').length;
},
/**
* 字符串格式化支持如 ${xxx.xxx} 的语法
*
* @param {string} source 模板字符串
* @param {Object} data 数据
* @return {string} 格式化后字符串
*/
format: function format(source, data) {
return source.replace(/\$\{([\w.]+)\}/g, function ($0, $1) {
var ref = $1.split('.');
var refObject = data;
var level;
while (refObject != null && (level = ref.shift())) {
refObject = refObject[level];
}
return refObject != null ? refObject : '';
});
},
/**
* 使用指定字符填充字符串,默认`0`
*
* @param {string} str 字符串
* @param {number} size 填充到的大小
* @param {string=} ch 填充字符
* @return {string} 字符串
*/
pad: function pad(str, size, ch) {
str = String(str);
if (str.length > size) {
return str.slice(str.length - size);
}
return new Array(size - str.length + 1).join(ch || '0') + str;
},
/**
* 获取字符串哈希编码
*
* @param {string} str 字符串
* @return {number} 哈希值
*/
hashcode: function hashcode(str) {
if (!str) {
return 0;
}
var hash = 0;
for (var i = 0, l = str.length; i < l; i++) {
hash = 0x7FFFFFFFF & hash * 31 + str.charCodeAt(i);
}
return hash;
}
};

View File

@ -0,0 +1,165 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.computePath = exports.computeBounding = void 0;
exports.computePathBox = computePathBox;
exports.quadraticBezier = void 0;
var _pathIterator = _interopRequireDefault(require("./pathIterator"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/**
* @file 计算曲线包围盒
* @author mengke01(kekee000@gmail.com)
*
* modify from:
* zrender
* https://github.com/ecomfe/zrender/blob/master/src/tool/computeBoundingBox.js
*/
/**
* 计算包围盒
*
* @param {Array} points 点集
* @return {Object} bounding box
*/
function computeBoundingBox(points) {
if (points.length === 0) {
return false;
}
var left = points[0].x;
var right = points[0].x;
var top = points[0].y;
var bottom = points[0].y;
for (var i = 1; i < points.length; i++) {
var p = points[i];
if (p.x < left) {
left = p.x;
}
if (p.x > right) {
right = p.x;
}
if (p.y < top) {
top = p.y;
}
if (p.y > bottom) {
bottom = p.y;
}
}
return {
x: left,
y: top,
width: right - left,
height: bottom - top
};
}
/**
* 计算二阶贝塞尔曲线的包围盒
* http://pissang.net/blog/?p=91
*
* @param {Object} p0 p0
* @param {Object} p1 p1
* @param {Object} p2 p2
* @return {Object} bound对象
*/
function computeQuadraticBezierBoundingBox(p0, p1, p2) {
// Find extremities, where derivative in x dim or y dim is zero
var tmp = p0.x + p2.x - 2 * p1.x;
// p1 is center of p0 and p2 in x dim
var t1;
if (tmp === 0) {
t1 = 0.5;
} else {
t1 = (p0.x - p1.x) / tmp;
}
tmp = p0.y + p2.y - 2 * p1.y;
// p1 is center of p0 and p2 in y dim
var t2;
if (tmp === 0) {
t2 = 0.5;
} else {
t2 = (p0.y - p1.y) / tmp;
}
t1 = Math.max(Math.min(t1, 1), 0);
t2 = Math.max(Math.min(t2, 1), 0);
var ct1 = 1 - t1;
var ct2 = 1 - t2;
var x1 = ct1 * ct1 * p0.x + 2 * ct1 * t1 * p1.x + t1 * t1 * p2.x;
var y1 = ct1 * ct1 * p0.y + 2 * ct1 * t1 * p1.y + t1 * t1 * p2.y;
var x2 = ct2 * ct2 * p0.x + 2 * ct2 * t2 * p1.x + t2 * t2 * p2.x;
var y2 = ct2 * ct2 * p0.y + 2 * ct2 * t2 * p1.y + t2 * t2 * p2.y;
return computeBoundingBox([p0, p2, {
x: x1,
y: y1
}, {
x: x2,
y: y2
}]);
}
/**
* 计算曲线包围盒
*
* @private
* @param {...Array} args 坐标点集, 支持多个path
* @return {Object} {x, y, width, height}
*/
function computePathBoundingBox() {
var points = [];
var iterator = function iterator(c, p0, p1, p2) {
if (c === 'L') {
points.push(p0);
points.push(p1);
} else if (c === 'Q') {
var bound = computeQuadraticBezierBoundingBox(p0, p1, p2);
points.push(bound);
points.push({
x: bound.x + bound.width,
y: bound.y + bound.height
});
}
};
if (arguments.length === 1) {
(0, _pathIterator.default)(arguments.length <= 0 ? undefined : arguments[0], function (c, p0, p1, p2) {
if (c === 'L') {
points.push(p0);
points.push(p1);
} else if (c === 'Q') {
var bound = computeQuadraticBezierBoundingBox(p0, p1, p2);
points.push(bound);
points.push({
x: bound.x + bound.width,
y: bound.y + bound.height
});
}
});
} else {
for (var i = 0, l = arguments.length; i < l; i++) {
(0, _pathIterator.default)(i < 0 || arguments.length <= i ? undefined : arguments[i], iterator);
}
}
return computeBoundingBox(points);
}
/**
* 计算曲线点边界
*
* @private
* @param {...Array} args path对象, 支持多个path
* @return {Object} {x, y, width, height}
*/
function computePathBox() {
var points = [];
if (arguments.length === 1) {
points = arguments.length <= 0 ? undefined : arguments[0];
} else {
for (var i = 0, l = arguments.length; i < l; i++) {
Array.prototype.splice.apply(points, [points.length, 0].concat(i < 0 || arguments.length <= i ? undefined : arguments[i]));
}
}
return computeBoundingBox(points);
}
var computeBounding = exports.computeBounding = computeBoundingBox;
var quadraticBezier = exports.quadraticBezier = computeQuadraticBezierBoundingBox;
var computePath = exports.computePath = computePathBoundingBox;

View File

@ -0,0 +1,223 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = getArc;
var _bezierCubic2Q = _interopRequireDefault(require("../math/bezierCubic2Q2"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/**
* @file 使用插值法获取椭圆弧度以支持svg arc命令
* @author mengke01(kekee000@gmail.com)
*
* modify from:
* https://github.com/fontello/svgpath/blob/master/lib/a2c.js
* references:
* http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
*/
var TAU = Math.PI * 2;
function vectorAngle(ux, uy, vx, vy) {
// Calculate an angle between two vectors
var sign = ux * vy - uy * vx < 0 ? -1 : 1;
var umag = Math.sqrt(ux * ux + uy * uy);
var vmag = Math.sqrt(ux * ux + uy * uy);
var dot = ux * vx + uy * vy;
var div = dot / (umag * vmag);
if (div > 1 || div < -1) {
// rounding errors, e.g. -1.0000000000000002 can screw up this
div = Math.max(div, -1);
div = Math.min(div, 1);
}
return sign * Math.acos(div);
}
function correctRadii(midx, midy, rx, ry) {
// Correction of out-of-range radii
rx = Math.abs(rx);
ry = Math.abs(ry);
var Λ = midx * midx / (rx * rx) + midy * midy / (ry * ry);
if (Λ > 1) {
rx *= Math.sqrt(Λ);
ry *= Math.sqrt(Λ);
}
return [rx, ry];
}
function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sin, cos) {
// Convert from endpoint to center parameterization,
// see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
// Step 1.
//
// Moving an ellipse so origin will be the middlepoint between our two
// points. After that, rotate it to line up ellipse axes with coordinate
// axes.
//
var x1p = cos * (x1 - x2) / 2 + sin * (y1 - y2) / 2;
var y1p = -sin * (x1 - x2) / 2 + cos * (y1 - y2) / 2;
var rx_sq = rx * rx;
var ry_sq = ry * ry;
var x1p_sq = x1p * x1p;
var y1p_sq = y1p * y1p;
// Step 2.
//
// Compute coordinates of the centre of this ellipse (cx', cy')
// in the new coordinate system.
//
var radicant = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq;
if (radicant < 0) {
// due to rounding errors it might be e.g. -1.3877787807814457e-17
radicant = 0;
}
radicant /= rx_sq * y1p_sq + ry_sq * x1p_sq;
radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1);
var cxp = radicant * rx / ry * y1p;
var cyp = radicant * -ry / rx * x1p;
// Step 3.
//
// Transform back to get centre coordinates (cx, cy) in the original
// coordinate system.
//
var cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
var cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
// Step 4.
//
// Compute angles (θ1, Δθ).
//
var v1x = (x1p - cxp) / rx;
var v1y = (y1p - cyp) / ry;
var v2x = (-x1p - cxp) / rx;
var v2y = (-y1p - cyp) / ry;
var θ1 = vectorAngle(1, 0, v1x, v1y);
var Δθ = vectorAngle(v1x, v1y, v2x, v2y);
if (fs === 0 && Δθ > 0) {
Δθ -= TAU;
}
if (fs === 1 && Δθ < 0) {
Δθ += TAU;
}
return [cx, cy, θ1, Δθ];
}
function approximateUnitArc(θ1, Δθ) {
// Approximate one unit arc segment with bézier curves,
// see http://math.stackexchange.com/questions/873224/
// calculate-control-points-of-cubic-bezier-curve-approximating-a-part-of-a-circle
var α = 4 / 3 * Math.tan(Δθ / 4);
var x1 = Math.cos(θ1);
var y1 = Math.sin(θ1);
var x2 = Math.cos(θ1 + Δθ);
var y2 = Math.sin(θ1 + Δθ);
return [x1, y1, x1 - y1 * α, y1 + x1 * α, x2 + y2 * α, y2 - x2 * α, x2, y2];
}
function a2c(x1, y1, x2, y2, fa, fs, rx, ry, φ) {
var sin = Math.sin(φ * TAU / 360);
var cos = Math.cos(φ * TAU / 360);
// Make sure radii are valid
//
var x1p = cos * (x1 - x2) / 2 + sin * (y1 - y2) / 2;
var y1p = -sin * (x1 - x2) / 2 + cos * (y1 - y2) / 2;
if (x1p === 0 && y1p === 0) {
// we're asked to draw line to itself
return [];
}
if (rx === 0 || ry === 0) {
// one of the radii is zero
return [];
}
var radii = correctRadii(x1p, y1p, rx, ry);
rx = radii[0];
ry = radii[1];
// Get center parameters (cx, cy, θ1, Δθ)
//
var cc = getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sin, cos);
var result = [];
var θ1 = cc[2];
var Δθ = cc[3];
// Split an arc to multiple segments, so each segment
// will be less than τ/4 (= 90°)
//
var segments = Math.max(Math.ceil(Math.abs(Δθ) / (TAU / 4)), 1);
Δθ /= segments;
for (var i = 0; i < segments; i++) {
result.push(approximateUnitArc(θ1, Δθ));
θ1 += Δθ;
}
// We have a bezier approximation of a unit circle,
// now need to transform back to the original ellipse
//
return result.map(function (curve) {
for (var _i = 0; _i < curve.length; _i += 2) {
var x = curve[_i + 0];
var y = curve[_i + 1];
// scale
x *= rx;
y *= ry;
// rotate
var xp = cos * x - sin * y;
var yp = sin * x + cos * y;
// translate
curve[_i + 0] = xp + cc[0];
curve[_i + 1] = yp + cc[1];
}
return curve;
});
}
/**
* 获取椭圆弧度
*
* @param {number} rx 椭圆长半轴
* @param {number} ry 椭圆短半轴
* @param {number} angle 旋转角度
* @param {number} largeArc 是否大圆弧
* @param {number} sweep 是否延伸圆弧
* @param {Object} p0 分割点1
* @param {Object} p1 分割点2
* @return {Array} 分割后的路径
*/
function getArc(rx, ry, angle, largeArc, sweep, p0, p1) {
var result = a2c(p0.x, p0.y, p1.x, p1.y, largeArc, sweep, rx, ry, angle);
var path = [];
if (result.length) {
path.push({
x: result[0][0],
y: result[0][1],
onCurve: true
});
// 将三次曲线转换成二次曲线
result.forEach(function (c) {
var q2Array = (0, _bezierCubic2Q.default)({
x: c[0],
y: c[1]
}, {
x: c[2],
y: c[3]
}, {
x: c[4],
y: c[5]
}, {
x: c[6],
y: c[7]
});
q2Array[0][2].onCurve = true;
path.push(q2Array[0][1]);
path.push(q2Array[0][2]);
if (q2Array[1]) {
q2Array[1][2].onCurve = true;
path.push(q2Array[1][1]);
path.push(q2Array[1][2]);
}
});
}
return path;
}

View File

@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.mul = mul;
exports.multiply = multiply;
/**
* @file matrix变换操作
* @author mengke01(kekee000@gmail.com)
*/
/**
* 仿射矩阵相乘
*
* @param {Array=} matrix1 矩阵1
* @param {Array=} matrix2 矩阵2
* @return {Array} 新矩阵
*/
function mul() {
var matrix1 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [1, 0, 0, 1];
var matrix2 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [1, 0, 0, 1];
// 旋转变换 4 个参数
if (matrix1.length === 4) {
return [matrix1[0] * matrix2[0] + matrix1[2] * matrix2[1], matrix1[1] * matrix2[0] + matrix1[3] * matrix2[1], matrix1[0] * matrix2[2] + matrix1[2] * matrix2[3], matrix1[1] * matrix2[2] + matrix1[3] * matrix2[3]];
}
// 旋转位移变换, 6 个参数
return [matrix1[0] * matrix2[0] + matrix1[2] * matrix2[1], matrix1[1] * matrix2[0] + matrix1[3] * matrix2[1], matrix1[0] * matrix2[2] + matrix1[2] * matrix2[3], matrix1[1] * matrix2[2] + matrix1[3] * matrix2[3], matrix1[0] * matrix2[4] + matrix1[2] * matrix2[5] + matrix1[4], matrix1[1] * matrix2[4] + matrix1[3] * matrix2[5] + matrix1[5]];
}
/**
* 多个仿射矩阵相乘
*
* @param {...Array} matrixs matrix array
* @return {Array} 新矩阵
*/
function multiply() {
var result = arguments.length <= 0 ? undefined : arguments[0];
for (var i = 1, matrix; matrix = i < 0 || arguments.length <= i ? undefined : arguments[i]; i++) {
result = mul(result, matrix);
}
return result;
}

View File

@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
/**
* @file 圆路径集合逆时针
* @author mengke01(kekee000@gmail.com)
*/
var _default = exports.default = [{
x: 582,
y: 0
}, {
x: 758,
y: 75
}, {
x: 890,
y: 208
}, {
x: 965,
y: 384
}, {
x: 965,
y: 583
}, {
x: 890,
y: 760
}, {
x: 758,
y: 891
}, {
x: 582,
y: 966
}, {
x: 383,
y: 966
}, {
x: 207,
y: 891
}, {
x: 75,
y: 760
}, {
x: 0,
y: 583
}, {
x: 0,
y: 384
}, {
x: 75,
y: 208
}, {
x: 207,
y: 75
}, {
x: 383,
y: 0
}];

View File

@ -0,0 +1,35 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathAdjust;
/**
* @file 调整路径缩放和平移
* @author mengke01(kekee000@gmail.com)
*/
/**
* 对path坐标进行调整
*
* @param {Object} contour 坐标点
* @param {number} scaleX x缩放比例
* @param {number} scaleY y缩放比例
* @param {number} offsetX x偏移
* @param {number} offsetY y偏移
*
* @return {Object} contour 坐标点
*/
function pathAdjust(contour, scaleX, scaleY, offsetX, offsetY) {
scaleX = scaleX === undefined ? 1 : scaleX;
scaleY = scaleY === undefined ? 1 : scaleY;
var x = offsetX || 0;
var y = offsetY || 0;
var p;
for (var i = 0, l = contour.length; i < l; i++) {
p = contour[i];
p.x = scaleX * (p.x + x);
p.y = scaleY * (p.y + y);
}
return contour;
}

View File

@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathCeil;
/**
* @file 对路径进行四舍五入
* @author mengke01(kekee000@gmail.com)
*/
/**
* 对path坐标进行调整
*
* @param {Array} contour 轮廓点数组对象格式或扁平格式 [x, y, onCurve, ...]
* @param {number} point 四舍五入的点数
* @return {Object} contour 坐标点
*/
function pathCeil(contour, point) {
if (!contour.length) {
return contour;
}
/* 优化66: 检测扁平格式 - 第一个元素是 number 则为扁平格式 */
if (typeof contour[0] === 'number') {
for (var i = 0, l = contour.length; i < l; i += 3) {
if (!point) {
contour[i] = Math.round(contour[i]);
contour[i + 1] = Math.round(contour[i + 1]);
} else {
contour[i] = Number(contour[i].toFixed(point));
contour[i + 1] = Number(contour[i + 1].toFixed(point));
}
}
} else {
for (var i = 0, l = contour.length; i < l; i++) {
var p = contour[i];
if (!point) {
p.x = Math.round(p.x);
p.y = Math.round(p.y);
} else {
p.x = Number(p.x.toFixed(point));
p.y = Number(p.y.toFixed(point));
}
}
}
return contour;
}

View File

@ -0,0 +1,70 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathIterator;
/**
* @file 遍历路径的路径集合包括segment和 bezier curve
* @author mengke01(kekee000@gmail.com)
*/
/**
* 遍历路径的路径集合
*
* @param {Array} contour 坐标点集
* @param {Function} callBack 回调函数参数集合command, p0, p1, p2, i
* p0, p1, p2 直线或者贝塞尔曲线参数
* i 当前遍历的点
* 其中command = L 或者 Q表示直线或者贝塞尔曲线
*/
function pathIterator(contour, callBack) {
var curPoint;
var prevPoint;
var nextPoint;
var cursorPoint; // cursorPoint 为当前单个绘制命令的起点
for (var i = 0, l = contour.length; i < l; i++) {
curPoint = contour[i];
prevPoint = i === 0 ? contour[l - 1] : contour[i - 1];
nextPoint = i === l - 1 ? contour[0] : contour[i + 1];
// 起始坐标
if (i === 0) {
if (curPoint.onCurve) {
cursorPoint = curPoint;
} else if (prevPoint.onCurve) {
cursorPoint = prevPoint;
} else {
cursorPoint = {
x: (prevPoint.x + curPoint.x) / 2,
y: (prevPoint.y + curPoint.y) / 2
};
}
}
// 直线
if (curPoint.onCurve && nextPoint.onCurve) {
if (false === callBack('L', curPoint, nextPoint, 0, i)) {
break;
}
cursorPoint = nextPoint;
} else if (!curPoint.onCurve) {
if (nextPoint.onCurve) {
if (false === callBack('Q', cursorPoint, curPoint, nextPoint, i)) {
break;
}
cursorPoint = nextPoint;
} else {
var last = {
x: (curPoint.x + nextPoint.x) / 2,
y: (curPoint.y + nextPoint.y) / 2
};
if (false === callBack('Q', cursorPoint, curPoint, last, i)) {
break;
}
cursorPoint = last;
}
}
}
}

View File

@ -0,0 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathRotate;
/**
* @file 路径旋转
* @author mengke01(kekee000@gmail.com)
*/
/**
* 对path坐标进行调整
*
* @param {Object} contour 坐标点
* @param {number} angle 角度
* @param {number} centerX x偏移
* @param {number} centerY y偏移
*
* @return {Object} contour 坐标点
*/
function pathRotate(contour, angle, centerX, centerY) {
angle = angle === undefined ? 0 : angle;
var x = centerX || 0;
var y = centerY || 0;
var cos = Math.cos(angle);
var sin = Math.sin(angle);
var px;
var py;
var p;
// x1=cos(angle)*x-sin(angle)*y;
// y1=cos(angle)*y+sin(angle)*x;
for (var i = 0, l = contour.length; i < l; i++) {
p = contour[i];
px = cos * (p.x - x) - sin * (p.y - y);
py = cos * (p.y - y) + sin * (p.x - x);
p.x = px + x;
p.y = py + y;
}
return contour;
}

View File

@ -0,0 +1,45 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathSkew;
/**
* @file path倾斜变换
* @author mengke01(kekee000@gmail.com)
*/
/**
* path倾斜变换
*
* @param {Object} contour 坐标点
* @param {number} angle 角度
* @param {number} offsetX x偏移
* @param {number} offsetY y偏移
*
* @return {Object} contour 坐标点
*/
function pathSkew(contour, angle, offsetX, offsetY) {
angle = angle === undefined ? 0 : angle;
var x = offsetX || 0;
var tan = Math.tan(angle);
var p;
var i;
var l;
// x 平移
if (x === 0) {
for (i = 0, l = contour.length; i < l; i++) {
p = contour[i];
p.x += tan * (p.y - offsetY);
}
}
// y平移
else {
for (i = 0, l = contour.length; i < l; i++) {
p = contour[i];
p.y += tan * (p.x - offsetX);
}
}
return contour;
}

View File

@ -0,0 +1,32 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathSkewX;
var _computeBoundingBox = require("./computeBoundingBox");
/**
* @file 按X轴平移变换, 变换中心为图像中心点
* @author mengke01(kekee000@gmail.com)
*/
/**
* path倾斜变换
*
* @param {Object} contour 坐标点
* @param {number} angle 角度
*
* @return {Object} contour 坐标点
*/
function pathSkewX(contour, angle) {
angle = angle === undefined ? 0 : angle;
var y = (0, _computeBoundingBox.computePath)(contour).y;
var tan = Math.tan(angle);
var p;
// x 平移
for (var i = 0, l = contour.length; i < l; i++) {
p = contour[i];
p.x += tan * (p.y - y);
}
return contour;
}

View File

@ -0,0 +1,32 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = pathSkewY;
var _computeBoundingBox = require("./computeBoundingBox");
/**
* @file 按Y轴平移变换, 变换中心为图像中心点
* @author mengke01(kekee000@gmail.com)
*/
/**
* path倾斜变换
*
* @param {Object} contour 坐标点
* @param {number} angle 角度
*
* @return {Object} contour 坐标点
*/
function pathSkewY(contour, angle) {
angle = angle === undefined ? 0 : angle;
var x = (0, _computeBoundingBox.computePath)(contour).x;
var tan = Math.tan(angle);
var p;
// y 平移
for (var i = 0, l = contour.length; i < l; i++) {
p = contour[i];
p.y += tan * (p.x - x);
}
return contour;
}

Some files were not shown because too many files have changed in this diff Show More