PySide6 复杂表格实践:项目周期表"配置—预览—导出"三位一体全记录
前言
在政务信息化项目管理系统中,"项目生命周期实施对照表"是贯穿立项、采购、建设、验收全流程的核心业务表。它比普通表格复杂得多:
- 两层表头:第一行有"内部(跨3列)""外部(跨2列)"这样的横向合并表头,序号/阶段/要件等列则跨两行纵向合并;
- 四列纵向合并:序号、阶段、分阶段、备注按业务规则跨行合并,且合并组会跨越分页边界;
- 超高数据行:个别要件是上百行的验收清单,一个单元格的高度超过整页版心,必须跨页拆分;
- 严格的公文版式:A4 横/纵可切、四边距、宋体/黑体、灰底表头、完成行绿色字……
用户的诉求很朴素:配置里调什么,预览就显示什么,导出的 Word 就印出什么。这正是本系列前几篇提出的"三位一体"原则,但在周期表这个场景下,难度上了一个台阶——它逼着我们把 Word 的分页算法、OOXML 的合并语义、QPainter 的裁剪模型全部研究透。
本文完整记录这套实现的技术原理与踩坑过程,技术栈:PySide6 6.6.3 + python-docx 1.2.0 + SQLite,麒麟 Linux(无 LibreOffice 环境)。
本系列前情:
- (一)三位一体架构设计与模板配置
- (二)QPainter 预览渲染精要
- (三)python-docx 导出实现
一、整体架构:一条配置数据,两个渲染后端
周期表沿用系列的分层架构,但预览与导出共享同一套几何模型与合并算法,这是一致性的根基:
┌────────────────────────────────────────────────────────┐
│ 配置层 main_window.py │
│ 页面设置Tab / 样式设置Tab / 要件列表Tab │
│ _load_cycle_template_content() 加载→控件 │
│ _collect_cycle_template_content() 控件→模板(深合并) │
├────────────────────────────────────────────────────────┤
│ 模板单一数据源 cycle_template_defaults.py │
│ LAYOUT_VERSION / get_default_cycle_template() │
│ normalize_cycle_seq_numbers / normalize_remarks_header │
├──────────────┬─────────────────────────────────────────┤
│ 预览后端 │ 导出后端 │
│ QPainter │ python-docx │
│ _layout_ │ CycleExportService.export_cycle_table() │
│ cycle_table │ OOXML: tblGrid/vMerge/gridSpan/tblHeader│
│ (mm→px几何) │ (mm→twips几何) │
├──────────────┴─────────────────────────────────────────┤
│ 数据层 SQLite sys_template(模板) / project_cycle(数据) │
│ init_db() 启动时按 _layout_version 增量迁移 │
└────────────────────────────────────────────────────────┘
两条渲染链路的单位换算是同一张"换算表":
# 1 英寸 = 25.4mm = 72pt
PT_TO_MM = 25.4 / 72.0 # 0.3528 mm/pt
MM_TO_TWIP = 56.69 # 1mm = 56.69 twips(OOXML 单位)
# 预览侧:1mm = scale px(scale=2.0 日常预览,3.5 放大预览)
只要预览和导出都以毫米为中间语言、按同一张表换算,几何一致就有了数学保证。
二、模板配置:数据结构、深合并与版本迁移
2.1 模板 JSON 结构
模板整体存为 JSON 字符串,放在 sys_template.template_content 字段。核心由四块组成:
{
"_layout_version": 3,
"page_settings": { # 页面设置Tab
"orientation": "landscape", # landscape / portrait
"top_margin": 1.78, "bottom_margin": 1.78,
"left_margin": 2.54, "right_margin": 2.54
},
"styles": { # 样式设置Tab
"title": {"font_name": "黑体", "font_size": 22, "bold": True, "alignment": "center"},
"table_header": {"font_name": "宋体", "font_size": 12, "bold": True},
"table_content": {"font_name": "宋体", "font_size": 10},
"completed_row": {"color": "#00AA00"},
"header_height_mm": 12, "row_height_mm": 10, "title_bar_height_mm": 20
},
"columns_widths_mm": [15,15,20,50, 18,18,18,18,18, 18,18,25],
"header_groups": [ # 两层表头结构(UI不暴露,按结构驱动)
{"name": "序号", "col_span": 1, "row_span": 2},
{"name": "阶段", "col_span": 1, "row_span": 2},
{"name": "分阶段", "col_span": 1, "row_span": 2},
{"name": "要件", "col_span": 1, "row_span": 2},
{"name": "内部", "col_span": 3, "row_span": 1,
"sub_headers": ["起始时间", "完成日期", "提交日期"]},
{"name": "外部", "col_span": 2, "row_span": 1,
"sub_headers": ["盖章日期", "收到日期"]},
{"name": "应完成时间", "col_span": 1, "row_span": 2},
{"name": "实际完成时间","col_span": 1, "row_span": 2},
{"name": "备注", "col_span": 1, "row_span": 2}
],
"requirements": [ # 要件列表Tab(全量可编辑)
{"seq_no": "1", "stage": "项目立项", "sub_stage": "计划编制",
"requirement": "内部项目:\\n□编制三年滚动计划……",
"planned_time": "5工作日", "remarks": "起始时间为……",
"stage_merge_start": True, "sub_stage_merge_start": True},
...
]
}
理解这个结构有三个要点:
# 要件列表收集:自动推导合并标记
if i > 0:
if stage == prev_stage and stage != '':
req_data['stage_merge_continue'] = True
else:
req_data['stage_merge_start'] = True
...
2.2 深合并:UI 只暴露一部分键
这是系列第一篇讲过的关键机制,周期表同样依赖它。样式 UI 只暴露字体名/字号/加粗,但 styles 里还有 header_height_mm、completed_row.color 等不暴露的键;表头结构 header_groups 完全不暴露。直接用收集结果覆盖模板会把这些键全部丢掉。
做法是加载时存快照,收集时以快照为底递归深合并:dict 递归、标量覆盖、列表整体替换(要件表是全量编辑):
def _collect_cycle_template_content(self):
result = {
"page_settings": {从控件读},
"styles": {"title": {...}, "table_header": {...}, "table_content": {...}},
"columns_widths_mm": [...],
"requirements": requirements
}
_loaded = getattr(self, '_loaded_cycle_template', {})
if _loaded:
def deep_merge(base, override):
for k, v in override.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
deep_merge(base[k], v)
else:
base[k] = v # 列表/标量整体替换
merged = copy.deepcopy(_loaded)
deep_merge(merged, result)
result = merged
return result
2.3 配置变更实时刷新:QTimer 防抖
所有配置控件(含字体下拉手动输入、列宽 spinbox、要件表 itemChanged)统一接到一个 100ms 单次定时器,避免连续输入时离屏渲染刷屏:
self._cycle_preview_timer = QTimer(self)
self._cycle_preview_timer.setSingleShot(True)
self._cycle_preview_timer.timeout.connect(self._update_cycle_template_preview)
# 各类控件信号 → timer.start(100)
避坑:每新增一个配置控件,都要把它接进信号列表,否则"改了不刷新"。字体下拉用的是 QFontComboBox,除了 currentIndexChanged 还要监听 currentTextChanged,否则用户手动键入字体名不刷新。
2.4 版本号迁移:让历史模板自动升级
模板自带 _layout_version,init_db() 启动时增量迁移,每一段都幂等。周期表历经三个版本,每一次都对应一个真实的格式修正:
# v1:补齐 page_settings/styles 新键、columns_widths_mm
# v2:序号 ↔ 分阶段一一对应(重排 1~40 + 同步改正文交叉引用)
if _cycle_lv < 2:
from src.services.cycle_template_defaults import normalize_cycle_seq_numbers
normalize_cycle_seq_numbers(_cycle_content)
_cycle_content['_layout_version'] = 2
# v3:备注表头纵向合并(row_span=2),修复导出被横线拆成两格
if _cycle_lv < 3:
from src.services.cycle_template_defaults import normalize_remarks_header
normalize_remarks_header(_cycle_content)
_cycle_content['_layout_version'] = 3
v2 迁移值得一说:早期模板里存在"一个序号横跨多个不同分阶段"的脏数据。迁移规则是相邻同分阶段成组、每组一个连续序号,重排后还要扫描所有要件正文,把"详见序号15"这类交叉引用按旧→新映射同步改写,否则正文会指向错误环节。
迁移原则始终是:只增量校正、不整体替换、保留用户自定义、重复执行结果不变。
三、预览核心(一):统一几何模型与自适应行高
预览入口 _render_cycle_preview_pixmap(scale) 把所有分页纵向画到一张 QImage 上;真正的核心是 _layout_cycle_table(cfg, scale)——它只做几何计算,不画任何像素,返回一个布局字典供绘制层使用。这种"布局与绘制分离"让分页逻辑可以独立测试。
3.1 列宽:等比缩放 + 最大余数法
配置列宽是用户在 12 列上随意分配的毫米值,其总和未必等于纸张可用宽度。先按比例缩放到版心:
avail_w_mm = PW – left_mm – right_mm # 横向A4默认: 297-25.4-25.4=246.2
k_scale = avail_w_mm / sum(cfg_widths)
widths_mm = [w * k_scale for w in cfg_widths]
换算成像素时,如果每列都 round 后再求和,12 列的舍入误差会累积,实测默认配置下表格总宽比版心窄了约 1.7mm——预览表格右边到不了右边距,与导出的 tblGrid 总宽对不齐。
用**最大余数法(Hamilton 法)**彻底消除:先全部向下取整,再把剩余像素按小数部分从大到小逐列 +1:
total_table_px = round(avail_w_mm * scale)
raw_px = [w * scale for w in widths_mm]
col_w_px = [int(v) for v in raw_px]
remainder = total_table_px – sum(col_w_px)
for i in sorted(range(len(raw_px)),
key=lambda k: raw_px[k] – int(raw_px[k]), reverse=True)[:remainder]:
col_w_px[i] += 1
# 保证:sum(col_w_px) == 版心宽(像素),逐列误差不超过1px
3.2 文字换行与行高自适应
预览必须自己实现文字换行(QPainter 不会像 Word 那样自动折行)。用 QFontMetrics 在"列宽减去左右内边距"的可用宽度内贪心折行,12 个单元格各自折行,行高取该行最高单元格:
PAD_X = mm(1.5) * 2 # 单元格左右内边距合计(与导出 tblCellMar 同值)
PAD_Y = mm(0.5) * 2
c_lh = int(content_pt * PT_TO_MM * 1.2 * scale) # 1.2倍行距
for req in requirements:
max_lines = 1
for c, txt in enumerate(12列文本):
lines = self._cycle_wrap_lines(txt, cfm, col_w_px[c] – PAD_X)
max_lines = max(max_lines, len(lines))
row_h_px.append(max(mm(row_min_mm), max_lines * c_lh + PAD_Y))
# 文字块在行内垂直居中的顶部偏移
row_text0[k] = max(0, (row_h_px[k] – n_lines[k] * c_lh) // 2)
这里 1.5mm / 0.5mm 的内边距不是随便取的——它与导出端写入 OOXML 的整表 w:tblCellMar 完全一致,换行宽度模型才相同:
def _set_table_cell_margins(table, top=0.5, left=1.5, bottom=0.5, right=1.5):
# 写 w:tblCellMar,单位 dxa = mm*56.69
四、预览核心(二):复刻 Word 的分页算法
这是整个需求中最难的部分。用户最初的反馈非常具体:
“一页里出现一整行什么内容都没有;而且不能把一行文字截成上下两半——这页显示字的上半部分,下页显示下半部分。”
4.1 Word 到底怎么分页表格行
观察 Word/WPS 的真实行为可以总结出两条规则:
4.2 片段(frag)模型
布局阶段把每个数据行在每一页上的部分抽象成一个片段:
frag = (行号 gi, 行内偏移起 o0, 行内偏移止 o1, 纸面y坐标)
- 普通行整行落在某一页:o0=0, o1=行高;
- 超高行被拆成多个片段,偏移量始终对齐文字行边界。
分页主循环:
while i < n_rows or carry is not None:
body_top = 上边距 + (首页标题区) + 两层表头高
capacity = 下边距线 – body_top
cursor = body_top
# 1) 先排上一页遗留的超高行片段(必在新页页顶)
if carry:
gi, off = carry
n_fit = max(1, min(剩余文字行数, capacity // c_lh))
...
# 2) 再依次排完整行
while i < n_rows:
if row_h[i] <= 剩余空间:
frags.append((i, 0, row_h[i], cursor)); cursor += row_h[i]; i += 1
continue
if cursor > body_top:
break # 普通行放不下 → 整行推下一页,页尾留白
# 行在新页页顶仍放不下(超高行)→ 按文字行边界拆
n_fit = max(1, min(nmax, (avail – text0) // c_lh))
take = text0 + n_fit * c_lh # 断点严格落在文字行边界
carry = (i, take); break
因为所有断点都是 text0 + k * 行高,每一个文字行都恰好完整落入唯一一个片段,从几何上保证不可能出现"半行字"。
4.3 片段内绘制:一个坐标 Bug 的排查
绘制单元格片段时要处理三种情况:完整行、跨页首页片段、续页片段。最初续页分支用"片段内相对坐标"定位文字,却在统一换算时减了一个几百像素的偏移,导致文字跑到页面外被裁掉——表现就是超高行的续页要件列一片空白,只剩边框。
修复方法是三个分支统一用行内绝对坐标 ly = block0 + k*line_h,绘制时再减去片段起点 o0:
def _draw_cell_piece(self, p, lines, x0, ftop, w, o0, o1, rh, line_h, ...):
block0 = max(0, (rh – len(lines) * line_h) // 2) # 文字块在整行中的顶
if o0 == 0 and o1 == rh: # 完整行:全部画,垂直居中
items = [(k, block0 + k*line_h) for k in range(len(lines))]
elif o0 == 0: # 首页片段:只画完整落入的行
items = [(k, block0 + k*line_h) for k in range(len(lines))
if block0 + k*line_h + line_h <= o1]
else: # 续页片段:画未显示过的行
items = [(k, block0 + k*line_h) for k in range(len(lines))
if block0 + k*line_h + line_h > o0]
p.setClipRect(QRect(x0, ftop, w, o1 – o0), Qt.IntersectClip)
for k, ly in items:
p.drawText(QRect(x0 + pad_x, ftop + (ly – o0), w – pad_x*2, line_h),
align, lines[k])
4.4 合并单元格跨页:每页可见区域内居中
序号/阶段/分阶段/备注是纵向合并列。Word 原生 vMerge 的语义是"文字只在合并主单元格画一次"——合并组跨页时,续页那部分是空框。用户认为这不可接受,要求每页都能看到这是哪个阶段。
预览的处理:对每个合并组,找出它在本页可见的行范围,以"本页可见区域"为矩形,文字在其中垂直居中绘制,并用 setClipRect 限制不画出界:
for gstart, gspan in spans.items():
vis = [gi for gi in range(gstart, gstart+gspan) if gi in frag_map]
top_ry, bot_ry = frag_map[min(vis)][0], frag_map[max(vis)][1]
rect_h = bot_ry – top_ry
ty = top_ry + max(pad_y//2, (rect_h – block_h)//2)
p.setClipRect(QRect(cx, top_ry, cw, rect_h), Qt.IntersectClip)
for k, ln in enumerate(lines):
p.drawText(QRect(cx+pad_x, ty+k*lh, cw–pad_x*2, lh),
Qt.AlignHCenter|Qt.AlignVCenter, ln)
关键的坑是最初代码加了 if gstart in frag_map 守卫(想模拟 Word"只画一次"),结果跨页续页的合并列全部只剩空框——这正是用户反馈的"整行什么都没有"。去掉守卫、每页画自己可见部分后问题消失。
4.5 表头跨页重复与标题版心对齐
- 两层表头行在导出侧打了 w:tblHeader,Word 跨页自动重复;预览在每页(非首页)顶部同样重画表头。
- 标题对齐的坑:预览标题最初以整张纸宽为矩形做左/右对齐,左对齐会贴到纸边;而 Word 段落是以左右边距(版心)为界。修正为在 [表格左边界, 表格右边界] 矩形内对齐:
tx0, tw = col_x[0], col_x[–1] – col_x[0]
p.drawText(tx0, y + top_px, tw, title_h, title_align | Qt.AlignVCenter, 标题文本)
五、导出核心:python-docx 精确构造 OOXML
python-docx 的高层 API 对复杂表格支持有限,大量细节要直接操作 OOXML 元素。导出与预览共用合并算法 _calc_spans_by_content,但输出的是 Word 能理解的 XML。
5.1 两层表头:gridSpan + vMerge
表头两行各画一遍。第一行 9 个 tc(有横向合并),第二行 12 个 tc:
for hg in header_groups:
rowspan, colspan = hg['row_span'], hg['col_span']
cell = row0.cells[col_idx]
_fmt_cell(cell, hg['name'], th, 'center', 'center')
if rowspan > 1: # 纵向合并:序号/要件/备注等
cell.merge(table.rows[1].cells[col_idx])
if colspan > 1: # 横向合并:内部3列/外部2列
for c in range(1, colspan):
cell = cell.merge(row0.cells[col_idx + c])
两行表头都打上跨页重复标记和灰底(续接 tc 也要上底纹,WPS 渲染才一致):
_set_row_repeat_header(row0) # 写 w:tblHeader
_set_row_repeat_header(row1)
for tr in (row0, row1):
for tc in tr._tr.findall(qn('w:tc')):
_set_cell_shading(tc, 'F0F0F0')
5.2 数据行:atLeast 行高 + 后置合并
数据行高用 w:trHeight 的 atLeast 规则(最小值,内容多了 Word 自动撑高),先把 12 个单元格全部填充文字,再执行纵向合并。后置合并是因为合并操作会改变单元格对象,先填好文字最稳:
for data_row_idx, req in enumerate(requirements):
_set_row_height(table_row, row_h, 'atLeast')
values = [序号, 阶段, 分阶段, 要件, 5个日期, 应完成时间, 实际完成时间, 备注]
for col in range(12):
align = 'left' if col in (3, 11) else 'center' # 要件、备注左对齐
_fmt_cell(cell, values[col], content_style, align, 'center')
# 之后按 spans 执行四列纵向合并
for start, span in seq_spans.items():
start_cell = table.rows[2+start].cells[0]
for s in range(1, span):
start_cell = start_cell.merge(table.rows[2+start+s].cells[0])
cls._clean_merged_cell(start_cell) # 清掉合并后多余的空段落,保留首个
5.3 python-docx 最大的坑:vMerge 校验假象
验证合并是否正确时,千万不能用 row.cells[i]:
# ❌ 错误验证方式
table.rows[3].cells[0]._tc # python-docx 会把 vMerge 续接格"解析回"主单元格,
# 你看到的永远是主格,误以为 restart,实际可能根本没合并
python-docX 的 cells 属性对纵向合并做了"网格化"展开,续接位置返回的是主单元格的 tc。要拿真实的原始 tc,必须直接从 w:tr 下按顺序取:
# ✅ 正确:用原始 XML 节点验证
from docx.oxml.ns import qn
tcs = table.rows[ri]._tr.findall(qn('w:tc'))
vm = tcs[11].find(qn('w:tcPr')).find(qn('w:vMerge'))
# vm.get(qn('w:val')) == 'restart' → 合并主格
# vm 存在但无 val(<w:vMerge/>) → 续接格
# vm is None → 未合并
另外注意:第一行因横向合并只有 9 个原始 tc(不是 12),备注在最后一个(index 8);第二行才有完整 12 个。
5.4 合并规则:按内容,还是按结构?
序号/阶段/分阶段/备注统一用一个算法——相邻行去空白后文本相同即合并:
@staticmethod
def _calc_spans_by_content(requirements, key):
spans, covered, i, n = {}, set(), 0, len(requirements)
while i < n:
text = str(requirements[i].get(key, '') or '').strip()
if text == '':
i += 1; continue
j = i + 1
while j < n and str(requirements[j].get(key,'') or '').strip() == text:
j += 1
if j – i > 1:
spans[i] = j – i
covered.update(range(i+1, j))
i = j
return spans, covered
但备注列曾经出过一个典型的"预览合并、导出不合并"问题:预览只看模板(三行相同模板备注 → 合并),而导出当时按项目实际值算合并——某项目第二行被人手动填了测试备注 567,与第一行模板备注不等,导出就裂开了。
本质上备注是"分阶段组的注解"(和阶段名一样属模板结构),个别行的临时输入不该拆散整组。最终规则:合并边界由模板决定(与预览同源),合并主格显示该行有效值(项目填写优先、空则回退模板):
# 显示值:项目数据优先,空则回退模板
rv = str(item.get('remarks','')).strip() or str(req.get('remarks','')).strip()
# 合并组:一律按模板算
remarks_spans, _ = cls._calc_spans_by_content(requirements, 'remarks')
5.5 数据源与格式:每列从哪取值
| 序号/阶段/分阶段/要件/应完成时间 | 模板 requirements | 结构与标准内容,项目表只读 |
| 五个日期、实际完成时间 | 项目数据 | format_date_display 统一显示(YYYYMMDD) |
| 是否完成 | 项目数据 | 整行文字染配置色,不加底纹 |
| 备注 | 项目数据优先,空回退模板 | 组合并,左对齐 |
完成行只改字色、不着背景色,预览侧对应地去掉了 fillRect 绿底,只保留绿色画笔:
if is_completed and completed_color:
for run in cell.paragraphs[0].runs:
run.font.color.rgb = completed_color
六、三位一体的终极保障:4830 项自动化一致性校验
人工"看截图对 Word"既不可靠也不可持续。我们写了一个自动化校验脚本,走完整链路(打开真实配置对话框 → 控件加载 → collect → 离屏 layout;同时 monkeypatch 数据源走真实导出),逐维度比对预览布局字典与 docx XML。
6.1 多配置变体
用 3 个差异极大的配置覆盖边界:
- 默认横向;
- 纵向 + 自定义四边距 + 自定义 12 列宽 + 替代字体 + 9pt 内容 + 标题左对齐;
- 横向 + 标题右对齐 + 不加粗 + 自定义完成行红色。
6.2 断言维度(节选)
# —— 几何 ——
纸张宽高/方向/orient属性;四边距(mm 对 twips);
tblGrid 12列宽≈配置等比缩放;预览列宽(mm) 与导出逐列误差<0.6mm;
预览列宽总和 == 版心宽(最大余数法);所有分页片段落在上下边距版心内
# —— 表头 ——
row0 9个tc / row1 12个tc;内部 gridSpan=3、外部=2;
序号/要件/备注 row_span=2(vMerge restart);两行 tblHeader;灰底 F0F0F0
# —— 数据行 ——
70行;四列纵向合并组 == _calc_spans_by_content(模板);
要件/备注 jc=left 其余 center;全部 vAlign=center;
标题/表头/内容三级字体名·字号·加粗;完成行字色=配置色且无 w:shd;
行高 hRule=atLeast;备注主格文字=首行有效值;日期格式化
# —— 配置往返 ——
方向/四边距/列宽/标题样式 collect 后零漂移
合计 4830 条断言,0 失败。任何一处预览/导出对配置的响应不一致,都会立刻被抓到。
6.3 这套校验抓到的最严重 Bug:变量遮蔽
审计时校验发现"内容字体/字号"两条断言失败:无论配置里把正文字体改成什么,导出 XML 里数据行永远是宋体 12pt(_apply_font 的缺省值),而预览是正确的。
根因是一个经典的 Python 变量遮蔽:
tc = styles.get('table_content', {}) # 内容样式字典……
# ……几十行后,表头灰底循环:
for tc in r._tr.findall(qn('w:tc')): # ❌ tc 被重新绑定成 lxml 元素!
_set_cell_shading(tc, 'F0F0F0')
# ……数据行:
_fmt_cell(cell, text, tc, ...) # tc 现在是最后一个表头 tc 元素,
# .get('font_size') 取不到 → 回退缺省12pt
预览不受影响(它不用这个变量),于是出现了"预览正确、导出失效"的隐蔽分歧。改为 _tc_el 后立刻通过。这类问题靠肉眼 code review 极难发现,自动化 XML 级断言一击即中。
七、避坑清单(可直接收藏)
QPainter 预览侧
python-docx 导出侧
配置与数据
结语
周期表这个需求看似只是"画个表格导个 Word",真正做下来,核心功夫全在对齐语义:对齐 Word 的分页规则、对齐 OOXML 的合并语义、对齐公文版式的毫米几何。我们没有 LibreOffice 可用,预览渲染完全靠 QPainter 手工实现,反而逼着自己把每一个毫米、每一条边框、每一次分页都算清楚——最终的回报是预览与导出在 4830 项断言下严格一致,用户再没提过"对不上"。
"三位一体"不是一句口号,它的工程落地可以归纳为三句话:一个配置数据源、一套几何与合并算法、一份自动化一致性校验。
项目环境:PySide6 6.6.3 + python-docx 1.2.0 + SQLite,麒麟 Linux(loongarch64/aarch64),离屏 QPainter 渲染(QT_QPA_PLATFORM=offscreen)
系列文章:
- (一)三位一体架构设计与模板配置
- (二)QPainter 预览渲染精要
- (三)python-docx 导出实现
- (四)项目周期表"配置—预览—导出"三位一体全记录