Compare commits
No commits in common. "webfont@1.5.0" and "master" have entirely different histories.
webfont@1.
...
master
52
.gitignore
vendored
@ -1,37 +1,39 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# OS
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
llrt
|
||||
font/*
|
||||
tjs
|
||||
app
|
||||
*.tar
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
dist_backend
|
||||
.pilot
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
verify_font_baseline
|
||||
benchmark_results
|
||||
.claude
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
.cache
|
||||
dist
|
||||
asset/font
|
||||
asset/dynamically
|
||||
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"deno.enable": false
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
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 前缀模糊匹配)。
|
||||
@ -1,6 +0,0 @@
|
||||
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
@ -1,97 +1,104 @@
|
||||
# web font 字体裁剪工具
|
||||
|
||||
之前的版本请查看 master 分支,为了能使用 llrt ,我进行了重写,之后只维护此分支
|
||||
|
||||

|
||||
|
||||
上面的内存占用是空载状态下,在执行字体裁剪时会将字体加载到内存中,所以会占用更多的内存,不过 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.裁剪字体包使其仅包含选中的字体,其体积自然十分之小
|
||||
1.裁剪字体包使其仅包含选中的字体
|
||||
|
||||
例如 如下图生成的字体包仅包含 「天地无极乾坤借法」
|
||||

|
||||
|
||||
<video src="./doc_img/功能演示.mkv" controls="controls" width:100% height:auto></video>
|
||||
|
||||
其体积自然十分之小
|
||||
|
||||

|
||||
|
||||
2.另外可以生成 css 直接复制可用,部署在公网便可永久访问
|
||||
3.支持字体文件上传(临时上传和管理员上传两种模式)
|
||||
4.支持下载裁剪后的字体文件
|
||||
5.字体名称支持模糊匹配(精确 > 前缀 > 包含)
|
||||
|
||||
例如
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 使用 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 很小的包体积 
|
||||
|
||||
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
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
其中 font 目录替换成你的字体文件存放目录
|
||||
3.将 ttf 的字体包放置在 ./asset/font_src/ 目录下自然可以检测到新的可用字体,无需重启服务
|
||||
|
||||

|
||||
|
||||
4.提供 zip 的整体下载方案
|
||||
|
||||

|
||||
|
||||
## 提供的服务
|
||||
|
||||
### API 接口
|
||||
### 查询可用字体列表
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `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>`) |
|
||||

|
||||
|
||||
### 上传功能
|
||||
### 生成压缩字体包
|
||||
|
||||
- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件(`TEMP_MAX_FILES`),总大小限制 200MB(`TEMP_MAX_TOTAL_SIZE`),超出后自动删除最早上传的(FIFO)
|
||||
- **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证
|
||||
- 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2`
|
||||

|
||||
|
||||
如图可见每个返回的字体资源,访问即可下载。另外在访问该目录下的 asset.zip 可以直接下载全部的文件,生成的资源目录结构见下图
|
||||
|
||||

|
||||
|
||||
注意,此接口是还支持 post 方式访问的,这样可以一次请求多个类型的字体文件,而且不会如同 get 方法那样有长度限制
|
||||
|
||||
|
||||

|
||||
|
||||
### 动态生成字体
|
||||
|
||||

|
||||
|
||||
#### 请注意
|
||||
|
||||
只支持生成 .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
|
||||
|
||||
## 鸣谢
|
||||
|
||||
[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
|
||||
|
||||
|
||||
BIN
asset/font_src/Alibaba-PuHuiTi-Heavy.ttf
Normal file
BIN
asset/font_src/令东齐伋复刻体.ttf
Normal file
BIN
asset/font_src/优设标题黑.ttf
Normal file
BIN
asset/font_src/问藏书房.ttf
Normal file
394
backend/app.ts
@ -1,394 +0,0 @@
|
||||
/** 解析请求 URL(req.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();
|
||||
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 从环境变量读取服务配置,启动时一次性加载
|
||||
*/
|
||||
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;
|
||||
@ -1,77 +0,0 @@
|
||||
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=true,sortGlyf 会直接返回
|
||||
*/
|
||||
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);
|
||||
};
|
||||
@ -1,67 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 轻量 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 +0,0 @@
|
||||
import "web-streams-polyfill/polyfill";
|
||||
@ -1,16 +0,0 @@
|
||||
// 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",
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@ -1,10 +0,0 @@
|
||||
// 请求和响应模型
|
||||
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>;
|
||||
@ -1,300 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// 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
|
||||
@ -1,129 +0,0 @@
|
||||
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
|
Before Width: | Height: | Size: 94 KiB |
BIN
doc/启动内存占用.png
|
Before Width: | Height: | Size: 21 KiB |
BIN
doc_img/api/font_list.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
doc_img/api/fontmin.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
doc_img/api/fontmin_post.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
doc_img/api/generate_fonts_dynamically.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
doc_img/下载展示.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
doc_img/体积展示.jpg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
doc_img/功能演示.mkv
Normal file
BIN
doc_img/生成的资源.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
doc_img/路径展示.jpg
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
doc_img/页面截图.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
15
index.html
@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
4
nest-cli.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
14099
package-lock.json
generated
Normal file
99
package.json
@ -1,36 +1,77 @@
|
||||
{
|
||||
"name": "webfont",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.9.12",
|
||||
"web-streams-polyfill": "^4.2.0"
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
"@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"
|
||||
}
|
||||
|
||||
3282
pnpm-lock.yaml
generated
@ -1 +0,0 @@
|
||||
approveBuilds: puppeteer
|
||||
23
postcss.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
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 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,390 +0,0 @@
|
||||
/**
|
||||
* WebFont SDK — 按需增量加载字体片段,无闪烁
|
||||
*
|
||||
* 架构:核心增量引擎 + 多种触发方式
|
||||
* - 核心:FontLoader 按 fontKey 管理已加载字符集,只生成增量 CSS
|
||||
* - 触发器:loadFont(轮询)、observeFont(DOM 事件)、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
|
||||
};
|
||||
})();
|
||||
@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 构建后端脚本
|
||||
* 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);
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
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));
|
||||
@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 同时启动前端 (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);
|
||||
@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 字体裁剪验证 — 裁剪多种字体并保存为文件
|
||||
* 运行: 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
@ -1,312 +0,0 @@
|
||||
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;
|
||||
@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,224 +0,0 @@
|
||||
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
@ -1,46 +0,0 @@
|
||||
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();
|
||||
}
|
||||
22
src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/app.controller.ts
Normal file
@ -0,0 +1,85 @@
|
||||
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>[]) {}
|
||||
21
src/app.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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 {}
|
||||
142
src/app.service.ts
Normal file
@ -0,0 +1,142 @@
|
||||
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 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
3
src/config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const config={
|
||||
web_font_path:"//127.0.0.1:3000/"
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web'
|
||||
|
||||
import App from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(() => <App />, root!)
|
||||
15
src/main.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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();
|
||||
|
||||
26
src/middleware/logger.middleware.ts
Normal file
@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
src/req.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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
@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
140
static/App.svelte
Normal file
@ -0,0 +1,140 @@
|
||||
<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}
|
||||
|
||||
|
||||
7
static/app.css
Normal file
@ -0,0 +1,7 @@
|
||||
.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;
|
||||
}
|
||||
3
static/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
19
static/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!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>
|
||||
6
static/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
// import '@babel/polyfill';
|
||||
import App from './App.svelte';
|
||||
import "./app.css";
|
||||
new App({
|
||||
target: document.querySelector('.c-app'),
|
||||
});
|
||||
49
static/req.ts
Normal file
@ -0,0 +1,49 @@
|
||||
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
Normal file
@ -0,0 +1,3 @@
|
||||
declare module '*.svelte' {
|
||||
export default any;
|
||||
}
|
||||
6
tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
theme: {
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
};
|
||||
23
task.md
@ -1,23 +0,0 @@
|
||||
/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来说,可能会有更高的及时性响应
|
||||
24
test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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!');
|
||||
});
|
||||
});
|
||||
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@ -1,15 +1,16 @@
|
||||
{
|
||||
"files": [],
|
||||
"compilerOptions": {},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.scripts.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
"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"]
|
||||
}
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["scripts"]
|
||||
}
|
||||
18
tslint.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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
@ -1,21 +0,0 @@
|
||||
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
@ -1,200 +0,0 @@
|
||||
# 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
12
vendor/fonteditor-core/lib/common/DOMParser.js
vendored
@ -1,12 +0,0 @@
|
||||
"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;
|
||||
92
vendor/fonteditor-core/lib/common/I18n.js
vendored
@ -1,92 +0,0 @@
|
||||
"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 : '';
|
||||
}
|
||||
}]);
|
||||
}();
|
||||
80
vendor/fonteditor-core/lib/common/ajaxFile.js
vendored
@ -1,80 +0,0 @@
|
||||
"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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
234
vendor/fonteditor-core/lib/common/lang.js
vendored
@ -1,234 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
94
vendor/fonteditor-core/lib/common/string.js
vendored
@ -1,94 +0,0 @@
|
||||
"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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
},
|
||||
/**
|
||||
* 获取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;
|
||||
}
|
||||
};
|
||||
@ -1,165 +0,0 @@
|
||||
"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;
|
||||
223
vendor/fonteditor-core/lib/graphics/getArc.js
vendored
@ -1,223 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
44
vendor/fonteditor-core/lib/graphics/matrix.js
vendored
@ -1,44 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
"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
|
||||
}];
|
||||
@ -1,35 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
47
vendor/fonteditor-core/lib/graphics/pathCeil.js
vendored
@ -1,47 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
45
vendor/fonteditor-core/lib/graphics/pathSkew.js
vendored
@ -1,45 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
32
vendor/fonteditor-core/lib/graphics/pathSkewX.js
vendored
@ -1,32 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
32
vendor/fonteditor-core/lib/graphics/pathSkewY.js
vendored
@ -1,32 +0,0 @@
|
||||
"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;
|
||||
}
|
||||