feat: v1.5.0 - F011 模板格式提取式应用(从模板读取字体/边框/填充/对齐)

This commit is contained in:
2026-06-06 12:18:20 +08:00
parent 351f56ecb5
commit 13e7b8c4a5
2 changed files with 273 additions and 84 deletions

View File

@@ -171,15 +171,18 @@ class TemplateReader:
'fontId': xf.get('fontId', '0'), 'fontId': xf.get('fontId', '0'),
'fillId': xf.get('fillId', '0'), 'fillId': xf.get('fillId', '0'),
'borderId': xf.get('borderId', '0'), 'borderId': xf.get('borderId', '0'),
'xfId': xf.get('xfId', '0'),
'applyFont': xf.get('applyFont', ''), 'applyFont': xf.get('applyFont', ''),
'applyFill': xf.get('applyFill', ''), 'applyFill': xf.get('applyFill', ''),
'applyBorder': xf.get('applyBorder', ''), 'applyBorder': xf.get('applyBorder', ''),
'applyAlignment': xf.get('applyAlignment', ''),
} }
# 对齐方式 # 对齐方式
align = xf.find(_tag('alignment')) align = xf.find(_tag('alignment'))
if align is not None: if align is not None:
xf_info['hAlign'] = align.get('horizontal', '') xf_info['hAlign'] = align.get('horizontal', '')
xf_info['vAlign'] = align.get('vertical', '') xf_info['vAlign'] = align.get('vertical', '')
xf_info['wrapText'] = align.get('wrapText', '')
style.cell_xfs.append(xf_info) style.cell_xfs.append(xf_info)
def _parse_sheet_dims(self, data: bytes, style: TemplateStyle): def _parse_sheet_dims(self, data: bytes, style: TemplateStyle):

View File

@@ -258,93 +258,35 @@ class StyledXLSXWriter:
return '\n'.join(parts) return '\n'.join(parts)
def _styles_xml(self) -> str: def _styles_xml(self) -> str:
"""Build xl/styles.xml with fonts, fills, borders, and cellXfs.""" """Build xl/styles.xml with fonts, fills, borders, and cellXfs.
F011: 有模板时从模板的 fonts / fills / borders / cellXfs 中
提取实际样式定义;无模板时回退到硬编码默认样式。
保留现有 4 个样式槽位xf 0~3
xf 0 = default (no style)
xf 1 = centered + thin border (pin cells)
xf 2 = bold + centered (A1 package info)
xf 3 = centered + border + light fill (header-like)
有模板时,四个 xf 的字体/边框/填充/对齐从模板读取。
"""
s = self._style s = self._style
# ── Fonts ──────────────────────────────────────────────────
fonts_xml = '<fonts count="2">'
# Font 0: default (no bold)
font_name = "Calibri"
font_size = "11"
font_color = "FF000000"
if s and s.fonts: if s and s.fonts:
f0 = s.fonts[0] # ── 有模板:从模板提取实际值构建样式 ──────────────────
font_name = f0.name fonts_xml = self._build_fonts_xml_from_template(s.fonts)
font_size = str(f0.size) fills_xml = self._build_fills_xml_from_template(s.fills)
font_color = "FF" + f0.color if f0.color and not f0.color.startswith("FF") else f0.color borders_xml = self._build_borders_xml_from_template(s.borders)
fonts_xml += ( cell_xfs_xml = self._build_cell_xfs_xml_from_template(
f'<font><sz val="{font_size}"/>' s.cell_xfs, s.fonts, s.fills, s.borders
f'<name val="{font_name}"/>'
f'<color rgb="{font_color}"/>'
f'</font>'
) )
# Font 1: bold (for package info in A1) else:
fonts_xml += ( # ── 无模板回退到硬编码默认样式F011 fallback──────
f'<font><sz val="{font_size}"/>' fonts_xml = self._default_fonts_xml()
f'<b/>' fills_xml = self._default_fills_xml()
f'<name val="{font_name}"/>' borders_xml = self._default_borders_xml()
f'<color rgb="{font_color}"/>' cell_xfs_xml = self._default_cell_xfs_xml()
f'</font>'
)
fonts_xml += '</fonts>'
# ── Fills ──────────────────────────────────────────────────
fills_xml = '<fills count="2">'
fills_xml += '<fill><patternFill patternType="none"/></fill>'
# Fill 1: light gray for header-like cells
fills_xml += (
'<fill><patternFill patternType="solid">'
'<fgColor rgb="FFF0F0F0"/>'
'</patternFill></fill>'
)
fills_xml += '</fills>'
# ── Borders ────────────────────────────────────────────────
borders_xml = '<borders count="2">'
# Border 0: none
borders_xml += (
'<border>'
'<left/><right/><top/><bottom/><diagonal/>'
'</border>'
)
# Border 1: thin all sides
borders_xml += (
'<border>'
'<left style="thin"/><right style="thin"/>'
'<top style="thin"/><bottom style="thin"/>'
'<diagonal/>'
'</border>'
)
borders_xml += '</borders>'
# ── Cell XFs ───────────────────────────────────────────────
# xf 0: default (no style)
# xf 1: centered with thin border (for pin cells)
# xf 2: bold + centered (for A1 package info)
# xf 3: centered + border + light fill (for header-like)
cell_xfs_xml = '<cellXfs count="4">'
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" '
'applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" '
'applyFont="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += (
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" '
'applyFill="1" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
)
cell_xfs_xml += '</cellXfs>'
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'] parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append( parts.append(
@@ -357,6 +299,250 @@ class StyledXLSXWriter:
parts.append('</styleSheet>') parts.append('</styleSheet>')
return '\n'.join(parts) return '\n'.join(parts)
# ── 模板样式构建F011─────────────────────────────────────────
@staticmethod
def _build_fonts_xml_from_template(fonts: list) -> str:
"""从模板字体列表构建 <fonts> XML。"""
parts = [f'<fonts count="{len(fonts)}">']
for f in fonts:
parts.append('<font>')
parts.append(f'<sz val="{f.size}"/>')
if f.bold:
parts.append('<b/>')
if f.italic:
parts.append('<i/>')
parts.append(f'<name val="{f.name}"/>')
color_val = f.color
if color_val and not color_val.startswith('FF'):
color_val = 'FF' + color_val
if color_val:
parts.append(f'<color rgb="{color_val}"/>')
parts.append('</font>')
parts.append('</fonts>')
return ''.join(parts)
@staticmethod
def _build_fills_xml_from_template(fills: list) -> str:
"""从模板填充列表构建 <fills> XML。"""
parts = [f'<fills count="{len(fills)}">']
for fl in fills:
parts.append('<fill>')
parts.append(f'<patternFill patternType="{fl.pattern_type}">')
if fl.fg_color:
color_val = fl.fg_color
if not color_val.startswith('FF'):
color_val = 'FF' + color_val
parts.append(f'<fgColor rgb="{color_val}"/>')
parts.append('</patternFill>')
parts.append('</fill>')
parts.append('</fills>')
return ''.join(parts)
@staticmethod
def _build_borders_xml_from_template(borders: list) -> str:
"""从模板边框列表构建 <borders> XML。"""
parts = [f'<borders count="{len(borders)}">']
for b in borders:
parts.append('<border>')
for side_name in ('left', 'right', 'top', 'bottom'):
style_val = getattr(b, side_name, 'none')
if style_val and style_val != 'none':
parts.append(f'<{side_name} style="{style_val}"/>')
else:
parts.append(f'<{side_name}/>')
parts.append('<diagonal/>')
parts.append('</border>')
parts.append('</borders>')
return ''.join(parts)
@staticmethod
def _build_cell_xfs_xml_from_template(
cell_xfs: list, fonts: list, fills: list, borders: list
) -> str:
"""从模板 cellXfs 列表构建 <cellXfs> XML。
保留 4 个样式槽位xf 0~3每个从模板对应位置的 xf 提取:
- xf 0: default (模板的 xf 0)
- xf 1: pin cells — 使用模板中带边框的 xf优先否则 fallback
- xf 2: A1 bold — 使用模板中带 bold 字体的 xf优先否则 fallback
- xf 3: header — 使用模板中带填充的 xf优先否则 fallback
"""
def _xf_to_attrs(xf: dict) -> str:
"""将 xf 信息字典转换为属性字符串。"""
attrs = [
f'numFmtId="{xf.get("numFmtId", "0")}"',
f'fontId="{xf.get("fontId", "0")}"',
f'fillId="{xf.get("fillId", "0")}"',
f'borderId="{xf.get("borderId", "0")}"',
f'xfId="{xf.get("xfId", "0")}"',
]
for attr_key in ('applyFont', 'applyFill', 'applyBorder', 'applyAlignment',
'applyNumberFormat', 'applyProtection'):
val = xf.get(attr_key, '')
if val:
attrs.append(f'{attr_key}="{val}"')
return ' '.join(attrs)
def _xf_to_alignment(xf: dict) -> str:
"""从 xf 信息字典生成对齐 XML片段。"""
h_align = xf.get('hAlign', '')
v_align = xf.get('vAlign', '')
wrap_text = xf.get('wrapText', '')
if h_align or v_align or wrap_text:
align_attrs = []
if h_align:
align_attrs.append(f'horizontal="{h_align}"')
if v_align:
align_attrs.append(f'vertical="{v_align}"')
if wrap_text:
align_attrs.append(f'wrapText="{wrap_text}"')
return f'<alignment {" ".join(align_attrs)}/>'
return ''
# 确保有足够的模板样式元素
def _get_or_default(lst, idx, default_name):
if idx < len(lst):
return idx
return 0
# ── 确定 4 个 xf 的映射 ──────────────────────────────────
# xf 0: 模板的 xf 0default
xf0 = cell_xfs[0] if len(cell_xfs) > 0 else {}
fi0 = _get_or_default(fonts, int(xf0.get('fontId', '0')), 'font')
fl0 = _get_or_default(fills, int(xf0.get('fillId', '0')), 'fill')
bd0 = _get_or_default(borders, int(xf0.get('borderId', '0')), 'border')
# xf 1: 寻找模板中有边框的 xfborderId > 0 或 applyBorder 非空)
xf1 = xf0
fi1, fl1, bd1 = fi0, fl0, bd0
for xf in cell_xfs:
bid = int(xf.get('borderId', '0'))
ab = xf.get('applyBorder', '')
if bid > 0 or ab:
xf1 = xf
fi1 = _get_or_default(fonts, int(xf1.get('fontId', '0')), 'font')
fl1 = _get_or_default(fills, int(xf1.get('fillId', '0')), 'fill')
bd1 = _get_or_default(borders, int(xf1.get('borderId', '0')), 'border')
break
# xf 2: 寻找模板中有 bold 字体的 xf
xf2 = xf0
fi2, fl2, bd2 = fi0, fl0, bd0
bold_font_id = None
for i, f in enumerate(fonts):
if f.bold:
bold_font_id = i
break
if bold_font_id is not None:
for xf in cell_xfs:
fid = int(xf.get('fontId', '0'))
if xf.get('applyFont', '') or fid == bold_font_id:
xf2 = xf
break
fi2 = bold_font_id
fl2 = _get_or_default(fills, int(xf2.get('fillId', '0')), 'fill')
bd2 = _get_or_default(borders, int(xf2.get('borderId', '0')), 'border')
# xf 3: 寻找模板中有填充的 xffillId > 0 或 applyFill 非空)
xf3 = xf0
fi3, fl3, bd3 = fi0, fl0, bd0
for xf in cell_xfs:
fid = int(xf.get('fillId', '0'))
af = xf.get('applyFill', '')
if fid > 0 or af:
xf3 = xf
fi3 = _get_or_default(fonts, int(xf3.get('fontId', '0')), 'font')
fl3 = _get_or_default(fills, int(xf3.get('fillId', '0')), 'fill')
bd3 = _get_or_default(borders, int(xf3.get('borderId', '0')), 'border')
break
# ── 构建 4 个 xf ──────────────────────────────────────────
parts = ['<cellXfs count="4">']
# xf 0: default (no style)
parts.append(f'<xf numFmtId="0" fontId="{fi0}" fillId="{fl0}" borderId="{bd0}" xfId="0"/>')
# xf 1: pin cells (border + center align)
align1 = _xf_to_alignment(xf1)
parts.append(
f'<xf numFmtId="0" fontId="{fi1}" fillId="{fl1}" borderId="{bd1}" xfId="0" applyBorder="1">'
f'{align1}'
f'</xf>'
)
# xf 2: A1 package info (bold + center align)
align2 = _xf_to_alignment(xf2) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi2}" fillId="{fl2}" borderId="{bd2}" xfId="0" applyFont="1">'
f'{align2}'
f'</xf>'
)
# xf 3: header-like (fill + border + center align)
align3 = _xf_to_alignment(xf3) or '<alignment horizontal="center" vertical="center"/>'
parts.append(
f'<xf numFmtId="0" fontId="{fi3}" fillId="{fl3}" borderId="{bd3}" xfId="0" applyFill="1" applyBorder="1">'
f'{align3}'
f'</xf>'
)
parts.append('</cellXfs>')
return ''.join(parts)
# ── 默认硬编码样式(无模板时回退)────────────────────────────
@staticmethod
def _default_fonts_xml() -> str:
"""生成默认硬编码字体font 0 = Calibri 11pt, font 1 = Calibri 11pt bold。"""
return (
'<fonts count="2">'
'<font><sz val="11"/><name val="Calibri"/><color rgb="FF000000"/></font>'
'<font><sz val="11"/><b/><name val="Calibri"/><color rgb="FF000000"/></font>'
'</fonts>'
)
@staticmethod
def _default_fills_xml() -> str:
"""生成默认硬编码填充fill 0 = none, fill 1 = light gray。"""
return (
'<fills count="2">'
'<fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="solid"><fgColor rgb="FFF0F0F0"/></patternFill></fill>'
'</fills>'
)
@staticmethod
def _default_borders_xml() -> str:
"""生成默认硬编码边框border 0 = none, border 1 = thin all sides。"""
return (
'<borders count="2">'
'<border><left/><right/><top/><bottom/><diagonal/></border>'
'<border><left style="thin"/><right style="thin"/>'
'<top style="thin"/><bottom style="thin"/><diagonal/></border>'
'</borders>'
)
@staticmethod
def _default_cell_xfs_xml() -> str:
"""生成默认硬编码 cellXfs
xf 0 = default, xf 1 = centered+border, xf 2 = bold+centered, xf 3 = centered+border+fill。
"""
return (
'<cellXfs count="4">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'<xf numFmtId="0" fontId="0" fillId="1" borderId="1" xfId="0" applyFill="1" applyBorder="1">'
'<alignment horizontal="center" vertical="center"/>'
'</xf>'
'</cellXfs>'
)
def _sheet_xml(self, data: dict[str, str]) -> str: def _sheet_xml(self, data: dict[str, str]) -> str:
"""Build sheet1.xml with style indices applied.""" """Build sheet1.xml with style indices applied."""
max_row = 0 max_row = 0