用 Python+docxtpl 批量生成 SYB 创业计划书并导出 PDF

📅 发布时间:2026/9/19 0:21:21
用 Python+docxtpl 批量生成 SYB 创业计划书并导出 PDF
简介这份SYB创业计划书模板面向参加创业培训的学员、个体经营者以及需要撰写规范商业计划书的初创团队围绕从企业概况到财务测算的完整框架提供可直接填写的Word文档帮助使用者理清创业思路、核算启动资金与经营成本。压缩包内共1个doc文件约205KB为排版规整的表格型模板可逐项补录信息后另存使用。目前已有425人学习下载适合零基础或初次接触SYB课程的人群快速上手。内容按十章结构展开企业概况与创业者个人情况、市场评估与竞争对手优劣分析、市场营销计划产品、价格、选址、促销、企业组织结构与注册形式、固定资产与折旧、流动资金、12个月销售收入预测、销售和成本计划以及现金流量计划其中设备、家具、员工薪酬等均预留了供应商与单价栏位便于直接套用。1. 一份 SYB创业计划书模板.doc 为什么值得用代码来管前段时间有个做创业培训的朋友找我说他们一期 300 个学员每人要交一份创业计划书格式必须套 SYB创业计划书模板.doc内容要换成各自的项目。他们原本的做法是招两个实习生复制粘贴一份改 20 分钟300 份就是 100 小时改完还要统一排版、转 PDF、按姓名命名归档错一个字段就得回炉。这事的本质不是排版活而是一个标准的文档渲染问题模板固定数据可变输出量大。IT 从业者拿到这类需求正确的姿势是把 SYB创业计划书模板当成渲染引擎的输入文件而不是当成一份要用鼠标去改的 Word 文档。整条链路是把 .doc 转成可编程的 .docx把模板里的固定文案、变量占位、循环表格拆开用结构化数据填充批量导出并校验。下面按这条线走一遍从 .doc 和 .docx 的差异、占位符标记方式讲到 docxtpl 渲染脚本、Excel 批量生成、PDF 导出和几个必踩的坑。做培训系统、教务系统、企业服务 SaaS 或者任何要批量出文书的人都能直接抄。2. SYB创业计划书模板.doc 的字段结构与可编程化改造2.1 .doc 和 .docx 的差异决定了你能不能写代码填先明确一件事.doc是 Word 97-2003 的二进制复合文档格式.docx是 OOXML 的 ZIP 包。Python 生态里python-docx、docxtpl、docxcompose这一整套工具只认.docx没有任何一个主流库能直接写.doc。所以第一步永远是格式转换而不是打开文件研究怎么写代码。Linux/macOS 上用 LibreOffice 命令行转换这是最稳的做法# 单文件转换把 SYB创业计划书模板.doc 转成 docx输出到 converted 目录 libreoffice --headless \ --convert-to docx:MS Word 2007 XML \ --outdir ./converted \ ./SYB创业计划书模板.doc # macOS 上 LibreOffice 的可执行文件路径通常是这个 # /Applications/LibreOffice.app/Contents/MacOS/soffice # 目录里全是 .doc 时批量转先转换成文件再执行避免一次塞太多参数 find ./templates -name *.doc -print0 | xargs -0 -n 20 libreoffice --headless \ --convert-to docx --outdir ./converted参数含义--headless表示不弹 GUI服务器上必须加--convert-to docx:MS Word 2007 XML里的过滤器名可以省略但显式写出来能避免某些环境误判成别的格式--outdir指定输出目录不加会覆盖源文件所在目录的同名文件。转换完之后必须做一次人工比对重点是这四类结构结构类型在 SYB 模板里的典型位置转换后常见问题标题层级章节大标题、小节标题大纲级别丢失生成目录时抓不到表格成本明细、人员工资、销售预测单元格合并方向错乱、边框丢失页眉页脚企业名称、页码文本框锚点偏移图片/图形封面 logo、组织架构图浮动图形变成嵌入式比对的方法不是肉眼翻而是把两版同时拆开看 XML 的w:tbl和w:pStyle是否一致。合并单元格方向错乱是最常见的因为它直接影响后面表格行循环能不能跑通。2.2 把模板拆成固定文案、变量占位、循环表格三层拿到转换后的.docx别急着往里写{{ }}先在纸上把整份 SYB创业计划书按三层归类。这三层的处理方式完全不同混在一起写必然返工。第一层是固定文案。创业计划书的章节引导语、政策口径描述、评分要点说明这些在所有学员的文档里逐字相同属于模板正文永远不动。第二层是标量变量。企业名称、法定代表人、注册地址、注册资本、成立日期、联系电话、行业归属这些一行一个值。它们的共同特点是全局唯一出现在文档多个位置时必须保持同一份值。第三层是循环表格。产品成本明细、设备清单、人员工资表、月度销售预测这些是行数不固定的表格有的学员 3 行有的 30 行。凡是数量不确定的重复结构都要走循环渲染绝不能靠预留若干空行再删。把这三层映射成数据键建议直接在模板旁边开一张对照表模板位置数据键类型示例值封面-企业名称company.namestring成都某某科技有限公司封面-成立日期company.foundeddate(YYYY-MM-DD)2024-03-11正文-注册资本company.capitalnumber500000成本明细表cost_itemsarray[object]见下成本明细行-项目名cost_items[].namestring服务器租赁成本明细行-月金额cost_items[].monthlynumber1200这张表就是后面所有脚本的契约。字段名一旦定下脚本、数据源、模板三方都按它对齐中途改名字三处一起改否则就是渲染出空值这种事。2.3 在 Word 里给变量打标记三种做法的选型标记变量位置有三种常见做法各有适用面第一种是直接在 Word 里敲 Jinja2 语法{{ company.name }}配合docxtpl渲染。上手最快改模板的人不需要懂代码只需要知道把{{ }}抄到正确位置。缺点是 Word 的自动更正和拼写检查会捣乱而且编辑过程中容易把占位符拆进不同的 run。第二种是用 Word 书签bookmark。python-docx支持定位书签填充逻辑自己写。适合模板结构非常固定、只需要替换几十个点的场景但书签多了以后维护成本急剧上升而且书签在复制粘贴时容易失效。第三种是内容控件Content Control / SDT可以在 Word 里做成带提示文字的下拉框、日期选择器。适合给非技术人员填的模板但解析 SDT 的代码量比前两种大一截。对 SYB创业计划书这种字段多、表格多、每周都要批量出一批的场景我一般直接选第一种docxtpl。理由是它同时解决了标量替换和表格行循环而且是纯文本标记模板交给培训老师自己改也能看懂。2.4 用脚本检查占位符有没有被 Word 拆散到多个 run这是docxtpl用久了必踩的第一个坑而且症状很迷惑模板里明明写着{{ company.name }}渲染出来却没被替换原样留在文档里。原因是 Word 在编辑过程中会根据拼写、语言、格式把一段文字切成多个 run{{和company.name可能落在两个 run 里docxtpl匹配不到。写个巡检脚本把所有段落和表格单元格里的占位符扫一遍# check_placeholders.py # 用途检测 Word 模板里的 {{ }} / {% %} 占位符是否被拆分到多个 run from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph def iter_paragraphs(parent): 递归遍历文档里的所有段落包含嵌套表格中的段落 if isinstance(parent, Table): for row in parent.rows: for cell in row.cells: yield from iter_paragraphs(cell) else: for para in parent.paragraphs: yield para for table in parent.tables: yield from iter_paragraphs(table) def scan(path): doc Document(path) bad 0 for para in iter_paragraphs(doc): joined .join(r.text for r in para.runs) if {{ not in joined and {% not in joined: continue # 完整标记应当出现在单个 run 内否则渲染时匹配不到 intact any({{ in r.text or {% in r.text for r in para.runs) if not intact: bad 1 runs_preview [r.text for r in para.runs if r.text][:5] print(f[RUN-SPLIT] style{para.style.name} runs{runs_preview}) print(f扫描完成可疑段落 {bad} 处 / 文件 {path}) if __name__ __main__: scan(SYB创业计划书模板.docx)逻辑说明iter_paragraphs用递归把所有表格、嵌套表格里的段落都拉平因为循环表格的占位符恰恰最容易出问题。判断标准是拼接后包含标记但单个 run 内不含标记命中即说明被拆散。参数上不用配置任何东西路径传模板文件即可退出时看bad计数非 0 就得回 Word 修。修复方法很土但有效在 Word 里选中整个占位符含两个花括号删掉重新一次性敲进去如果还不行先把字体改成和周围一致再敲字体切换是触发 run 拆分的常见原因。改完重跑一次巡检直到输出 0 处。提示把巡检脚本挂到模板提交的 Git 钩子里比事后发现 300 份文档全部渲染失败要省事得多。3. 用 docxtpl 渲染 SYB创业计划书模板最小可跑示例3.1 环境依赖与版本约束docxtpl的底层是python-docx加jinja2它把模板里的 Jinja2 语法在内存里渲染成 XML 节点再写回 docx 包。安装很轻python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install docxtpl0.16 python-docx1.1 openpyxl # 服务器上导出 PDF 还需要 LibreOfficeDebian/Ubuntu 系列 # apt-get install -y libreoffice-writer fonts-noto-cjkfonts-noto-cjk这一步别省。Linux 服务器没装中文字体时转出来的 PDF 中文全是方框而docx本身看不出问题因为字体信息还在文档里只是渲染时找不到字形。3.2 一份成品文档的渲染代码先把模板里的占位符写好再用下面的脚本渲染。数据我建议放在 JSON 里跟模板放同一个仓库方便做版本对照。# render_one.py import json from pathlib import Path from docxtpl import DocxTemplate TPL_PATH Path(SYB创业计划书模板.docx) DATA_PATH Path(data/sample.json) OUT_DIR Path(out) def rmb_upper(value: float) - str: 把数字金额转成人民币大写模板里用 {{ company.capital | rmb_upper }} 调用 digits 零壹贰叁肆伍陆柒捌玖 units [, 拾, 佰, 仟] big_units [, 万, 亿] n int(round(value)) if n 0: return 零元整 s, group_idx , 0 while n 0: group, part, zero_flag n % 10000, , False for i in range(4): d group % 10 if d: part digits[d] units[i] part zero_flag True elif zero_flag: part 零 part group // 10 if part: s part big_units[group_idx] s n // 10000 group_idx 1 return s.strip(零) 元整 def main(): ctx json.loads(DATA_PATH.read_text(encodingutf-8)) tpl DocxTemplate(TPL_PATH) # 注册自定义过滤器必须在 render 之前 tpl.environment.filters[rmb_upper] rmb_upper OUT_DIR.mkdir(exist_okTrue) out_file OUT_DIR / f创业计划书_{ctx[company][name]}.docx tpl.render(ctx) tpl.save(out_file) print(f已生成 {out_file}) if __name__ __main__: main()逻辑说明DocxTemplate加载模板时不解析内容render(ctx)才走 Jinja2 编译所以过滤器要在render之前注册到tpl.environment.filters。ctx就是 2.2 节那张对照表的 JSON 版本键名必须完全一致包括嵌套层级。save走的是python-docx的写盘输出目录必须先建好否则会抛FileNotFoundError。3.3 模板里该写什么变量、条件、表格行循环docxtpl的标记分三个层级用错了就是整段没渲染或表格塌陷标记作用范围典型用法{{ var }}单个文本位置企业名称、注册资本{%p ... %}整个段落整段条件显示、整段循环{%tr ... %}整个表格行成本明细、人员工资{%tc ... %}整个表格列按列展开的多期数据表格行循环的写法有个硬性规矩{%tr for %}写在需要重复的那一行的第一个单元格里{%tr endfor %}写在循环体结束后的下一行的第一个单元格里docxtpl会把带for的行留作循环体把带endfor的整行删掉。{# 条件段落有出口业务才输出这一段 #} {%p if company.has_export %} 本企业计划在成立第二年开展出口业务出口收入占总营收比重不超过三成。 {%p endif %} {# 标量变量 自定义过滤器 #} 注册资本{{ company.capital }} 元{{ company.capital | rmb_upper }} {# 成本明细表表头行保持不动数据行循环展开 #} {%tr for item in cost_items %} {{ item.name }} | {{ item.period }} | {{ %.2f | format(item.monthly) }} | {{ %.2f | format(item.total) }} {%tr endfor %}逻辑说明{%p if %}和{%p endif %}必须独占一整段不能和其他文字混排否则整段会被删掉{%tr for %}所在行里的其他{{ }}会被逐行求值行数取决于cost_items的长度。格式化金额用%.2f | format()比写 Python 方法安全模板里没法访问对象方法。3.4 参数怎么设金额、日期和空值的兜底三个参数细节最容易出问题。金额统一用number存储、用过滤器格式化别在数据源里存成带千分位的字符串否则求和和转大写都要重新解析。日期在 JSON 里一律存YYYY-MM-DD字符串模板里用{{ company.founded }}直接输出需要中文格式就在数据源里额外加一个founded_cn字段比在模板里做字符串切割可靠。空值兜底用 Jinja2 的default过滤器联系电话{{ company.phone | default(待补充, true) }}true这个参数表示空字符串也算空值不加它的话数据源里写了会原样输出一个空白评审时看起来就是漏填。3.5 渲染后自动校验残留标记和必填字段批量渲染最怕的不是报错是静默输出一份缺字段的文档。加一个渲染后校验函数检查两件事文档里还有没有残留的{{或{%以及关键字段是不是空。# verify.py from docx import Document REQUIRED [company.name, company.capital, company.founded, cost_items] def verify(docx_path: str, ctx: dict) - list[str]: problems [] for key in REQUIRED: node ctx for part in key.split(.): node node.get(part) if isinstance(node, dict) else None if node is None: problems.append(f字段缺失: {key}) break doc Document(docx_path) all_text \n.join(p.text for p in doc.paragraphs) for table in doc.tables: for row in table.rows: all_text \n .join(c.text for c in row.cells) if {{ in all_text or {% in all_text: problems.append(模板存在未渲染的占位符) return problems逻辑说明REQUIRED是渲染前必须存在的键逐级下钻检查文档侧的检查把正文和所有表格单元格拼成一个字符串统一搜索因为残留占位符往往躲在表格里。返回值非空时把文件标记为失败不要混进成品目录。4. 用数据源批量生成 N 份 SYB创业计划书并导出 PDF4.1 数据契约一张 Excel 表收敛全部标量字段单份渲染跑通后批量的瓶颈就从代码变成了数据收集。最省事的做法是发给每位学员一张标准 Excel 表列名和 2.2 节的对照表一一对应循环表格用第二个 sheet 承载按企业名称关联。主表列定义列名对应数据键校验规则企业名称company.name非空长度 ≤ 40不含 \ / : * ? 法定代表人company.owner非空注册资本company.capital数字≥ 0成立日期company.foundedYYYY-MM-DD联系电话company.phone11 位数字或含区号座机文件名非法字符这条校验必须做。Windows 上文件名带:或/会直接写盘失败而学员填企业名称时写某某公司成都分公司是很常见的事。4.2 批量渲染脚本# render_batch.py import re from pathlib import Path import openpyxl from docxtpl import DocxTemplate TPL Path(SYB创业计划书模板.docx) XLSX Path(data/学员信息.xlsx) OUT Path(out/batch) OUT.mkdir(parentsTrue, exist_okTrue) ILLEGAL re.compile(r[\\/:*?|\r\n]) def safe_name(name: str) - str: 去掉文件名非法字符并限制长度避免超出文件系统上限 return ILLEGAL.sub(_, name).strip()[:60] def load_rows(xlsx: Path): wb openpyxl.load_workbook(xlsx, data_onlyTrue) main wb[主表] headers [c.value for c in main[1]] rows [dict(zip(headers, [c.value for c in r])) for r in main.iter_rows(min_row2)] cost {} for r in wb[成本明细].iter_rows(min_row2, values_onlyTrue): if not r or not r[0]: continue cost.setdefault(r[0], []).append( {name: r[1], period: r[2], monthly: r[3], total: r[4]} ) return rows, cost def main(): rows, cost_map load_rows(XLSX) ok fail 0 for row in rows: name row.get(企业名称) if not name: continue ctx { company: { name: name, owner: row.get(法定代表人), capital: row.get(注册资本) or 0, founded: row.get(成立日期), phone: row.get(联系电话), }, cost_items: cost_map.get(name, []), } try: tpl DocxTemplate(TPL) tpl.render(ctx) tpl.save(OUT / f创业计划书_{safe_name(name)}.docx) ok 1 except Exception as exc: # 单份失败不影响整批 fail 1 print(f[FAIL] {name} - {exc}) print(f完成成功 {ok} 份失败 {fail} 份) if __name__ __main__: main()逻辑说明每轮循环重新DocxTemplate(TPL)是刻意的DocxTemplate实例在render后会持有上一次的上下文复用同一个实例容易串数据。data_onlyTrue让 openpyxl 读公式的缓存值而不是公式本身学员表里常有SUM()出来的合计。safe_name同时处理非法字符和长度60 是一个对主流文件系统都安全的保守值。4.3 批量导出 PDF 与并发控制批量转 PDF 有个反直觉的点直接用libreoffice --convert-to pdf ./out/*.docx一次传几百个文件很容易在中途被 OOM killer 干掉因为 LibreOffice 是单进程顺序处理但会累积内存。稳妥的做法是分批每批不超过 30 个文件# 分批转 PDF每批 30 个文件 mkdir -p out/pdf find out/batch -name *.docx -print0 \ | xargs -0 -n 30 -I{} sh -c libreoffice --headless \ -env:UserInstallationfile:///tmp/lo_$$ \ --convert-to pdf --outdir out/pdf $ /dev/null 21 sh {}-env:UserInstallation是关键参数。不加它同一个用户下并发起多个 soffice 进程会抢同一个 profile 目录后启动的直接静默退出、什么都不转。带上$$当前 shell 的 PID让每批用独立 profile才能真正并行。-n 30控制每批文件数量-I{}把一批文件的路径作为位置参数传进sh -c。4.4 输出目录结构与命名规范批量产物的目录建议一次性定死后面接归档、接邮件分发、接上传都靠它out/ ├── batch/ # 渲染出的 docx逐份 │ └── 创业计划书_成都某某科技有限公司.docx ├── pdf/ # 转好的 PDF文件名与 docx 一致 ├── failed/ # 校验不通过或被人工打回的文件 └── manifest.csv # 文件名、企业名称、页数、生成时间、状态manifest.csv不是可选项。300 份文档发下去以后一定会有人问我的那份第几页少了一段没有清单就只能一份份翻。生成清单的成本比追查成本低两个数量级用 3.5 节的verify()结果直接写进去即可。5. 样式错乱、表格跨页与页码域SYB创业计划书批量生成的排错清单5.1 渲染后字体变成 Calibri 的定位方法症状是模板里明明是宋体小四渲染出来占位符那一小段变成了 Calibri。原因是渲染时替换文本的 run 继承了它所在 run 的字符属性而 Word 把光标停在非中文字符旁时往往给新输入的内容打上了西文字体的 rPr。定位方法是把模板和成品同时解压比对同一段落里w:rPr的差异mkdir -p /tmp/tpl cd /tmp/tpl unzip -o ~/SYB创业计划书模板.docx word/document.xml修复方式有两种。省事的一种是渲染后统一刷字体缺点是会覆盖模板里刻意设置的加粗和字号。稳妥的一种是保证占位符那一小段整段使用同一套字符格式在 Word 里把{{ company.name }}连同前后的冒号一起选中用格式刷从相邻正文刷一遍再重敲占位符。需要局部保留加粗时用docxtpl的RichText对象传入而不是在模板里手工调 run 属性。5.2 {%tr%} 循环行错位与表头不重复循环表格的三个高发问题一是合并单元格跨行时循环塌陷。带{%tr for %}的行如果存在纵向合并vMergedocxtpl复制行时会复制合并标记导致第二行开始整体右移一列。处理办法是把循环体的数据行做成完全不合并的规整表格需要视觉合并的效果用合并后单独补一行的方式绕开。二是表头翻页后消失。Word 里要显式开启重复标题行选中表头行表格工具里勾选重复标题行底层是给该行加上w:tblHeader/。这个设置不随渲染丢失但前提是模板转换后这一属性还在2.1 节的比对里要专门看一眼。三是循环行被拆到两页。给数据行加上与下段同页w:keepNext和段中不分页w:keepLines能减少断行但数据行本身很长时无效只能接受断开。5.3 页码与目录域不刷新python-docx保存文档时不会重新计算域所以封面页码、页脚页码、自动目录在所有成品里都是模板里最后一次保存的旧值。LibreOffice 转 PDF 时会重算一次域所以 PDF 通常是对的但 docx 打开时用户会先看到错的观感很差。解决办法是往settings.xml里写一个打开时更新域的开关# enable_update_fields.py from docx import Document from docx.oxml.ns import qn from docx.oxml import OxmlElement def enable_update_fields(path: str, out: str): doc Document(path) settings doc.settings.element if settings.find(qn(w:updateFields)) is None: el OxmlElement(w:updateFields) el.set(qn(w:val), true) # Word 打开文档时自动更新所有域 settings.append(el) doc.save(out) enable_update_fields(out/batch/创业计划书_成都某某科技有限公司.docx, out/updated.docx)逻辑说明w:updateFields是settings.xml的顶层元素加在末尾即可Word 和 WPS 都认。w:valtrue表示打开时更新全部域包括页码、目录、交叉引用。代价是打开时会弹一次是否更新域的提示部分版本不弹直接更新对批量交付来说完全值得。一个更省事的替代方案是渲染完直接走一遍 LibreOffice 转 PDF、再从 PDF 回写 docx 的路径但这条路径会丢掉批注和修订记录只在纯打印场景下用。真正要控制交付质量的场景还是上面这段三行 XML 最实在。本文还有配套的精品资源点击获取