ticket-print: 纯前端单页PDF排版
纯前端实现的 PDF 排版工具,支持自定义排版参数和拖拽换序,让你的差旅报销更省心
前言
一直以来深受出差报销打印车票之困扰。为了在一张纸上打出四张车票,从前我的工作流是这样的:
- 先用 PDF 编辑器合并要打印的车票
- 用 chrome 打印到 PDF,选高级设置,一张纸打印四张
由于 chrome 打印 PDF 是“左上-右上-左下-右下”的顺序,而我想打印“左上-左下-右上-右下”的顺序,因此有如下 workaround:
- 如果打印三张或者四张车票,那么 PDF 合并时调换第二张和第三张的顺序(转置)
- 如果打印两张车票,那么合并时在第二张之前插入一个空白页
可见,这样搞非常麻烦。中间全部手工操作,费力又易错。正好前一阵子 Kimi K3 上线,就用它做了一个原型。
总共改了五版,中间加了一个需求,做得不好又降低了一下要求。总的来说不错,第三版已经能够实现初始需求了。
差不多了之后下载到本机上,一个是用 Open Design 去 AI 味,再一个是 opencode 处理一下发布前的琐事——比如 GitHub Action、README 等等。模型都用的是 deepseek v4 flash。
发布后发现有 basename 的问题,也是 vibe 解决的。全程我除了跟 AI 打字之外就是来回对了几遍生成出来的车票(乐
下面产品介绍和技术介绍是 AI 写的,链接直接跳到最后
产品体验:三分钟上手
打开页面,整个界面就两样东西:左侧上传区 + 右侧预览区。
第一步:拖入 PDF 车票。支持批量上传,每张票自动进入解析流程。
第二步:调整排版参数。装订线距离、票间距、页边距,三个滑块拖动即调,右侧预览实时响应。
第三步:拖拽排序。预览区里的车票可以直接用鼠标拖到任意格子,落点已有票则自动交换位置。
第四步:导出。点击「导出 PDF」,一张 A4 横向、四张票 2×2 排列的 PDF 就生成了。文件名还会根据票面乘车日期自动生成,比如 07.20-07.24.pdf。
整个过程不需要安装任何软件,打开浏览器就能用。
技术架构:全链路纯前端
这个项目的技术栈选型很有意思——没有后端,所有 PDF 处理都在浏览器中完成。这得益于几个关键库:
| 环节 | 工具 | 职责 |
|---|---|---|
| PDF 解析与渲染 | pdfjs-dist |
读取 PDF、渲染预览图、提取票面文本 |
| 自动裁剪 | 自研 findCropRect |
扫描画布像素识别内容区域 |
| PDF 导出 | pdf-lib |
矢量嵌入源页面,生成最终排版 PDF |
核心管线
用户上传 PDF 后,数据流是这样的:
-
解析 —
pdfjs-dist加载 PDF 文件,渲染第一页到 Canvas(3 倍缩放以保证预览清晰度),同时提取文本内容用于后续日期解析。 -
自动裁剪 — 对渲染好的 Canvas 进行像素级扫描。从四边向中心逐行/逐列检查,找到第一个非白色像素的位置,确定内容矩形。加上 6px 的容差边距(缩放后 18px),避免裁掉票面的边框线。然后将像素坐标反算回 PDF 用户空间坐标(pt),得到裁剪框 cropBox。
-
布局计算 — 所有排版参数都基于毫米计算,内部使用统一的
MM_TO_PT(72 / 25.4)转换因子。2×2 四个格子的位置由computeCells函数根据装订线、间距、边距实时计算,列优先排列(左上、左下、右上、右下)。 -
矢量导出 — 这是最关键的一环。
pdf-lib的embedPage方法支持传入裁剪框(subrectangle),直接从源页面中只嵌入票面区域的矢量内容,而不是嵌入整页再裁剪。这意味着导出的 PDF 保留了原始 PDF 的字体、图形和渲染精度,完全无损。
// 带裁剪框嵌入:只保留票面区域的矢量内容
const embedded = t.cropBox
? await doc.embedPage(srcPage, t.cropBox)
: await doc.embedPage(srcPage)
拖拽交互:自己动手,不比库差
拖拽排序是这个工具交互上的核心。最初调研了 @dnd-kit,但它的抽象层较多,对于「四个固定格子 + 交换位置」这个场景反而显得笨重。
最终选择了用 Pointer Events 自己实现:
pointerdown记录拖拽起始位置和偏移量pointermove实时计算鼠标在 A4 预览区内的毫米坐标,通过cellAt函数命中测试四个格子pointerup触发onDropToCell,如果目标格子有票则交换位置(handleDropToCell中的简单 swap 逻辑)
整个交互在 React state 中管理,拖拽中的半透明浮动层用独立的 <img> 渲染,而原本位置的票图则降低透明度(opacity: 0.25),保持视觉连续性。配合 Framer Motion 的 0.2 秒缓动动画,手感足够顺滑。
自动命名:正则的力量
导出的文件名从一个简单的需求开始:能不能根据票面的乘车日期自动取名?
实现方式是:解析票面文本 → 正则匹配日期 → 排序取首尾 → 格式化输出。
日期解析的难点在于区分乘车日期和开票日期。中国铁路电子客票上同时印着「售」和「开」相关的日期,真正的乘车日期后面通常跟着时刻(如 2026年07月20日10:30)。所以正则优先匹配后随时间或"开"字旁的日期:
t.match(/(\d{4})年(\d{1,2})月(\d{1,2})日(?=\d{1,2}[::]\d{2}|开)/)
如果多张票的日期不同,文件名自动生成范围格式,比如 07.20-07.24;跨年时带完整年份。
主题系统:一个属性搞定
深色/浅色主题通过 [data-theme] 属性控制,CSS 变量定义两种色板,Tailwind 的 darkMode: ["class"] 配合 shadcn/ui 的 hsl() 变量体系,做到了一处切换、全局响应。主题偏好持久化在 localStorage 的 ticket-theme 键中。
部署与发布
项目使用 Vite 构建,base: './' 使用相对路径,完美适配 GitHub Pages 的子路径部署。
CI 流程在 .github/workflows/deploy-pages.yml 中定义:main 分支推送到 GitHub → npm ci → npm run build → 上传 app/dist 目录到 Pages。为了支持子路径下的路由,BrowserRouter 的 basename 在运行时动态计算:
const basename = window.location.pathname
.replace(/\/index\.html$/, '')
.replace(/\/$/, '')
技术栈一览
- 框架: React 19 + TypeScript 5.9(strict mode)
- 构建: Vite 7 + tsc project references(
tsconfig.app.json+tsconfig.node.json) - 样式: Tailwind CSS 3 + shadcn/ui(New York 风格)
- PDF: pdfjs-dist 5.x(预览)+ pdf-lib 1.x(生成)
- 动画: Framer Motion 12
- 部署: GitHub Pages,
npm run build即产出
TypeScript 配置上启用了一些严格的限制:verbatimModuleSyntax 要求类型导入必须加 type 前缀,erasableSyntaxOnly 禁止 enum 和 namespace,noUnusedLocals 和 noUnusedParameters 严防死守死代码。
后记
ticket-print 用纯前端技术解决了一个实际的打印排版需求——没有后端,没有数据库,没有 API,打开浏览器就能用。
项目地址:GitHub
在线体验:GitHub Pages
Hook this up to your favourite commenting platform — Giscus, Disqus, or your own.

