Products

ticket-print: 纯前端单页PDF排版

纯前端实现的 PDF 排版工具,支持自定义排版参数和拖拽换序,让你的差旅报销更省心

JamzumSum · ·updated 2026年7月25日 · 6 min read

前言

一直以来深受出差报销打印车票之困扰。为了在一张纸上打出四张车票,从前我的工作流是这样的:

  1. 先用 PDF 编辑器合并要打印的车票
  2. 用 chrome 打印到 PDF,选高级设置,一张纸打印四张

由于 chrome 打印 PDF 是“左上-右上-左下-右下”的顺序,而我想打印“左上-左下-右上-右下”的顺序,因此有如下 workaround:

  1. 如果打印三张或者四张车票,那么 PDF 合并时调换第二张和第三张的顺序(转置)
  2. 如果打印两张车票,那么合并时在第二张之前插入一个空白页

可见,这样搞非常麻烦。中间全部手工操作,费力又易错。正好前一阵子 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 后,数据流是这样的:

  1. 解析pdfjs-dist 加载 PDF 文件,渲染第一页到 Canvas(3 倍缩放以保证预览清晰度),同时提取文本内容用于后续日期解析。

  2. 自动裁剪 — 对渲染好的 Canvas 进行像素级扫描。从四边向中心逐行/逐列检查,找到第一个非白色像素的位置,确定内容矩形。加上 6px 的容差边距(缩放后 18px),避免裁掉票面的边框线。然后将像素坐标反算回 PDF 用户空间坐标(pt),得到裁剪框 cropBox。

  3. 布局计算 — 所有排版参数都基于毫米计算,内部使用统一的 MM_TO_PT(72 / 25.4)转换因子。2×2 四个格子的位置由 computeCells 函数根据装订线、间距、边距实时计算,列优先排列(左上、左下、右上、右下)。

  4. 矢量导出 — 这是最关键的一环。pdf-libembedPage 方法支持传入裁剪框(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() 变量体系,做到了一处切换、全局响应。主题偏好持久化在 localStorageticket-theme 键中。

部署与发布

项目使用 Vite 构建,base: './' 使用相对路径,完美适配 GitHub Pages 的子路径部署。

CI 流程在 .github/workflows/deploy-pages.yml 中定义:main 分支推送到 GitHub → npm cinpm 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,noUnusedLocalsnoUnusedParameters 严防死守死代码。

后记

ticket-print 用纯前端技术解决了一个实际的打印排版需求——没有后端,没有数据库,没有 API,打开浏览器就能用。


项目地址:GitHub

在线体验:GitHub Pages

分享
作者
JamzumSum

Open-source developer, major language: Python, C++.

Comments

Hook this up to your favourite commenting platform — Giscus, Disqus, or your own.

Continue reading