ARTICLE · 1098097
自研记账凭证PDF打印完整实操手册(含底层原理与成长感悟)
自研记账凭证PDF打印完整实操手册(含底层原理与成长感悟)本文适合三类读者: 你可以只读自己关心的部分。技术操作在第一至九节,认知复盘在第十节。 本文较长,建议先收藏,再按需阅读。 本项目基于 Python、ReportLab 库与 SQLite 数据库,从零自研搭建一套记账凭证 PDF 自动打印系统。全程手动完成测量、建模、入库、渲染、调试全流程,不依赖商用软件,最终实现两大核心打印模式:空白 A4 白纸打印、财务专用凭证纸套打。 整套系统落地可实现三大核心能力: 手动测量空白纸质凭证,采集坐标数据生成专属打印模板,一次建模、永久复用; 手工录入纸质凭证的真实业务分录,实现每一张凭证业务数据独立存储; 程序自动匹配模板与业务数据,智能渲染分页、绘制凭证格式,输出标准可打印 PDF 文件。 名词注解: 使用财务打印软件时,我们通常只接触打印按钮,却看不到背后的分层逻辑。 本项目最关键、最核心的设计,就是模板模具与业务数据彻底分离,这也是所有商用票据打印系统的通用底层架构。 对应四张模板数据表:template_main、template_entry_grid、template_text、template_line。 对应两张业务数据表:voucher_head(凭证表头)、voucher_item(凭证明细)。 PDF 渲染的本质:固定模具承载动态业务数据,分层叠加、精准合成。 建表之前脑子里先有这张图,全程不会懵。 运行环境:Anaconda 核心依赖:ReportLab 数据库工具:DB-Browser-for-SQLite(可视化、零复杂 SQL) 空白记账凭证、已填写分录凭证、A4 白纸、毫米直尺。 统一项目目录:D:\voucher_project,所有文件统一存放。 A4 左下角为坐标原点:X=0,Y=0; X 向右递增、Y 向上递增,单位统一 mm; 3.文字 Y 坐标严格以基线为准:以汉字“一”横画下边缘对齐,杜绝文字裁切、错位; 分录横线由代码动态生成,无需手动测量。 打开 DB-Browser-for-SQLite,新建 voucher_print.db,执行以下 SQL,生成4 张模板表+2 张业务表。 A4 白纸与空白凭证纸左下角、底边、左边完全对齐、固定不动。 基准错一寸,全局错一尺。 依次录入:模板主表、分录网格、静态文字、固定线条。 核心原则:模板只存位置,不存内容;业务只存内容,不存位置。 基准对齐、单位统一毫米、文字基线测量、无多余分录横线、偏移归零、坐标方向无误。 针对每一张纸质凭证,录入表头信息与凭证明细,绑定对应模板 ID。 模板定型不动,业务数据灵活可变。 新建voucher_all.py,完整代码如下: 运行命令: 整体错位调偏移,局部错位调坐标。 中文乱码、文字裁切、整体偏移、多余线条、模板找不到等问题,均可通过对应坐标、参数、基准快速修复。 从前使用商用财务软件,打印是封闭黑盒:出错只能试错、靠运气、求人解决。 自研这套系统后彻底通透: 所有票据、凭证、报表打印的底层逻辑,全都是:模板定样式、数据定内容、代码定规则。 模板与数据彻底解耦,是所有成熟打印系统的工业级设计。 逐段弄懂坐标系基线规则、模板业务分离、全局偏移微调、自动分页、套打开关、借贷校验的底层原理时,内心满是通透与兴奋。后来,我慢慢读懂了这份感受。 这六个技术点,串联起整套打印系统的完整链路: 定位 → 架构 → 校正 → 分页 → 输出模式 → 数据校验 打通整条链路的那一刻,不是学会了功能,是拆解了系统。 兴奋,是手里有了修改的权利,不再被动接受软件的固定格式;通透,是心里有了预判的逻辑,任何异常都不再是玄学。 普通人靠经验试错,懂原理的人靠逻辑推演。 从前打印错位是随机故障,现在是可溯源、可解释、可修复的链路问题。确定感,永远来自底层逻辑,不来自运气。 这份兴奋,是认知破局带来的多巴胺。 原本神秘的黑盒被亲手拆解,杂乱的现象收敛成自洽的闭环,不确定性被彻底消解。从“被动使用工具”变成“主动设计规则”,这是使用者到创造者的蜕变。 这份通透,是掌控感回归的踏实。 从前是工具束缚人,报错、错位、分页异常只能反复试错;如今是人驾驭工具,清楚每一处参数的边界、每一次改动的影响。 这份愉悦,来自完整体系的逻辑闭环。 从物理纸张测量、数据库建模、代码渲染,到调试定型,全链路无断点、无盲区。这种通透的清爽感,是碎片化学习永远给不了的。 很多人学习只满足于“能用、会用”,而真正的成长,在于“弄懂、看透、掌控”。 它不只是会用,更是会造。 这套项目留给我的,不只是一套打印代码,更是深挖底层、拆解黑盒、闭环落地的思维习惯。 技术成长从来不是堆砌代码,而是读懂底层原理、搭建心智模型、沉淀可迁移复用的思维体系。 每打通一个原理、完成一次闭环,就是一次扎实的成长。深耕底层、循序渐进,就是最稳的技术之路。 这次自研最大的收获,不是实现了凭证打印,而是完成了一次完整的认知跃迁: 从现象到本质,从碎片到体系,从被动到主动,从使用到创造。 能拆、能懂、能改、能造,才是真正掌握一门技术。
如果这篇实操手册对你有帮助,欢迎收藏、转发。 评论区聊聊:你遇到过最折磨人的软件打印问题是什么? 关注我,持续更新更多「从零自研、拆解底层」的完整项目复盘。
用财务软件打印凭证,最让人抓狂的不是打印本身,而是——
错位了只能反复试,分页错了只能碰运气,套打偏了只能找 IT。
后来我决定自己写一套。
兴奋,是因为可以自由调整;通透,是因为一切皆在掌控。
它不只是会用,更是会造。
财务人员:想自己动手做凭证打印,不再受商用软件束缚; 技术爱好者:想学 Python + ReportLab 生成 PDF,掌握精确到毫米的打印控制; 终身学习者:喜欢“拆黑盒”,享受从现象到本质、从碎片到体系的认知快感。
一、项目整体目标
Python:一种编程语言,语法接近英语,适合初学者,广泛用于数据处理、自动化脚本。 ReportLab:Python 的一个第三方库,专门用来生成 PDF 文件,可以精确控制文字、线条、表格的位置。 SQLite:一种轻量级数据库,不需要安装服务器,一个文件就是一个数据库,适合个人项目。 PDF:便携文档格式,打印时格式不会乱。 模板:预先测量好的“格式框架”,规定格子、线条、文字的位置。 渲染:把数据“画”到 PDF 上的过程,用代码代替手写。 套打:在预制格式的凭证纸上,仅打印文字数字,不打印框线。
二、核心概念(整套系统的灵魂)
1. 打印模板数据(固定模具)
数据来源:毫米直尺实测空白纸质凭证的线条、文字、表格坐标。 存储内容:纸张尺寸、框线坐标、静态文字位置、字体字号、全局打印偏移量。 核心特点:仅需采集一次,适配成千上万张凭证打印;不存储任何动态业务内容。
2. 凭证业务数据(动态内容)
数据来源:手工抄录真实发生的纸质记账凭证。 存储内容:凭证字、凭证号、业务日期、附单据张数、摘要、科目、借贷金额。 核心特点:每张凭证数据独立,通过 template_id 绑定模板,实现一套模具,万千内容。
3. 通俗类比理解
模板= 印刷厂空白凭证底版,只定格式、固定不变; 业务数据= 会计手写的每一笔分录,每张凭证各不相同。
4. 六表关系图
template_main(模板主表)├── template_entry_grid(分录网格)├── template_text(静态文字)└── template_line(固定线条)voucher_head(凭证表头)└── voucher_item(凭证明细)voucher_head.template_id → template_main.template_id
三、前期环境与规范准备
1. 所需软件与工具
pip install reportlab
2. 所需硬件物料
3. 坐标系硬性标准(精准打印核心)
四、数据库建表实操
-- ========= 4张打印模板表(模具,空白凭证测量得到) =========CREATE TABLE IF NOT EXISTS template_main (template_id TEXT PRIMARY KEY,template_name TEXT NOT NULL,paper_width_mm REAL,paper_height_mm REAL,max_entry_line_per_page INTEGER,line_height_mm REAL,offset_x REAL DEFAULT 0,offset_y REAL DEFAULT 0,print_frame_line INTEGER DEFAULT 1 --1白纸打印画框;0套打不画框);CREATE TABLE IF NOT EXISTS template_entry_grid (template_id TEXT PRIMARY KEY REFERENCES template_main(template_id),left_x_mm REAL,right_x_mm REAL,first_line_top_y_mm REAL,col1_x_mm REAL,col2_x_mm REAL,col3_x_mm REAL);CREATE TABLE IF NOT EXISTS template_text (id INTEGER PRIMARY KEY AUTOINCREMENT,template_id TEXT REFERENCES template_main(template_id),text_code TEXT NOT NULL,desc TEXT,x_mm REAL,y_mm REAL,font_name TEXT,font_size_pt INTEGER,align TEXT CHECK(align IN ('left','right')));CREATE TABLE IF NOT EXISTS template_line (id INTEGER PRIMARY KEY AUTOINCREMENT,template_id TEXT REFERENCES template_main(template_id),line_name TEXT,x1_mm REAL,y1_mm REAL,x2_mm REAL,y2_mm REAL,line_width REAL);-- ========= 2张业务数据表(存储一张张填好的纸质凭证内容) =========CREATE TABLE IF NOT EXISTS voucher_head (vid INTEGER PRIMARY KEY AUTOINCREMENT,template_id TEXT, --关联使用哪一套打印模板voucher_word TEXT,voucher_no TEXT,voucher_date TEXT,attach_count INTEGER,create_time TEXT DEFAULT CURRENT_TIMESTAMP);CREATE TABLE IF NOT EXISTS voucher_item (iid INTEGER PRIMARY KEY AUTOINCREMENT,vid INTEGER, --关联voucher_head的vid,归属某一张凭证summary TEXT,subject TEXT,debit REAL DEFAULT 0,credit REAL DEFAULT 0,FOREIGN KEY (vid) REFERENCES voucher_head(vid));
五、打印模板采集录入(一次采集、永久复用)
1. 纸张摆放规范(重中之重)
2-5. 模板数据录入
6. 模板自检清单
六、凭证业务数据录入
七、Python 渲染核心代码
import sqlite3from reportlab.lib.pagesizes import A4from reportlab.pdfgen import canvasfrom reportlab.lib.units import mmfrom reportlab.pdfbase import pdfmetricsfrom reportlab.pdfbase.cidfonts import UnicodeCIDFont注册中文字体 pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light')) def safe_text(text, max_len=24): ”””文本超长简单截断””” s = str(text) if len(s) > max_len: return s[:max_len-3] + ”…” return s def load_template_from_db(db_path, template_id): ”””从数据库读取打印模板(模具)””” conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(”SELECT * FROM template_main WHERE template_id=?”, (template_id,)) main_row = cur.fetchone() if not main_row: raise Exception(f”数据库找不到模板 template_id={template_id}”) cur.execute(”SELECT * FROM template_entry_grid WHERE template_id=?”, (template_id,)) grid_row = cur.fetchone() cur.execute(”SELECT * FROM template_text WHERE template_id=?”, (template_id,)) text_rows = cur.fetchall() cur.execute(”SELECT * FROM template_line WHERE template_id=?”, (template_id,)) line_rows = cur.fetchall() conn.close() template = { ”offset_x”: main_row[”offset_x”], ”offset_y”: main_row[”offset_y”], ”max_entry_line_per_page”: main_row[”max_entry_line_per_page”], ”line_height_mm”: main_row[”line_height_mm”], ”print_frame_line”: main_row[”print_frame_line”], ”entry_grid”: { ”left_x”: grid_row[”left_x_mm”], ”right_x”: grid_row[”right_x_mm”], ”first_line_top_y”: grid_row[”first_line_top_y_mm”], ”col_x”: [ grid_row[”left_x_mm”], grid_row[”col1_x_mm”], grid_row[”col2_x_mm”], grid_row[”col3_x_mm”] ] }, ”header_text”: [], ”footer_text”: [], ”all_fixed_line”: [] } for r in text_rows: item = { ”code”: r[”text_code”], ”x”: r[”x_mm”], ”y”: r[”y_mm”], ”font_name”: r[”font_name”], ”fontSize”: r[”font_size_pt”], ”align”: r[”align”], ”text”: r[”desc”] } if r[”text_code”] in (”voucher_word”,”voucher_no”,”voucher_date”,”attach_count”): template[”header_text”].append(item) else: template[”footer_text”].append(item) for r in line_rows: line_item = { ”x1”: r[”x1_mm”], ”y1”: r[”y1_mm”], ”x2”: r[”x2_mm”], ”y2”: r[”y2_mm”], ”line_width”: r[”line_width”] } template[”all_fixed_line”].append(line_item) return template def load_voucher_from_db(db_path, vid): ”””读取凭证业务数据””” conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(”SELECT * FROM voucher_head WHERE vid=?”, (vid,)) head_row = cur.fetchone() if not head_row: raise Exception(f”找不到凭证 vid={vid}”) cur.execute(”SELECT * FROM voucher_item WHERE vid=?”, (vid,)) item_rows = cur.fetchall() conn.close() voucher_info = { ”voucher_word”: head_row[”voucher_word”], ”voucher_no”: head_row[”voucher_no”], ”voucher_date”: head_row[”voucher_date”], ”attach_count”: str(head_row[”attach_count”]) } entry_data = [] for r in item_rows: entry_data.append({ ”summary”: r[”summary”], ”subject”: r[”subject”], ”debit”: r[”debit”], ”credit”: r[”credit”] }) return head_row[”template_id”], voucher_info, entry_data def draw_voucher(c, template, voucher_data, entry_list): ”””核心渲染函数””” ox = template[”offset_x”] * mm oy = template[”offset_y”] * mm max_entry = template[”max_entry_line_per_page”] line_h = template[”line_height_mm”] * mm print_frame = template[”print_frame_line”] total_debit_all = sum(item[”debit”] for item in entry_list) total_credit_all = sum(item[”credit”] for item in entry_list) if abs(total_debit_all - total_credit_all) > 0.001: raise ValueError(f”借贷不平衡!借方:{total_debit_all:.2f} 贷方:{total_credit_all:.2f}”) page_groups = [entry_list[i:i+max_entry] for i in range(0, len(entry_list), max_entry)] total_page = len(page_groups) grid = template[”entry_grid”] entry_left_x = grid[”left_x”] * mm + ox entry_right_x = grid[”right_x”] * mm + ox table_top_y = grid[”first_line_top_y”] * mm + oy col_x_list = [x * mm + ox for x in grid[”col_x”]] for page_idx, page_entries in enumerate(page_groups): if page_idx > 0: c.showPage() if print_frame == 1: for line in template[”all_fixed_line”]: x1 = line[”x1”] * mm + ox y1 = line[”y1”] * mm + oy x2 = line[”x2”] * mm + ox y2 = line[”y2”] * mm + oy c.setLineWidth(line[”line_width”]) c.line(x1, y1, x2, y2) for t in template[”header_text”]: x = t[”x”] * mm + ox y = t[”y”] * mm + oy c.setFont(t[”font_name”], t[”fontSize”]) val = safe_text(voucher_data[t[”code”]]) if t[”align”] == ”left”: c.drawString(x, y, val) else: c.drawRightString(x, y, val) header_y = table_top_y c.setFont(”STSong-Light”, 10) c.drawString(col_x_list[0]+1mm, header_y+1mm, ”摘要”) c.drawString(col_x_list[1]+1mm, header_y+1mm, ”会计科目”) c.drawRightString(col_x_list[2]-1mm, header_y+1mm, ”借方金额”) c.drawRightString(col_x_list[3]-1mm, header_y+1mm, ”贷方金额”) if print_frame == 1: c.line(entry_left_x, header_y, entry_right_x, header_y) current_y = table_top_y - line_h page_bottom_y = current_y - len(page_entries)line_h if print_frame == 1: for xv in col_x_list: c.line(xv, table_top_y, xv, page_bottom_y) for entry in page_entries: c.setFont(”STSong-Light”, 10) c.drawString(col_x_list[0]+1mm, current_y+1mm, safe_text(entry[”summary”])) c.drawString(col_x_list[1]+1mm, current_y+1mm, safe_text(entry[”subject”])) c.drawRightString(col_x_list[2]-1mm, current_y+1mm, f”{entry['debit']:.2f}”) c.drawRightString(col_x_list[3]-1mm, current_y+1*mm, f”{entry['credit']:.2f}”) if print_frame == 1: c.line(entry_left_x, current_y, entry_right_x, current_y) current_y -= line_h for t in template[”footer_text”]: x = t[”x”] * mm + ox y = t[”y”] * mm + oy c.setFont(t[”font_name”], t[”fontSize”]) if t[”code”] == ”debit_total”: txt = f”借方合计:{total_debit_all:.2f}” if (page_idx == total_page-1) else ”借方合计:” elif t[”code”] == ”credit_total”: txt = f”贷方合计:{total_credit_all:.2f}” if (page_idx == total_page-1) else ”贷方合计:” else: txt = t[”text”] if t[”align”] == ”left”: c.drawString(x, y, txt) else: c.drawRightString(x, y, txt) if name == ”main”: DB_FILE = r”voucher_print.db” VOUCHER_VID = 1 used_template_id, voucher_info, entry_data = load_voucher_from_db(DB_FILE, VOUCHER_VID) template = load_template_from_db(DB_FILE, used_template_id) pdf_name = ”输出记账凭证.pdf” c = canvas.Canvas(pdf_name, pagesize=A4) try: draw_voucher(c, template, voucher_info, entry_data) c.save() print(f”✅成功生成 {pdf_name}”) except Exception as e: c.save() print(f”❌渲染出错:{e}”)
cd D:\voucher_projectpython voucher_all.py
八、打印调试与模式切换
白纸打印:print_frame_line=1,自动绘制全套框线 凭证套打:print_frame_line=0,仅输出文字数字
九、故障排查方案
十、项目核心原理与个人成长感悟
1. 项目核心原理
2. 认知复盘
兴奋,是因为可以自由调整;通透,是因为一切皆在掌控。
懂得了原理,就像提前知道了答案,带来一种确定感与十拿九稳。并非拿到现成结果,而是掌握了推演的逻辑;现象不再是随机的意外,一切变化都在掌控之内。