系统架构与技术栈
endfield-docmaker(终末地文档生成器)是一个纯前端、零后端的静态 Web 应用:用户在浏览器表单中填写内容,由 WebAssembly 版 Typst 排版引擎在浏览器内实时编译生成 PDF,全程不依赖任何服务端计算。本文是整个仓库的顶层架构总览,说明系统分层、技术选型、核心数据流、数据模型与构建部署方式。
目的与范围
本页覆盖以下内容:
- endfield-docmaker 的整体架构分层与组件关系
- 完整技术栈选型及版本(前端框架、排版引擎、UI 库、样式、国际化、构建工具)
- 从表单输入到 PDF 导出的端到端核心数据流
- 浏览器端持久化模型(IndexedDB 三张对象仓库)
- 构建工具链与静态部署架构
以下主题有意留给兄弟页面,本页仅作指引:
- 模板系统与 Typst 模板源文件(
official-doc.typ红头文件、tuzhang.typ圆形公章等)——属于模板实现细节 - Typst WASM 编译器的初始化与编译内部机制(
src/lib/typst.svelte.ts)——本页仅描述其在架构中的位置 - i18n 文案管理流程(
scripts/manage-messages.ts、messages/*.json)——属于国际化专题 - 具体 stores 的读写实现(
src/lib/stores/files.ts、fonts.ts、docs.ts、cloud.ts)——属于持久化专题
概述
该项目的目标是生成《明日方舟:终末地》游戏世界观中各机构签发的文档(红头公文、试卷等)。其最关键的架构决策是:把排版引擎整体搬到浏览器里运行。
传统做法需要一个后端服务接收表单数据、调用 Typst/LaTeX 编译、再把 PDF 返回给浏览器。本项目通过以下组合彻底消除了后端:
- Typst 以 WebAssembly 形式运行:
@myriaddreamin/typst.ts配合typst-ts-web-compiler(编译器)与typst-ts-renderer(渲染器)在浏览器主线程/Worker 中完成排版 - 字体与用户资产缓存在 IndexedDB:避免每次会话重复下载体积较大的 CJK 字体
- 静态站点部署:
@sveltejs/adapter-static产出纯静态文件,可直接托管在 GitHub Pages / Cloudflare / Vercel
由此带来的直接收益:零服务器成本、无隐私外泄(用户数据不出浏览器)、可离线使用、天然水平扩展。代价则是首屏需加载 WASM 编译器与字体,浏览器端存储配额成为容量边界(详见"故障模式与边界")。
应用的主要能力(摘自 README)包括:
- 在浏览器中实时生成 PDF 文件,无需后端服务
- 支持多个模板,每个模板分别支持数个自定义参数
- 试卷模板自动根据单题分数计算板块总分与整卷总分
- 公文模板自动生成带有随机偏移和旋转的圆形公章
- 基于 Typst 排版引擎,内容区域支持 Typst 标记语法
- PDF 实时预览、在新标签页中打开、下载
- 表单内容自动持久化至浏览器本地存储;字体文件自动缓存至 IndexedDB
Source: README.md
总体架构
架构分层说明
上图展示了五个清晰的层次,自上而下依次为:
| 层次 | 实际载体 | 职责 |
|---|---|---|
| 路由层 | src/routes/+page.svelte、+layout.svelte、+layout.ts | 承载"表单 + PDF 预览"主页面与根布局,是所有用户交互的入口 |
| 组件层 | src/lib/components/(ui/ 子目录 + DateInput、Footer、LocaleSwitch、ThemeToggle) | shadcn-svelte 风格的可复用 UI 原子组件与业务组件 |
| 状态与存储层 | src/lib/stores/(db.ts、files.ts、fonts.ts、docs.ts、cloud.ts) | 封装 IndexedDB 访问、字体缓存、文档库、用户资产 |
| 业务逻辑层 | src/lib/hooks/ | 连接表单状态与编译/持久化的业务钩子 |
| 排版引擎层 | src/lib/typst.svelte.ts + @myriaddreamin/typst.ts 三件套 | 在浏览器内完成 Typst 源码编译与排版结果渲染 |
两个横切关注点不属于任何单一层次:
- 国际化(Paraglide JS):
messages/zh.json与messages/en.json存放文案,构建期由 Paraglide 生成src/lib/paraglide/目录,组件层直接调用生成函数。 - 主题切换(mode-watcher):明暗主题由
mode-watcher管理,配合layout.css中的主题变量实现。
这一分层的意图非常明确:UI 与排版彻底解耦。组件层只负责收集参数,排版引擎层只消费参数产出 PDF,二者通过模板参数这一数据契约衔接。新增一个模板时,理论上不需要触碰 UI 框架层——这正是"支持多个模板,每个模板分别支持数个自定义参数"这一特性在架构上的支撑点。
Source: README.md
技术栈总览
技术栈与版本信息全部来自 package.json(name: endfield-docmaker,version: 0.1.2,type: module,包管理器 pnpm@12.3.4):
| 层级 | 技术 | 版本 | 角色 |
|---|---|---|---|
| 框架 | SvelteKit + Svelte 5(Runes 模式) | @sveltejs/kit ^2.69.0 / svelte ^5.56.4 | 应用框架与响应式运行时 |
| 排版引擎 | Typst(WebAssembly) | @myriaddreamin/typst.ts 0.8.0-rc3 | 浏览器内排版编译 |
| 排版编译器 | typst-ts-web-compiler | 0.8.0-rc3 | WASM 编译器组件 |
| 排版渲染器 | typst-ts-renderer | 0.8.0-rc3 | WASM 渲染组件 |
| PDF 预览 | pdfjs-dist | ^4.10.38 | 在页面中渲染生成结果 |
| 组件库 | shadcn-svelte + Bits UI | ^1.3.0 / ^2.18.1 | 无头组件与样式化组件 |
| 样式 | Tailwind CSS 4 | ^4.3.2(+ @tailwindcss/vite) | 原子化 CSS |
| 国际化 | Paraglide JS(Inlang) | @inlang/paraglide-js ^2.20.2 | 中英双语 |
| 图标 | Phosphor + Lucide | phosphor-svelte ^3.1.0 / @lucide/svelte ^1.23.0 | 双图标集 |
| 主题 | mode-watcher | ^1.1.0 | 明暗主题切换 |
| 字体包 | @fontsource-variable/* | noto-sans-sc / sora | 界面字体 |
| 压缩 | fflate | ^0.8.3 | 数据打包/解压 |
| 二维码 | qrcode-generator | ^2.0.4 | 模板内二维码生成 |
| 构建工具 | Vite | ^8.1.2 | 开发服务器与打包 |
| 语言 | TypeScript | ^6.0.3 | 静态类型(pnpm check 走 svelte-check) |
| 部署 | @sveltejs/adapter-static | ^3.0.10 | 产出纯静态站点 |
| 分析 | @vercel/analytics | ^2.0.1 | 访问统计 |
值得注意的依赖分层设计:typst.ts 三件套、pdfjs-dist、字体包等运行时必需的重型依赖放在 dependencies,而 SvelteKit、Tailwind、ESLint/Prettier、tsx 等只在开发期使用,全部放在 devDependencies。由于最终产物是静态站点,这个区分对生产构建体积有直接影响。
1"dependencies": {
2 "@fontsource-variable/noto-sans-sc": "^5.2.10",
3 "@fontsource-variable/sora": "^5.2.8",
4 "@lucide/svelte": "^1.23.0",
5 "@myriaddreamin/typst-ts-renderer": "0.8.0-rc3",
6 "@myriaddreamin/typst-ts-web-compiler": "0.8.0-rc3",
7 "@myriaddreamin/typst.ts": "0.8.0-rc3",
8 "@vercel/analytics": "^2.0.1",
9 "fflate": "^0.8.3",
10 "mode-watcher": "^1.1.0",
11 "pdfjs-dist": "^4.10.38",
12 "qrcode-generator": "^2.0.4"
13}Source: package.json
Typst 相关依赖被精确锁定在 0.8.0-rc3(不带 ^ 前缀),而其余依赖使用语义化范围。这是有意为之:WASM 编译器与渲染器必须严格同版本配对,任何一方的补丁级漂移都可能造成编译产物与渲染格式不匹配,因此通过精确版本号把三者绑定为一个原子单元。
核心数据流
下图展示一次典型文档生成从用户输入到 PDF 导出的完整链路:
流程解读(按时间顺序):
- 首次加载:路由层触发
typst.svelte.ts初始化 Typst WASM 编译器。由于 CJK 字体体积巨大,字体 Blob 被缓存在 IndexedDB 的fonts仓库中,二次访问直接命中缓存。 - 表单填写:每张模板的参数由组件层收集,内容自动写入 localStorage,刷新页面不丢失。
- 编译组装:业务层把模板源码(
assets/typst/*.typ)与用户参数组装成 Typst 源码。内容区域支持 Typst 标记语法,因此用户输入会被直接嵌入.typ源码参与编译。 - 编译与渲染:WASM 编译器产出排版结果,渲染器把它转成 PDF 数据。
- 预览与导出:
pdfjs-dist在页面内实时渲染预览;用户可选择下载或在新标签页打开。
这条链路中不存在任何网络上的服务端调用——除了托管静态资源本身的 CDN 请求之外,整个生成过程都在浏览器沙箱内完成。这也是 README 强调"该工具本身无法在不修改代码的前提下仿造真实文件"的安全声明在架构上的体现:输出完全由本地模板与本地输入决定。
Source: README.md
数据模型与持久化
浏览器端使用 IndexedDB 数据库 endfield-docmaker(版本号 DB_VERSION = 3),由 src/lib/stores/db.ts 统一管理。共三张对象仓库:
各仓库职责(源自 db.ts 头部注释):
files— 按模板隔离的用户资产(图片等)fonts— 为 Typst 缓存的字体 Blobdocs— 已保存文档库条目(表单值 + 缩略图)
核心实现是一段带版本升级钩子的单例连接:
1const DB_NAME = 'endfield-docmaker';
2const DB_VERSION = 3;
3export const FILES_STORE = 'files';
4export const FONTS_STORE = 'fonts';
5export const DOCS_STORE = 'docs';
6
7let dbPromise: Promise<IDBDatabase> | null = null;
8
9export function openDB(): Promise<IDBDatabase> {
10 if (dbPromise) return dbPromise;
11 dbPromise = new Promise((resolve, reject) => {
12 const req = indexedDB.open(DB_NAME, DB_VERSION);
13 req.onupgradeneeded = () => {
14 const db = req.result;
15 if (!db.objectStoreNames.contains(FILES_STORE)) {
16 const store = db.createObjectStore(FILES_STORE, { keyPath: 'id' });
17 store.createIndex('templateId', 'templateId', { unique: false });
18 }
19 if (!db.objectStoreNames.contains(FONTS_STORE)) {
20 db.createObjectStore(FONTS_STORE, { keyPath: 'name' });
21 }
22 if (!db.objectStoreNames.contains(DOCS_STORE)) {
23 const store = db.createObjectStore(DOCS_STORE, { keyPath: 'id' });
24 store.createIndex('updatedAt', 'updatedAt', { unique: false });
25 }
26 };
27 req.onsuccess = () => resolve(req.result);
28 req.onerror = () => {
29 dbPromise = null;
30 reject(req.error);
31 };
32 });
33 return dbPromise;
34}Source: src/lib/stores/db.ts
设计意图有三点值得注意:
- 单例连接复用:
openDB()用模块级dbPromise缓存连接,首次调用建立连接后,后续所有 store 操作共享同一个IDBDatabase,避免重复握手。 - 幂等升级:
onupgradeneeded中每个createObjectStore前都检查objectStoreNames.contains(...),保证从任意旧版本升级到 v3 时都不会因仓库已存在而抛错——这是浏览器端数据库做前向兼容的标准手法。 - 失败自动重置:连接失败时把
dbPromise置回null,下次调用会重新尝试打开,而不是让一个 rejected Promise 永久污染单例。
清空数据则通过一个跨三仓库的读写事务完成,设置页的"一键清空数据"即落在这里:
1export async function clearAllStores(): Promise<void> {
2 const db = await openDB();
3 const tx = db.transaction([FILES_STORE, FONTS_STORE, DOCS_STORE], 'readwrite');
4 tx.objectStore(FILES_STORE).clear();
5 tx.objectStore(FONTS_STORE).clear();
6 tx.objectStore(DOCS_STORE).clear();
7 return new Promise((resolve, reject) => {
8 tx.oncomplete = () => resolve();
9 tx.onerror = () => reject(tx.error);
10 });
11}Source: src/lib/stores/db.ts
三个 clear() 放在同一个事务里,保证清空操作的原子性:要么三张仓库全部清空,要么整体回滚,不会出现"字体被清掉但文档还残留"的中间态。
在此之上,src/lib/stores/ 目录下还有四个面向具体场景的封装:files.ts(用户资产)、fonts.ts(字体缓存)、docs.ts(文档库)、cloud.ts(云端同步相关)。表单内容本身则持久化在 localStorage,与 IndexedDB 分工——轻量高频的表单状态走 localStorage,大体积 Blob 走 IndexedDB。
构建工具链与部署架构
构建脚本
package.json 定义了一套精简的开发命令链:
| 命令 | 脚本内容 | 用途 |
|---|---|---|
dev | vite dev | 启动开发服务器 |
build | node -e "fs.rmSync('build',{recursive:true,force:true})" && vite build | 清空旧产物后构建静态站点 |
preview | vite preview | 预览构建产物 |
check | svelte-kit sync && svelte-check --tsconfig ./tsconfig.json | TypeScript 类型检查 |
lint | prettier --check . && eslint . | 格式与静态检查 |
fl | prettier --write . && eslint . --fix | 格式化并自动修复 |
m / mw | tsx ./scripts/manage-messages.js [--write] | i18n 文案管理(mw 追加 prettier 格式化) |
Source: package.json
build 脚本先用 node -e 内联执行 fs.rmSync('build', ...) 清空产物目录再构建,这样构建总是从干净状态开始,避免陈旧文件混入部署包——虽然只是一个小细节,但体现了"构建产物必须可复现"的工程取向。
静态部署拓扑
构建产物为纯静态文件,README 列出三个并行部署入口:
- GitHub Pages:
https://naptie.github.io/endfield-docmaker/ - Cloudflare:
https://endfield-docmaker.phi.zone/ - Vercel:
https://endfield.phi.zone/docmaker/
由于三个目标都是静态托管,同一份 build/ 产物可以在不加修改的情况下分别部署到任意静态主机。Vercel 侧额外接入了 @vercel/analytics 做访问统计,这是全项目唯一的运行时遥测组件。
注意 Vercel 路径 /docmaker/ 与 GitHub Pages 子路径 /endfield-docmaker/ 不同,静态站点需要正确配置 base path 才能在多目标下共存——这与选择 adapter-static 的部署策略直接相关。
工程规范
配套的工程化配置文件覆盖了完整的质量链:
eslint.config.js— ESLint 10 扁平配置(依赖eslint-plugin-svelte、typescript-eslint、eslint-config-prettier)svelte.config.js— SvelteKit 配置(含 adapter 选择)vite.config.ts— Vite 8 构建配置(挂载@tailwindcss/vite与vite-plugin-svelte)tsconfig.json— TypeScript 6 项目配置components.json— shadcn-svelte 组件生成器配置pnpm-workspace.yaml/pnpm-lock.yaml— pnpm 工作区与锁文件
src/lib/components/ui/ 下的 13 个目录对应 shadcn-svelte 生成的组件族:badge、button、card、dialog、input、label、select、separator、spinner、switch、tabs、textarea、tooltip。这些组件以源码形式进入仓库(而非 npm 依赖),是 shadcn "复制进项目、由你掌控"理念的直接体现——每个目录的 index.ts 导出该组件的样式化版本。
Source: README.md
故障模式与边界
基于源码可确认的边界行为:
数据库打开失败:openDB() 在 onerror 中同时完成两件事——reject(req.error) 让调用方感知错误,并把单例 dbPromise 重置为 null。这意味着 IndexedDB 打开失败(例如隐私模式、存储被禁用、版本冲突)不会让应用永久卡死:下一次任何 store 调用都会重新尝试建立连接。但要注意,如果失败原因是持久性的(如浏览器禁用存储),每次调用都会触发一次完整的失败握手。
升级幂等性:DB_VERSION = 3 的存在说明库结构经历过两次演进。升级钩子中的 contains 检查保证了从 v1/v2 升级过来的老用户不会因为仓库已存在而升级失败;但代价是结构变更只能追加式演进——重命名或删除仓库需要额外的迁移分支,当前代码未包含此类分支。
存储配额:字体仓库 fonts 缓存多款 CJK 字体(NotoSansCJKsc、NotoSerifCJKsc、FZXIAOBIAOSONG、JetBrainsMono 及数款系统字体替代品,均位于 src/lib/assets/fonts/),单字体体积可达数 MB。浏览器对单域名的 IndexedDB 配额有限(移动端尤其紧张),这正是提供"一键清空数据"(clearAllStores)的动机之一。
事务原子性:clearAllStores 把三张仓库放进同一事务,失败时整体回滚——清空操作不会留下半完成状态。
无服务端兜底:整个架构没有任何后端回退路径。Typst WASM 初始化失败、字体加载失败等情况只能由浏览器端 UI 处理,不存在"降级到服务器渲染"的选项。这是纯静态架构的固有取舍。
性能与运维要点
- 首屏成本:WASM 编译器(
typst-ts-web-compiler)+ 渲染器 + CJK 字体构成主要首屏负载;字体经 IndexedDB 缓存后,二次访问的主要成本只剩 WASM 资源本身。 - 增量编译收益:Typst 的编译模型配合
typst.svelte.ts的初始化逻辑,让表单参数变化触发的是重新编译而非全量重启,支撑"实时预览"体验。 - 三个部署入口的同源性:同一份构建产物服务三个域名,GitHub Pages 与 Cloudflare 不含分析组件的运行时副作用,
@vercel/analytics只在 Vercel 生效。 - 本地开发闭环:
pnpm check(svelte-check)+pnpm fl(prettier + eslint --fix)构成提交前的质量门;pnpm mw负责把 i18n 文案改动写回messages/并格式化。
扩展点
- 新增模板:向
src/lib/assets/typst/添加.typ模板,并在src/lib/templates/index.ts注册模板元数据(含参数定义),组件层按模板参数动态渲染表单。现有模板包括official-doc.typ(红头公文,作者 ParaN3xus)与tuzhang.typ(圆形公章,作者 Lonyou),以及源自 ezexam 的试卷模板。 - 新增持久化仓库:在
db.ts中导出新 store 常量并提升DB_VERSION,同时在onupgradeneeded中追加带contains检查的建仓逻辑,即可安全升级老用户数据。 - 新增 UI 原子组件:通过
components.json驱动 shadcn-svelte CLI 向src/lib/components/ui/生成,与现有 13 个组件族保持同构。 - 新增语言:在
messages/增加语言 JSON,经scripts/manage-messages.ts与 Paraglide 构建管线生成对应src/lib/paraglide/产物。
相关链接
- 上游项目:Typst WebAssembly 编译器 typst.ts
- 组件库:shadcn-svelte、Bits UI
- 样式:Tailwind CSS 4
- 框架:SvelteKit / Svelte 5
- 国际化:Paraglide JS
- 在线实例:GitHub Pages / Cloudflare / Vercel 三部署入口见上文
- 本地实现入口:src/lib/stores/db.ts、src/lib/typst.svelte.ts、src/lib/templates/index.ts