欢迎光临
我们一直在努力

PySide6 复杂表格实践:项目周期表“配置—预览—导出“三位一体全记录

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},
...
]
}

理解这个结构有三个要点:

  • 表头是"结构数据"而非"列名数组"。12 个底层列由 9 个表头组组合而成,横向合并看 col_span + sub_headers,纵向合并看 row_span。配置页的"列配置"表格只能改列宽、不能改列名——改名对两层表头毫无意义,列名单元格直接设为只读。
  • 要件行同时携带内容与合并标记。stage_merge_continue、sub_stage_merge_continue 表示本行是上一行同组的延续。收集时根据相邻单元格文本是否相等自动重算,用户只需正常录入:
  • # 要件列表收集:自动推导合并标记
    if i > 0:
    if stage == prev_stage and stage != '':
    req_data['stage_merge_continue'] = True
    else:
    req_data['stage_merge_start'] = True
    ...

  • 模板数据与项目数据分离。requirements 是模板(要件、应完成时间、默认备注),project_cycle 表存的是每个项目自己的 5 个日期、实际完成时间、是否完成、以及覆盖填写的备注。导出时按列决定数据源(详见第五节)。
  • 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, cwpad_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 预览侧

  • 布局与绘制分离:先算纯几何 dict,再画像素,分页逻辑才能单测;
  • 多列等比缩放后用最大余数法分配整数像素,杜绝累计舍入;
  • 文字换行宽度要扣除与 docx tblCellMar 相同的内边距;
  • 分页"整行优先推页 + 超高行只按文字行边界拆",从几何上杜绝半行字;
  • 合并列跨页不要加"只在主格画一次"的守卫,每页画本页可见区域并 clip;
  • 标题对齐的矩形是版心,不是整张纸。
  • python-docx 导出侧

  • 验证 vMerge 用 tr.findall(qn('w:tc')) 原始 tc,row.cells[i] 会把续接格解析成主格;
  • 注意横向合并行会让该行 tc 数量变少(第一行 9 个而非 12 个);
  • 行高用 atLeast,让 Word 对超高内容自动撑高、自动在文字行间分页;
  • 数据行先全部填字、后做合并,合并后清理多余空段落;
  • 表头两行都打 w:tblHeader,灰底要覆盖续接 tc;
  • 循环变量名不要与样式字典等外层变量同名(tc 是高发区)。
  • 配置与数据

  • UI 只暴露部分键时,collect 必须深合并回加载快照,列表全量替换、dict 递归;
  • 结构变化用 _layout_version 增量迁移,幂等、保自定义;
  • 预览和导出共用同一份合并算法、同一套毫米几何,是"三位一体"的前提;
  • 用多配置变体 + XML 级断言做回归,比人工目检可靠得多。

  • 结语

    周期表这个需求看似只是"画个表格导个 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 导出实现
    • (四)项目周期表"配置—预览—导出"三位一体全记录
    赞(0)
    未经允许不得转载:171主机测评 » PySide6 复杂表格实践:项目周期表“配置—预览—导出“三位一体全记录
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址