Repository Wiki
Naptie/endfield-docmaker

系统架构与技术栈

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

总体架构

Loading diagram...

架构分层说明

上图展示了五个清晰的层次,自上而下依次为:

层次实际载体职责
路由层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-compiler0.8.0-rc3WASM 编译器组件
排版渲染器typst-ts-renderer0.8.0-rc3WASM 渲染组件
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 + Lucidephosphor-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。由于最终产物是静态站点,这个区分对生产构建体积有直接影响。

json
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 导出的完整链路:

Loading diagram...

流程解读(按时间顺序):

  1. 首次加载:路由层触发 typst.svelte.ts 初始化 Typst WASM 编译器。由于 CJK 字体体积巨大,字体 Blob 被缓存在 IndexedDB 的 fonts 仓库中,二次访问直接命中缓存。
  2. 表单填写:每张模板的参数由组件层收集,内容自动写入 localStorage,刷新页面不丢失。
  3. 编译组装:业务层把模板源码(assets/typst/*.typ)与用户参数组装成 Typst 源码。内容区域支持 Typst 标记语法,因此用户输入会被直接嵌入 .typ 源码参与编译。
  4. 编译与渲染:WASM 编译器产出排版结果,渲染器把它转成 PDF 数据。
  5. 预览与导出:pdfjs-dist 在页面内实时渲染预览;用户可选择下载或在新标签页打开。

这条链路中不存在任何网络上的服务端调用——除了托管静态资源本身的 CDN 请求之外,整个生成过程都在浏览器沙箱内完成。这也是 README 强调"该工具本身无法在不修改代码的前提下仿造真实文件"的安全声明在架构上的体现:输出完全由本地模板与本地输入决定。

Source: README.md

数据模型与持久化

浏览器端使用 IndexedDB 数据库 endfield-docmaker(版本号 DB_VERSION = 3),由 src/lib/stores/db.ts 统一管理。共三张对象仓库:

Loading diagram...

各仓库职责(源自 db.ts 头部注释):

  • files — 按模板隔离的用户资产(图片等)
  • fonts — 为 Typst 缓存的字体 Blob
  • docs — 已保存文档库条目(表单值 + 缩略图)

核心实现是一段带版本升级钩子的单例连接:

typescript
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

设计意图有三点值得注意:

  1. 单例连接复用:openDB() 用模块级 dbPromise 缓存连接,首次调用建立连接后,后续所有 store 操作共享同一个 IDBDatabase,避免重复握手。
  2. 幂等升级:onupgradeneeded 中每个 createObjectStore 前都检查 objectStoreNames.contains(...),保证从任意旧版本升级到 v3 时都不会因仓库已存在而抛错——这是浏览器端数据库做前向兼容的标准手法。
  3. 失败自动重置:连接失败时把 dbPromise 置回 null,下次调用会重新尝试打开,而不是让一个 rejected Promise 永久污染单例。

清空数据则通过一个跨三仓库的读写事务完成,设置页的"一键清空数据"即落在这里:

typescript
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 定义了一套精简的开发命令链:

命令脚本内容用途
devvite dev启动开发服务器
buildnode -e "fs.rmSync('build',{recursive:true,force:true})" && vite build清空旧产物后构建静态站点
previewvite preview预览构建产物
checksvelte-kit sync && svelte-check --tsconfig ./tsconfig.jsonTypeScript 类型检查
lintprettier --check . && eslint .格式与静态检查
flprettier --write . && eslint . --fix格式化并自动修复
m / mwtsx ./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/
Loading diagram...

由于三个目标都是静态托管,同一份 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/ 产物。

相关链接

Sources

(3 files)
src/lib/stores