☰
档案目录批量打印:卷内目录与案卷目录PDF/Excel自动导出
2026/10/28 2:44:49 网站建设 项目流程

档案目录打印这件事,只要完整做过一轮年度归档,就知道它有多磨人。案卷目录还好,整本誊一次能管一年;卷内目录才是真正的体力活,每个案卷十几到几十条文件级条目,一条一条往模板里填,填完调版式、对页号、装订、补印,一个中等规模的归档批次就能把人固定在工位上两周。所以当我第一次把卷内目录、案卷目录的 PDF/Excel 导出做成一条命令跑完时,感受很直接:这活本来就不该这么干。

下面这份内容,是我把档案目录批量打印这件事从手工搬到脚本的完整记录。前面讲清楚两种目录到底差在哪、手工成本怎么算;中间拆数据表结构、字段规范、版式参数怎么定;后面给可以直接复制的导出实现和批量脚本;最后是我踩过的坑和对应排查办法。做档案整理的、管综合档案室的、负责项目竣工文件归档的,不管会不会写代码,都能从里面拿到能用的东西。

1. 手工打印到底慢在哪:先把成本算清楚

1.1 卷内目录和案卷目录不是一回事

很多人把这两样当同一种表,只是字段多一点少一点,实际差别很大。案卷目录是卷级的,一个案卷占一行,记录的是这一卷整体的情况:档号、题名、起止日期、保管期限、卷内件数、总页数、密级。它面向的是“找卷”,相当于整个库房的索引册。卷内目录是件级的,一卷一份,逐件罗列卷内每一份文件的文号、责任者、题名、日期、页号,它面向的是“找件”,通常放在卷内首页,跟文件一起装订。

这个层级差异直接决定了打印策略完全不同。案卷目录是一本连续编号的册子,几十上百页,装订成一本,页码从头到尾连续,通常还要加封面、目录说明、备考表。卷内目录是一卷一文件、一卷一起页,每份都是独立的两三页,页码从第 1 页重新开始,打印完要按卷分开、分别塞进对应的档案盒。

字段上也有几处容易混淆的地方。案卷目录里的“页数”指整卷总页数;“卷内件数”是这一卷里有几份文件。卷内目录里的“页号”指该份文件在卷内的起止页,写法通常是“1-5”这种区间,而不是单个数。这两列如果搞混,打出来的目录看着没问题,一核对就全错。我见过最典型的一种错,是把卷内目录的“页号”列直接填成序号 1、2、3,结果整卷的页号完全对不上,只能全部重打。

对比项案卷目录卷内目录
著录层级卷级,一卷一行件级,一件一行
单份页数几十页到上百页连续成册通常 1 至 3 页
页码规则全册连续编号每卷从第 1 页重新起
装订位置单独成册置于卷内首页,随卷装订
关键字段档号、题名、起止日期、保管期限、件数、页数件号、文号、责任者、题名、日期、页号
打印频次年度或批次集中打印一次每卷整理完成后即时打印

1.2 一笔手工账:一千卷要多花多少工时

我拿自己经手过的一个批次算过账:约 1000 卷,平均每卷 18 件,合计约 1.8 万条件级记录。手工路径大概是这样的——从台账里找到对应那一卷的数据,复制到 Word 模板,逐条核对字段,调整列宽和行高,检查有没有一条挤到下一页,加页码,打印,取纸,按卷分拣,装进档案盒。熟练的人做一卷,从复制到装订完成,8 到 12 分钟是常态,慢一点十五分钟也正常。

按 10 分钟一卷算,1000 卷就是 166 个小时,折合 21 个八小时工作日。这还只是“第一次打印”。档案整理有个绕不开的特点:返工率高。查到一条题名写错、一个日期格式没统一、页号算错一页,就得重新打开模板、改、再打一遍。返工率按 15% 估,又要多出三四十个小时。

批量导出之后是什么样?1000 卷 1.8 万条记录,脚本跑完大概三到五分钟,输出 1000 个 PDF 文件加 1000 个 Excel 文件。人工只剩下两件事:抽检和打印。打印本身也能批量,一次选中几百个文件丢给打印机连续输出,中间不用管。

环节手工耗时(每卷)批量耗时(每卷)
数据搬运与核对3 至 5 分钟0(脚本读取台账)
版式调整2 至 3 分钟0(模板固定)
页码与页号校验1 至 2 分钟0(脚本自动校验)
打印与分拣2 至 3 分钟约 30 秒(连续打印后按序分拣)
返工平均 1.5 分钟0(改台账后重跑单卷)
合计约 10 分钟约 30 秒

这个账算下来,真正省的不是打印那一下,而是数据搬运和版式调整这两块。所以后面所有的设计,都是围绕“把这两块彻底交给程序”来做的。

1.3 哪些环节其实可以交给程序,哪些必须留给人

有个误区得先破掉:不是全流程都能自动化。档案这行有个硬约束,目录内容必须跟实体文件严丝合缝,任何一条题名错了,程序是发现不了的,它只会忠实地把错的打出来,而且一次错 1000 份。所以分工要划清楚。

能交给程序的:字段拼接、格式统一(日期、档号前导零)、页码计算、“第 X 页 共 Y 页”生成、版式排版、跨页表头重复、批量文件命名、页号连续性校验、档号唯一性校验、批量转 PDF、按规则分目录存放。这些事情规则明确、结果可验证,程序做得比人快也比人准。

必须留给人的:题名是否准确反映文件内容、责任者写法是否符合本单位习惯、保管期限判定、密级判定、实体文件与目录的实物核对、盖章签字。尤其是题名,档案著录里最容易出问题的就是这一列,程序永远只能照搬。

把这条线划清楚,后面的技术选型和脚本设计才有依据——我们做的是“把人从重复劳动里捞出来”,不是“让人不用看目录”。

2. 数据底座:把台账整理成能输入模板的结构

2.1 目录字段怎么定:一份可以直接照抄的字段表

所有导出方案的前提,是一张干净的台账。我见过太多人上来就写脚本,结果卡在数据上:日期一列有“2023.5.6”“2023年5月6日”“2023-05-06”三种写法,档号有的带前导零有的不带,件号跳号。脚本没错,是数据没法用。

所以第一步是把字段定死。下面这张表是我目前用的版本,字段名尽量贴近行业通用著录项,列宽和格式也在里面标清楚了。

字段名类型是否必填格式要求常见错误
档号文本是全宗号-目录号-案卷号,各段位数固定被 Excel 识别成数字,前导零丢失
件号整数是卷内从 1 开始连续跳号、重号
文号文本否原样著录,含年份方括号方括号被当成公式
责任者文本是多个责任者用顿号分隔混用逗号、分号
题名文本是不超过 100 字,超长需说明含 & < > 等符号
日期日期是统一 YYYY-MM-DD文本格式与日期格式混用
页号文本是起止页写成“起-止”,单页写单个数写成序号、区间倒置
页数整数是止页减起页加一与页号区间不一致
保管期限枚举是永久 / 30年 / 10年写成“长期”“短期”
密级枚举否公开 / 内部 / 秘密空白与“公开”混用
备注文本否自由文本含换行符导致排版错位

两个细节值得单独说。档号必须是文本格式,这是最常见的坑。Excel 打开一个 CSV,档号“001-2023-K-0001”会被自动转成数字或者科学计数法,前导零全没了,打出来的目录档号全错。解决办法是在导入时就把这一列指定为字符串类型,或者在 Excel 里提前把整列设成文本格式。日期必须统一成标准格式,因为后面要做排序和区间计算,混着“2023.5.6”这种写法,排序结果会乱七八糟。

2.2 主从表拆分与关联键

台账不能是一张平表。一张平表里,同一卷的档号、题名、保管期限要在 18 行里重复 18 遍,改一处就得改 18 处,迟早出错。正确做法是拆成两张表。

案卷表:一卷一行,存卷级信息。字段是档号、案卷题名、起止日期、保管期限、密级、卷内件数、总页数、备注。

卷内文件表:一件一行,存件级信息。字段是档号、件号、文号、责任者、题名、日期、页号、页数、备注。这里的档号是外键,指向案卷表里的那一卷。

两张表用档号关联。案卷表里档号唯一,卷内文件表里同一个档号可以出现多次。导出某一卷的卷内目录时,就是拿这一卷的档号去卷内文件表里筛,按件号排序,再渲染成表格。

import pandas as pd # dtype 显式指定,避免档号前导零被吃掉 juan = pd.read_excel("台账.xlsx", sheet_name="案卷表", dtype={"档号": str}) nei = pd.read_excel("台账.xlsx", sheet_name="卷内文件表", dtype={"档号": str, "页号": str, "文号": str}) # 按档号取一卷的卷内文件,按件号升序 def get_items(danghao: str) -> pd.DataFrame: sub = nei[nei["档号"] == danghao].copy() return sub.sort_values("件号").reset_index(drop=True) print(get_items("001-2023-K-0001").head())

这段代码看着简单,但dtype那三个字是关键。不写它,档号“001-2023-K-0001”里如果有一段是纯数字,pandas 有可能把它推断成数值,后面拼字符串就出错。同理,页号“1-5”如果不指定成字符串,也可能出问题。

2.3 三条必做的数据校验:把错误挡在打印之前

数据校验是整条流水线里性价比最高的环节。花二十分钟写校验,能省下几十个小时的返工。我固定跑四条检查,每条都对应一类真实发生过的错误。

第一条,档号唯一性。案卷表里同一个档号出现两次,说明台账有重复录入,导出时会出现两卷一样的目录,装订时必然多出一份。检查方法很简单,用重复值标记筛一遍。

第二条,件号连续性。同一档号下的件号必须从 1 开始、逐个递增、不跳号不重号。跳号意味着有文件没著录,重号意味着有两份文件抢了同一个位置。

第三条,页号衔接。这一条最容易被忽略,但它错了就是硬伤。规则是:本件的止页,加 1,等于下一件的起页;最后一件的止页,等于案卷表里的总页数。这三个数对不上,说明卷内文件的实际页数和目录记载不一致。

第四条,日期合法性。起止日期要在合理区间内,不能出现 1900 年或者 2099 年这种明显笔误,也不能起晚于止。

def check(juan: pd.DataFrame, nei: pd.DataFrame) -> list[str]: errs = [] # 1 档号唯一 dup = juan[juan["档号"].duplicated(keep=False)] for dh in dup["档号"].unique(): errs.append(f"[档号重复] {dh}") for dh, grp in nei.groupby("档号"): grp = grp.sort_values("件号") # 2 件号连续 expect = list(range(1, len(grp) + 1)) if grp["件号"].tolist() != expect: errs.append(f"[件号跳号或重号] {dh}: {grp['件号'].tolist()}") # 3 页号衔接 for i in range(len(grp) - 1): cur_end = int(str(grp.iloc[i]["页号"]).split("-")[-1]) nxt_start = int(str(grp.iloc[i + 1]["页号"]).split("-")[0]) if cur_end + 1 != nxt_start: errs.append( f"[页号断开] {dh} 第{grp.iloc[i]['件号']}件止页{cur_end} " f"与第{grp.iloc[i+1]['件号']}件起页{nxt_start} 不衔接" ) # 4 日期区间 bad = juan[pd.to_datetime(juan["起止日期"].str[:10], errors="coerce").isna()] for dh in bad["档号"]: errs.append(f"[日期异常] {dh}") return errs for e in check(juan, nei)[:20]: print(e)

跑完这四条,如果没有报错,基本可以放心批量输出。有报错也不慌,脚本会把具体哪个档号、哪一件、哪两个数字对不上都打出来,改台账就行,不用去翻已经打好的纸。

3. 版式定稿:打印件一次成型的参数清单

3.1 纸张、边距、字体、行高:全套参数

版式这块,最省事的做法是先定死一套参数,之后所有输出都用同一套。每次打印都重新调格式,是手工时代最大的时间黑洞之一。

纸张统一 A4 纵向,210mm × 297mm。边距我固定用上 25mm、下 25mm、左 30mm、右 20mm。左边留 30mm 是为了装订线,卷内目录要跟文件一起装订,左边不留够宽度,装订完靠近装订侧的几列文字会被吃掉。右边 20mm 是常规页边距,够用。

按这个边距算,表格可用宽度是 210 − 30 − 20 =160mm。列宽分配就要在这个 160 里做文章。卷内目录六列,我一般这么分:件号 12mm、文号 26mm、责任者 26mm、题名 62mm、日期 18mm、页号 16mm,加起来正好 160mm。题名给到 62mm 是因为它最长,也最需要留出换行空间。

字体上,标题用黑体二号居中,表名下面如果还有一行“档号:×××”这种副标题,用小四宋体。表头用小四宋体加粗,正文小四仿宋。行高固定 1cm 左右,正文行距 1.5 倍。这些参数不是为了好看,是为了换一台机器打印结果不变——字体、字号、行高只要有一个不固定,换台电脑打出来页数就可能不一样。

参数项取值说明
纸张A4 纵向 210×297mm统一,不用 A3 或自定义尺寸
上边距25mm留出标题空间
下边距25mm留出页码与签字空间
左边距30mm装订线,必须留够
右边距20mm常规
表格总宽160mm可用宽度
标题黑体 二号 居中卷内目录 / 案卷目录
表头宋体 小四 加粗每页重复
正文仿宋 小四行距 1.5 倍
行高约 10mm单行文本时
页码宋体 五号 居中第 X 页 共 Y 页

3.2 跨页表头与动态行高:两个必须解决的排版难题

跨页表头是必须的。卷内目录超过一页时,第二页顶部如果没有表头,读者根本不知道哪一列是什么。Word 里的做法是选中表头行,表格属性里勾“在各页顶端以标题行形式重复出现”。HTML 渲染 PDF 时,用<thead>包住表头行,CSS 里加一句thead { display: table-header-group; },WeasyPrint 会自动在每页重复。这个属性值写错了就完全没效果,table-row-group是不行的。

动态行高麻烦一些。题名一列字数不固定,短的五六个字,长的六七十个字。如果固定行高,长的会被裁掉;如果都按最长的那条设行高,页面会浪费大量空白,一卷打五六页。正确做法是让表格自己撑高:不设固定行高,只设最小行高,单元格内容自动换行,行高随内容增长。Word 里叫“行高值设为最小值”,HTML 里就是不给height只给min-height,同时保证table-layout: fixed配合明确列宽,这样换行位置才可控。

这里有个细节:中文没有空格,浏览器默认在任意字符处都能断行,英文单词默认不拆。如果题名里夹着英文或者数字串,比如“2023年度XXXXX报告”,渲染时那一长串会被整体推到下一行,导致上面一行留一大片空白。解法是给单元格加word-break: break-all;(或者新一点的overflow-wrap: anywhere;),让它在任何位置都可断。

还有一条,表头不能单独留在页尾。会出现这种情况:某一页最后一行刚好是表头,下面的数据被推到下一页。视觉上就是“表头悬空”。CSS 里用tr { page-break-inside: avoid; }保证一行不被拆开,再用thead { page-break-after: avoid; }防止表头后面直接跟分页。这两条加上,排版基本就稳了。

3.3 页码、总页数与装订线:卷内目录特有的坑

页码这块,卷内目录和案卷目录规则完全不同,必须分开处理。

案卷目录是一本连续册子,页码从封面之后第一页开始,一直编到最后一页,全文连续。总页数是全册总页数。

卷内目录是每卷独立一份,页码每卷从第 1 页重新起,总页数是这一份卷内目录本身的页数(通常 1 到 3 页)。这个“共 Y 页”不是卷内文件的总页数,很多人第一反应会搞错。

还有一处极易混淆:卷内目录表格里有一列叫“页号”,那是卷内文件在整卷中的起止页,跟目录本身的页码完全是两回事。一份卷内目录上会同时出现“第 2 页 共 3 页”(这是目录自身的页码)和“页号 36-42”(这是某份文件在卷内的位置),新手看到容易懵。

技术上,Word 里用域代码PAGE和NUMPAGES,每份文档单独打开时会自动从 1 开始。HTML 渲染 PDF 时,用 CSS 的@page规则:

@page { @bottom-center { content: "第 " counter(page) " 页 共 " counter(pages) " 页"; font-family: "SimSun", serif; font-size: 9pt; } }

counter(page)是当前页,counter(pages)是总页数。因为是每卷单独渲染成一个 PDF,每份的页码自动从 1 开始,总页数也只统计这一份,正好符合卷内目录的要求。

装订线还要提醒一句:打印时不要勾“缩放以适应纸张”。这个选项会把 100% 的版式缩到 95% 左右,看起来没差别,但 30mm 的装订边距会变成 28.5mm,几十份下来装订侧的列就开始贴边了。打印设置里固定选“实际大小”或 100%。

4. 导出实现:Excel 和 PDF 两条路怎么走

4.1 Excel 路线:模板填充加格式锁定

Excel 路线的核心思路是模板 + 数据填充,不是从零画表。好处是格式、列宽、打印区域、页眉页脚全部预先设在模板里,脚本只负责往指定单元格写数据,永远不会跑版。

具体做法:先用 Excel 手工做一份空白模板,把标题、表头、列宽、行高、打印区域、页边距、页脚页码全部设好,存成template.xlsx。然后用 openpyxl 打开它,从第 2 行往下填数据,填完另存。

from openpyxl import load_workbook from openpyxl.worksheet.pagebreak import Break def fill_excel(danghao, meta, items, out_path): wb = load_workbook("template.xlsx") ws = wb.active ws["A2"] = f"档号:{danghao}" ws["A3"] = f"案卷题名:{meta['案卷题名']}" row = 5 for it in items: ws.cell(row=row, column=1, value=it["件号"]) ws.cell(row=row, column=2, value=it["文号"]) ws.cell(row=row, column=3, value=it["责任者"]) ws.cell(row=row, column=4, value=it["题名"]) ws.cell(row=row, column=5, value=it["日期"]) ws.cell(row=row, column=6, value=it["页号"]) row += 1 # 打印设置:A4、100%、打印标题行 ws.page_setup.paperSize = ws.PAPERSIZE_A4 ws.page_setup.orientation = "portrait" ws.page_setup.scale = 100 ws.print_title_rows = "4:4" # 第4行是表头,每页重复 ws.page_margins.left = 30 / 25.4 # 英寸 ws.page_margins.right = 20 / 25.4 ws.page_margins.top = 25 / 25.4 ws.page_margins.bottom = 25 / 25.4 wb.save(out_path)

print_title_rows = "4:4"这一行是跨页表头的关键,等价于 Word 里的“重复标题行”。page_margins的单位是英寸,所以要用毫米除以 25.4 换算,这一点非常容易忘,直接填 30 会得到 30 英寸的边距,一页什么都放不下。

Excel 还有个优势:审核方便。目录生成后发给同事核对,对方直接在 Excel 里改,改完重新跑一遍就行。所以我的实际做法是先出 Excel 供核对,确认无误再转 PDF 归档。

转 PDF 用办公套件的命令行模式,一次转整个目录:

# 批量把目录下所有 xlsx 转成 pdf libreoffice --headless --convert-to pdf --outdir ./out_pdf ./out_xlsx/*.xlsx

这条路的好处是不依赖代码环境里的字体配置,转换用的是本机已装字体,中文不会出问题。缺点是每份文件都要启动一次转换进程,几百份会慢一些,可以考虑分批跑。

4.2 PDF 路线:HTML 渲染的完整脚本

如果只想要 PDF,不想要 Excel 中间产物,直接走 HTML 渲染更干净。思路是把每一卷渲染成一张 HTML 页面,再用渲染器输出 PDF。模板用 Jinja2 写,数据往里一塞就行。

先看模板骨架:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <style> @page { size: A4 portrait; margin: 25mm 20mm 25mm 30mm; @bottom-center { content: "第 " counter(page) " 页 共 " counter(pages) " 页"; font-family: "SimSun", serif; font-size: 9pt; } } body { font-family: "FangSong", "SimSun", serif; font-size: 12pt; } h1 { font-family: "SimHei", sans-serif; font-size: 22pt; text-align: center; margin: 0 0 6mm 0; letter-spacing: 4px; } .sub { font-size: 10.5pt; margin: 0 0 3mm 0; } table { width: 160mm; border-collapse: collapse; table-layout: fixed; font-size: 10.5pt; } thead { display: table-header-group; } /* 跨页重复表头 */ tr { page-break-inside: avoid; } /* 一行不拆开 */ th, td { border: 0.5pt solid #000; padding: 2mm 1mm; min-height: 10mm; vertical-align: middle; word-break: break-all; line-height: 1.5; } th { font-family: "SimHei", sans-serif; font-weight: bold; text-align: center; } td.c { text-align: center; } col.c1 { width: 12mm; } col.c2 { width: 26mm; } col.c3 { width: 26mm; } col.c4 { width: 62mm; } col.c5 { width: 18mm; } col.c6 { width: 16mm; } </style> </head> <body> <h1>卷 内 目 录</h1> <p class="sub">档号:{{ meta.danghao }}</p> <p class="sub">案卷题名:{{ meta.title }}</p> <table> <colgroup> <col class="c1"><col class="c2"><col class="c3"> <col class="c4"><col class="c5"><col class="c6"> </colgroup> <thead> <tr> <th>件号</th><th>文号</th><th>责任者</th> <th>题名</th><th>日期</th><th>页号</th> </tr> </thead> <tbody> {% for it in items %} <tr> <td class="c">{{ it.no }}</td> <td>{{ it.docno }}</td> <td>{{ it.author }}</td> <td>{{ it.title }}</td> <td class="c">{{ it.date }}</td> <td class="c">{{ it.pages }}</td> </tr> {% endfor %} </tbody> </table> </body> </html>

几个关键点值得说透。table-layout: fixed必须配colgroup里的明确宽度,两者一起用才能保证列宽严格按 160mm 分配、不被内容撑开。display: table-header-group是跨页重复表头的开关,写在thead上。page-break-inside: avoid写在tr上,防止一行被拆到两页。word-break: break-all处理中文长题名和夹杂的英文数字串。

渲染脚本本身很短:

from jinja2 import Environment, FileSystemLoader from weasyprint import HTML import pandas as pd, os env = Environment(loader=FileSystemLoader("./templates")) tpl = env.get_template("juannei.html") def render_one(meta, items, out_dir): html = tpl.render(meta=meta, items=items) out = os.path.join(out_dir, f"{meta['danghao']}.pdf") HTML(string=html).write_pdf(out) return out

HTML(string=html).write_pdf(out)这一行就是全部转换逻辑。中文能不能正常显示,取决于系统里有没有装“仿宋”“宋体”这些字体,没有就会退化成方框。这是 PDF 路线最容易翻车的地方,后面踩坑那节细说。

4.3 批量循环、文件命名与失败兜底

单卷能导出只是第一步,真正的价值在批量。批量脚本要多做三件事:遍历所有档号、规范文件命名、记录失败。

import os, traceback def batch_export(juan, nei, out_dir): os.makedirs(out_dir, exist_ok=True) ok, fail = 0, [] for _, row in juan.iterrows(): dh = row["档号"] try: items = nei[nei["档号"] == dh].sort_values("件号").to_dict("records") if not items: fail.append((dh, "卷内文件表无对应记录")) continue meta = { "danghao": dh, "title": row["案卷题名"], "date_range": row["起止日期"], "keep": row["保管期限"], } render_one(meta, items, out_dir) ok += 1 except Exception as e: fail.append((dh, repr(e))) traceback.print_exc() print(f"成功 {ok} 卷,失败 {len(fail)} 卷") for dh, why in fail: print(f" {dh}: {why}") return fail

文件命名有条经验:直接用档号做文件名,不要夹中文标题。理由是档号唯一、可排序、不含特殊字符,而案卷题名可能很长、含书名号括号、含斜杠(斜杠在文件名里是非法的,会导致保存失败)。命名成001-2023-K-0001.pdf,在文件管理器里按名称排序就是按卷号排序,打印时选中全选,输出顺序天然正确,分拣时不用再对。

失败兜底不能省。批量跑几百卷,总会有几卷数据有问题——卷内文件表里没记录、题名超长导致排版溢出、日期格式解析失败。没有 try 包住,第一份出错整个循环就断了,前面跑的全白跑。加上异常捕获,跑完打印一份失败清单,改完数据单独重跑这几卷就行。

分批存放也值得做。一千个 PDF 塞进一个文件夹,打开都卡。按年度或按目录号建子目录,out/2023/K-001/001-2023-K-0001.pdf这种结构,查找和打印都方便。

5. 踩坑记录:那些文档里不会写的问题

5.1 分页错乱、空白页与“表头悬空”

分页是排版里最容易出问题的地方,我把它拆成三种具体现象来看。

第一种,多出一张空白页。最常见原因是内容刚好占满一页,末尾多了一个看不见的换行或者一个空段落,把它挤到了第二页。HTML 渲染时,body最后如果有一个空的<p>或者表格后面跟着<br>,就会生成空白页。解决办法是把模板里的多余空白节点全部删掉,body里除了标题、副标题、表格之外不留任何东西。Word 路线则是把表格后面那个空段落字号设成 1 磅、行距设成固定值 1 磅。

第二种,行被从中间劈开。一行数据上半截在第一页,下半截在第二页。这个纯粹是缺了page-break-inside: avoid。加上之后,渲染器会把这一行整体推到下一页。代价是当前页底部会留一块空白,可以接受。

第三种,表头单独留在页尾。一页最后一行是表头,数据全在下一页。这个要在表头行后面加page-break-after: avoid,让它不能跟后面的内容断开。另外thead上的display: table-header-group已经保证了每页都有表头,所以就算出现表头悬空,视觉上也只是多一行,不至于看不懂,但能避免还是避免。

还有一个隐蔽的:最后一行特别高,把整个表格顶出一页。原因是某一卷最后一件的题名特别长,占了四五行,而前面刚好排到页底。这种情况没法靠 CSS 完全消除,实际做法是在生成前先估一下每卷的预估页数,如果某卷特别长,就接受它多一页,不要去压缩行高——压行高会导致其他卷的排版也跟着变,得不偿失。

5.2 字体缺失、特殊字符与断行位置

字体缺失是 PDF 路线最典型的翻车点。本机渲染看着好好的,换到一台没装仿宋的机器上跑,整个 PDF 全是方块或者豆腐块。原因是渲染器找不到指定字体,退回到默认字体,而默认字体没有中文字形。解决办法有三个层次:一是在 CSS 里配字体回退链,"FangSong", "SimSun", "Microsoft YaHei", serif,前一个找不到就用后一个;二是把字体文件随项目放一份,用@font-face显式引入;三是干脆改用办公套件转换这条路,它用的是本机已装字体,只要机器上能正常打开文档,转出来就不会乱码。

特殊字符主要影响 HTML 渲染。题名里如果有&、<、>,直接塞进 HTML 会被当成标签或实体开头,轻则显示错乱,重则整页结构崩掉。Jinja2 默认开启自动转义,{{ it.title }}里的&会自动变成&amp;,所以正常情况下不会有问题。但如果你在模板里用了|safe过滤器,就等于关掉了这层保护,那就要自己先做转义。我的做法是干脆不用|safe,所有变量老老实实走默认转义。

断行位置的坑前面提过一次,这里补充一个实际案例。有一批文件的题名里带项目编号,形如“关于XX项目(编号 A1B2C3D4E5)的批复”。渲染出来经常出现一行只有“关于XX项目(编号”这几个字,然后一大片空白,下一行才接编号。原因是编号那一长串被当成一个不可分割的整体。加word-break: break-all之后,编号可以在任意字符处断开,空白就消失了。副作用是英文单词也可能被从中间断开,但目录场景里这个副作用基本无害。

5.3 数据量、性能与打印队列

一千卷听着不多,但实际跑起来会暴露几个性能问题。

渲染速度。单卷渲染一个 PDF,WeasyPrint 大概需要 0.3 到 0.8 秒,一千卷就是 5 到 13 分钟。这个速度可以接受,但如果卷内件数多(比如每卷 50 件),单卷时间会涨到 2 秒以上。优化手段是复用同一个渲染器实例,不要在循环里反复初始化,另外可以把大卷和小卷分开跑,先用小批量试跑确认版式,再全量跑。

内存占用。用 pandas 一次读入 1.8 万条记录,内存占用很小,不是问题。真正占内存的是同时打开太多 PDF 句柄,如果忘了close(),跑几百卷之后会报“too many open files”。write_pdf(path)这种写法内部会自己关闭,write_pdf(file_object)就要自己管。

打印队列。一次性选中一千个 PDF 发给打印机,大概率会卡死打印队列,尤其是网络打印机。实际做法是分批发送,一次 50 到 100 份,等队列清空再发下一批。另外一定要在打印设置里确认三件事:纸张 A4、缩放 100%、双面打印关闭(卷内目录单面打印,方便装订和翻阅)。

Excel 路线的性能问题不太一样。openpyxl 加载一个带大量样式的模板,再逐格写入,单卷几百毫秒,一千卷下来十几分钟。如果只是要 PDF,走 Excel 再转一次纯属多余,直接 HTML 渲染更省事。我现在的分工是:要核对就出 Excel,要归档就出 PDF,不混着来。

5.4 常见问题速查表

下面这张表是我这几年攒下来的,遇到问题先在这里找一遍,能省不少时间。

现象可能原因排查与处理
档号前导零丢失Excel 把文本列识别成数值导入时dtype={"档号": str},或列格式设为文本
打印出来是方块或乱码渲染机器缺少中文字体配字体回退链,或把字体随项目放置,或改用办公套件转换
第二页没有表头缺display: table-header-group在thead上加该属性;Word 里勾“重复标题行”
末尾多一张空白页模板末尾有多余空节点删除body内多余元素;Word 里把末段字号行距设为 1 磅
一行被劈成两半缺page-break-inside: avoid加在tr上
题名处出现大片空白长英文数字串不可断行加word-break: break-all
页号列和实际对不上把序号当成页号填了跑页号衔校验,止页加 1 应等于下件起页
每卷页码接着上一卷编批量时复用了同一个渲染上下文每卷独立渲染,或每份单独重置计数器
表格右边超出纸边列宽合计超过可用宽度核算为 160mm,各列相加必须等于这个值
装订后左侧文字被吃掉左边距不足或打印被缩放左边距 30mm,打印缩放固定 100%
批量跑到一半中断单卷数据异常未捕获循环内 try 包裹,记录失败清单单独重跑
打印队列卡死一次性发送文件过多分批 50 至 100 份发送
文件名保存失败文件名含斜杠等非法字符用档号命名,不用中文题名

表里最后一行值得单独强调。我早期用案卷题名做文件名,结果有一批题名里带“XX/XX项目”,斜杠在文件系统里是路径分隔符,保存直接报错,而且这个错是在跑到第三百多卷才出现的,前面跑的全得重来。改用档号命名之后,这个问题彻底消失,顺带还解决了排序问题。

最后分享一个我自己一直在用的小习惯。每次批量跑完,不要急着打印,先随机抽五卷打开看:一看看第一页的表头是否齐全、二看看最后一页有没有空白、三看看页号列有没有出现“0-0”或者倒置区间、四看看题名最长的那一件有没有被裁掉、五看看页码是不是从 1 开始。这五眼看下来,五分钟,能挡掉八成以上的返工。档案这行,纸一旦打出来、装进盒、盖了章,再改就是大工程,前面多花五分钟,后面省下的可能是半天。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询