536 lines
19 KiB
Python
536 lines
19 KiB
Python
|
|
"""绘图辅助函数 — 在 slide 上添加文本、形状、表格、图片、页眉/页脚"""
|
|||
|
|
|
|||
|
|
import io
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
from urllib.request import urlopen, Request
|
|||
|
|
import base64
|
|||
|
|
|
|||
|
|
from PIL import Image as PILImage
|
|||
|
|
from pptx.util import Inches, Pt, Emu
|
|||
|
|
from pptx.dml.color import RGBColor
|
|||
|
|
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
|||
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rgb(hex_str: str) -> RGBColor:
|
|||
|
|
"""'1E2761' -> RGBColor"""
|
|||
|
|
return RGBColor.from_string(hex_str)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_slide_bg(slide, color_hex: str):
|
|||
|
|
"""设置幻灯片纯色背景"""
|
|||
|
|
bg = slide.background
|
|||
|
|
fill = bg.fill
|
|||
|
|
fill.solid()
|
|||
|
|
fill.fore_color.rgb = _rgb(color_hex)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_text(slide, text: str, x, y, w, h, *,
|
|||
|
|
font_size=14, font_name="微软雅黑", color="212121",
|
|||
|
|
bold=False, italic=False,
|
|||
|
|
align="left", valign="top",
|
|||
|
|
word_wrap=True, margin_pt=None):
|
|||
|
|
"""在 slide 上添加文本框,返回 shape"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
width = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
height = Inches(h) if isinstance(h, (int, float)) else h
|
|||
|
|
|
|||
|
|
txBox = slide.shapes.add_textbox(left, top, width, height)
|
|||
|
|
tf = txBox.text_frame
|
|||
|
|
tf.word_wrap = word_wrap
|
|||
|
|
|
|||
|
|
if margin_pt is not None:
|
|||
|
|
tf.margin_left = Pt(margin_pt)
|
|||
|
|
tf.margin_right = Pt(margin_pt)
|
|||
|
|
tf.margin_top = Pt(margin_pt)
|
|||
|
|
tf.margin_bottom = Pt(margin_pt)
|
|||
|
|
|
|||
|
|
# 段落
|
|||
|
|
p = tf.paragraphs[0]
|
|||
|
|
p.text = text
|
|||
|
|
|
|||
|
|
align_map = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
|
|||
|
|
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
|||
|
|
|
|||
|
|
# 字体
|
|||
|
|
run = p.runs[0] if p.runs else p.add_run()
|
|||
|
|
if not p.runs:
|
|||
|
|
run.text = text
|
|||
|
|
run.font.size = Pt(font_size)
|
|||
|
|
run.font.name = font_name
|
|||
|
|
run.font.color.rgb = _rgb(color)
|
|||
|
|
run.font.bold = bold
|
|||
|
|
run.font.italic = italic
|
|||
|
|
|
|||
|
|
# 垂直对齐
|
|||
|
|
from pptx.oxml.ns import qn
|
|||
|
|
bodyPr = tf._txBody.find(qn("a:bodyPr"))
|
|||
|
|
if bodyPr is not None:
|
|||
|
|
anchor_map = {"top": "t", "middle": "ctr", "bottom": "b"}
|
|||
|
|
bodyPr.set("anchor", anchor_map.get(valign, "t"))
|
|||
|
|
|
|||
|
|
return txBox
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_rich_text(slide, parts, x, y, w, h, *,
|
|||
|
|
font_name="微软雅黑", align="left", valign="top",
|
|||
|
|
line_spacing=1.2, margin_pt=None):
|
|||
|
|
"""添加富文本框 — parts 为 [{"text": ..., "size": ..., "color": ..., "bold": ...}, ...]
|
|||
|
|
支持 newline=True 换行"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
width = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
height = Inches(h) if isinstance(h, (int, float)) else h
|
|||
|
|
|
|||
|
|
txBox = slide.shapes.add_textbox(left, top, width, height)
|
|||
|
|
tf = txBox.text_frame
|
|||
|
|
tf.word_wrap = True
|
|||
|
|
|
|||
|
|
if margin_pt is not None:
|
|||
|
|
tf.margin_left = Pt(margin_pt)
|
|||
|
|
tf.margin_right = Pt(margin_pt)
|
|||
|
|
tf.margin_top = Pt(margin_pt)
|
|||
|
|
tf.margin_bottom = Pt(margin_pt)
|
|||
|
|
|
|||
|
|
align_map = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
|
|||
|
|
|
|||
|
|
p = tf.paragraphs[0]
|
|||
|
|
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
|||
|
|
|
|||
|
|
for i, part in enumerate(parts):
|
|||
|
|
if part.get("newline") and i > 0:
|
|||
|
|
p = tf.add_paragraph()
|
|||
|
|
p.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
|||
|
|
if line_spacing != 1.0:
|
|||
|
|
p.line_spacing = Pt(int(part.get("size", 14) * line_spacing))
|
|||
|
|
|
|||
|
|
run = p.add_run()
|
|||
|
|
run.text = part["text"]
|
|||
|
|
run.font.size = Pt(part.get("size", 14))
|
|||
|
|
run.font.name = part.get("font", font_name)
|
|||
|
|
run.font.color.rgb = _rgb(part.get("color", "212121"))
|
|||
|
|
run.font.bold = part.get("bold", False)
|
|||
|
|
run.font.italic = part.get("italic", False)
|
|||
|
|
|
|||
|
|
return txBox
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_shape(slide, shape_type, x, y, w, h, *,
|
|||
|
|
fill=None, line_color=None, line_width=None):
|
|||
|
|
"""添加形状"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
width = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
height = Inches(h) if isinstance(h, (int, float)) else h
|
|||
|
|
|
|||
|
|
shape = slide.shapes.add_shape(shape_type, left, top, width, height)
|
|||
|
|
|
|||
|
|
if fill:
|
|||
|
|
shape.fill.solid()
|
|||
|
|
shape.fill.fore_color.rgb = _rgb(fill)
|
|||
|
|
else:
|
|||
|
|
shape.fill.background()
|
|||
|
|
|
|||
|
|
if line_color:
|
|||
|
|
shape.line.color.rgb = _rgb(line_color)
|
|||
|
|
shape.line.width = Pt(line_width or 1)
|
|||
|
|
else:
|
|||
|
|
shape.line.fill.background()
|
|||
|
|
|
|||
|
|
return shape
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_rect(slide, x, y, w, h, **kwargs):
|
|||
|
|
return add_shape(slide, MSO_SHAPE.RECTANGLE, x, y, w, h, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_rounded_rect(slide, x, y, w, h, **kwargs):
|
|||
|
|
return add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_line(slide, x, y, w, *, color="E0E0E0", width=1):
|
|||
|
|
"""添加水平线"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
w_emu = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
|
|||
|
|
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, w_emu, Pt(width))
|
|||
|
|
shape.fill.solid()
|
|||
|
|
shape.fill.fore_color.rgb = _rgb(color)
|
|||
|
|
shape.line.fill.background()
|
|||
|
|
return shape
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Picture ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _resolve_image_source(image_src):
|
|||
|
|
"""将图片来源统一为 (BytesIO stream, (width, height)) 元组
|
|||
|
|
|
|||
|
|
支持三种来源:
|
|||
|
|
- 本地文件路径 (str / Path)
|
|||
|
|
- URL (http:// / https://)
|
|||
|
|
- base64 字符串 ("data:image/...;base64,..." 或裸 base64)
|
|||
|
|
|
|||
|
|
返回 (stream, (img_w, img_h))。stream 可直接传给 python-pptx。
|
|||
|
|
"""
|
|||
|
|
src = str(image_src)
|
|||
|
|
|
|||
|
|
if src.startswith(("http://", "https://")):
|
|||
|
|
req = Request(src, headers={"User-Agent": "ppt-station/1.0"})
|
|||
|
|
resp = urlopen(req, timeout=30)
|
|||
|
|
raw_bytes = resp.read()
|
|||
|
|
elif src.startswith("data:"):
|
|||
|
|
# data URI: "data:image/png;base64,iVBOR..."
|
|||
|
|
b64_data = src.split(";base64,", 1)[1]
|
|||
|
|
raw_bytes = base64.b64decode(b64_data)
|
|||
|
|
else:
|
|||
|
|
# 先尝试本地文件,不存在则当作裸 base64
|
|||
|
|
p = Path(src).expanduser().resolve()
|
|||
|
|
if p.exists():
|
|||
|
|
raw_bytes = p.read_bytes()
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
raw_bytes = base64.b64decode(src)
|
|||
|
|
except Exception:
|
|||
|
|
raise FileNotFoundError(f"图片文件不存在: {p}")
|
|||
|
|
|
|||
|
|
# 用独立 BytesIO 读尺寸,避免 PIL 关闭影响主 stream
|
|||
|
|
with PILImage.open(io.BytesIO(raw_bytes)) as pil:
|
|||
|
|
size = pil.size
|
|||
|
|
|
|||
|
|
return io.BytesIO(raw_bytes), size
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_picture(slide, image_src, x, y, w, h, *, sizing="stretch"):
|
|||
|
|
"""在 slide 上插入图片,返回 Picture shape
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
image_src: 图片来源 — 本地路径 / URL / base64 字符串
|
|||
|
|
x, y, w, h: 目标区域(英寸或 Emu)
|
|||
|
|
sizing:
|
|||
|
|
"stretch" — 拉伸填满目标区域(可能变形)
|
|||
|
|
"contain" — 等比缩放适配,居中显示(可能留白)
|
|||
|
|
"cover" — 等比缩放裁切填满(无变形无留白)
|
|||
|
|
"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
width = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
height = Inches(h) if isinstance(h, (int, float)) else h
|
|||
|
|
|
|||
|
|
stream, (img_w, img_h) = _resolve_image_source(image_src)
|
|||
|
|
|
|||
|
|
if sizing == "stretch":
|
|||
|
|
return slide.shapes.add_picture(stream, left, top, width, height)
|
|||
|
|
|
|||
|
|
target_ratio = w / h if isinstance(w, (int, float)) and isinstance(h, (int, float)) else (width / height)
|
|||
|
|
img_ratio = img_w / img_h
|
|||
|
|
|
|||
|
|
if sizing == "contain":
|
|||
|
|
if img_ratio > target_ratio:
|
|||
|
|
# 图片更宽 → 宽度撑满,高度居中
|
|||
|
|
final_w = w if isinstance(w, (int, float)) else width
|
|||
|
|
final_h = (w / img_ratio) if isinstance(w, (int, float)) else int(width / img_ratio)
|
|||
|
|
else:
|
|||
|
|
# 图片更高 → 高度撑满,宽度居中
|
|||
|
|
final_h = h if isinstance(h, (int, float)) else height
|
|||
|
|
final_w = (h * img_ratio) if isinstance(h, (int, float)) else int(height * img_ratio)
|
|||
|
|
|
|||
|
|
if isinstance(w, (int, float)):
|
|||
|
|
offset_x = x + (w - final_w) / 2
|
|||
|
|
offset_y = y + (h - final_h) / 2
|
|||
|
|
return slide.shapes.add_picture(
|
|||
|
|
stream,
|
|||
|
|
Inches(offset_x), Inches(offset_y),
|
|||
|
|
Inches(final_w), Inches(final_h))
|
|||
|
|
else:
|
|||
|
|
offset_x = left + (width - final_w) // 2
|
|||
|
|
offset_y = top + (height - final_h) // 2
|
|||
|
|
return slide.shapes.add_picture(stream, offset_x, offset_y, final_w, final_h)
|
|||
|
|
|
|||
|
|
if sizing == "cover":
|
|||
|
|
# 先拉伸填满目标区域
|
|||
|
|
pic = slide.shapes.add_picture(stream, left, top, width, height)
|
|||
|
|
# 通过 srcRect crop 恢复宽高比
|
|||
|
|
if img_ratio > target_ratio:
|
|||
|
|
# 图片更宽 → 裁左右
|
|||
|
|
crop = (1 - target_ratio / img_ratio) / 2
|
|||
|
|
_set_crop(pic, left=crop, right=crop)
|
|||
|
|
elif img_ratio < target_ratio:
|
|||
|
|
# 图片更高 → 裁上下
|
|||
|
|
crop = (1 - img_ratio / target_ratio) / 2
|
|||
|
|
_set_crop(pic, top=crop, bottom=crop)
|
|||
|
|
return pic
|
|||
|
|
|
|||
|
|
raise ValueError(f"未知 sizing 模式: {sizing}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _set_crop(pic, *, left=0, right=0, top=0, bottom=0):
|
|||
|
|
"""通过 OOXML srcRect 设置图片裁切"""
|
|||
|
|
from pptx.oxml.ns import qn
|
|||
|
|
from lxml import etree
|
|||
|
|
|
|||
|
|
blipFill = pic._element.find(qn("p:blipFill"))
|
|||
|
|
if blipFill is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
srcRect = blipFill.find(qn("a:srcRect"))
|
|||
|
|
if srcRect is None:
|
|||
|
|
srcRect = etree.SubElement(blipFill, qn("a:srcRect"))
|
|||
|
|
|
|||
|
|
# OOXML srcRect 用千分比(1000 = 1%,100000 = 100%)
|
|||
|
|
if left:
|
|||
|
|
srcRect.set("l", str(int(left * 100000)))
|
|||
|
|
if right:
|
|||
|
|
srcRect.set("r", str(int(right * 100000)))
|
|||
|
|
if top:
|
|||
|
|
srcRect.set("t", str(int(top * 100000)))
|
|||
|
|
if bottom:
|
|||
|
|
srcRect.set("b", str(int(bottom * 100000)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Content Page Setup ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def setup_content_page(slide, data, theme):
|
|||
|
|
"""内容页通用初始化:背景 + 页眉标题
|
|||
|
|
|
|||
|
|
返回常用布局参数元组 (m, sw, content_y, content_w, footer_y)。
|
|||
|
|
"""
|
|||
|
|
m = theme["margin"]
|
|||
|
|
sw = theme.get("slide_w", 13.333)
|
|||
|
|
content_y = theme.get("content_y", 1.00)
|
|||
|
|
content_w = theme.get("content_w", sw - 2 * m)
|
|||
|
|
footer_y = theme.get("footer_y", 7.10)
|
|||
|
|
|
|||
|
|
set_slide_bg(slide, theme["bg_light"])
|
|||
|
|
add_page_header(slide, data["title"], theme)
|
|||
|
|
|
|||
|
|
return m, sw, content_y, content_w, footer_y
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Page Header / Footer (投行级标准) ──────────────────────────────
|
|||
|
|
|
|||
|
|
def add_page_header(slide, title, theme):
|
|||
|
|
"""投行级标准页眉: 16pt 标题 + 全宽分隔线
|
|||
|
|
|
|||
|
|
使用 theme tokens: page_title_size, header_y, divider_y, divider_h, content_w
|
|||
|
|
"""
|
|||
|
|
m = theme["margin"]
|
|||
|
|
sw = theme.get("slide_w", 13.333)
|
|||
|
|
page_title_size = theme.get("page_title_size", 16)
|
|||
|
|
header_y = theme.get("header_y", 0.25)
|
|||
|
|
divider_y = theme.get("divider_y", 0.80)
|
|||
|
|
divider_h = theme.get("divider_h", 0.015)
|
|||
|
|
content_w = theme.get("content_w", sw - 2 * m)
|
|||
|
|
|
|||
|
|
# 页面标题
|
|||
|
|
add_text(slide, title,
|
|||
|
|
x=m, y=header_y, w=content_w, h=0.5,
|
|||
|
|
font_size=page_title_size, color=theme["text_dark"],
|
|||
|
|
bold=True, font_name=theme["header_font"])
|
|||
|
|
|
|||
|
|
# 全宽分隔线
|
|||
|
|
add_rect(slide, m, divider_y, content_w, divider_h, fill=theme["primary"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_page_footer(slide, theme, source="", page_num=None, brand=""):
|
|||
|
|
"""投行级标准页脚: 左侧来源 8pt + 右侧页码/品牌
|
|||
|
|
|
|||
|
|
使用 theme tokens: footer_size, footer_y, content_w
|
|||
|
|
"""
|
|||
|
|
m = theme["margin"]
|
|||
|
|
sw = theme.get("slide_w", 13.333)
|
|||
|
|
footer_size = theme.get("footer_size", 8)
|
|||
|
|
footer_y = theme.get("footer_y", 7.10)
|
|||
|
|
content_w = theme.get("content_w", sw - 2 * m)
|
|||
|
|
|
|||
|
|
# 页脚分隔细线
|
|||
|
|
add_rect(slide, m, footer_y - 0.08, content_w, 0.005, fill=theme["border"])
|
|||
|
|
|
|||
|
|
# 左侧: 来源
|
|||
|
|
if source:
|
|||
|
|
source_text = f"Source: {source}" if not source.startswith("Source") else source
|
|||
|
|
add_text(slide, source_text,
|
|||
|
|
x=m, y=footer_y, w=content_w * 0.7, h=0.25,
|
|||
|
|
font_size=footer_size, color=theme["text_muted"],
|
|||
|
|
italic=True, font_name=theme["body_font"])
|
|||
|
|
|
|||
|
|
# 右侧: 页码/品牌
|
|||
|
|
right_parts = []
|
|||
|
|
if brand:
|
|||
|
|
right_parts.append(brand)
|
|||
|
|
if page_num is not None:
|
|||
|
|
right_parts.append(str(page_num))
|
|||
|
|
if right_parts:
|
|||
|
|
add_text(slide, " | ".join(right_parts),
|
|||
|
|
x=m + content_w * 0.3, y=footer_y, w=content_w * 0.7, h=0.25,
|
|||
|
|
font_size=footer_size, color=theme["text_muted"],
|
|||
|
|
align="right", font_name=theme["body_font"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Table (投行级) ──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
_NUM_PATTERN = re.compile(r'^[\s]*[-+]?[\d,]+\.?\d*\s*[%xX倍万亿]?\s*$')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_numeric_cell(val: str) -> bool:
|
|||
|
|
"""检测单元格值是否为数值型(用于右对齐)"""
|
|||
|
|
if not val:
|
|||
|
|
return False
|
|||
|
|
return bool(_NUM_PATTERN.match(val))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_table(slide, headers, rows, x, y, w, h, *,
|
|||
|
|
theme=None, header_bg=None, header_color="FFFFFF",
|
|||
|
|
font_name="微软雅黑", font_size=11,
|
|||
|
|
highlight_row=None, highlight_bg="EBF5FF",
|
|||
|
|
highlight_cols=None,
|
|||
|
|
ib_style=True):
|
|||
|
|
"""添加带样式的表格
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
highlight_cols: 高亮列索引列表 (如 [2, 3])
|
|||
|
|
ib_style: True 启用投行级样式(无垂直边框、数值右对齐、斑马纹 token)
|
|||
|
|
"""
|
|||
|
|
left = Inches(x) if isinstance(x, (int, float)) else x
|
|||
|
|
top = Inches(y) if isinstance(y, (int, float)) else y
|
|||
|
|
width = Inches(w) if isinstance(w, (int, float)) else w
|
|||
|
|
height = Inches(h) if isinstance(h, (int, float)) else h
|
|||
|
|
|
|||
|
|
n_rows = len(rows) + 1
|
|||
|
|
n_cols = len(headers)
|
|||
|
|
|
|||
|
|
table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, width, height)
|
|||
|
|
table = table_shape.table
|
|||
|
|
|
|||
|
|
# 均匀列宽
|
|||
|
|
col_w = int(width / n_cols)
|
|||
|
|
for j in range(n_cols):
|
|||
|
|
table.columns[j].width = col_w
|
|||
|
|
|
|||
|
|
# 颜色 tokens
|
|||
|
|
hdr_bg = header_bg or (theme.get("table_header_bg", theme["primary"]) if theme else "36454F")
|
|||
|
|
zebra_even = theme.get("table_zebra_even", "F8FAFC") if theme else "F8FAFC"
|
|||
|
|
zebra_odd = theme.get("table_zebra_odd", "FFFFFF") if theme else "FFFFFF"
|
|||
|
|
highlight_col_bg = theme.get("table_zebra_even", "EBF5FF") if theme else "EBF5FF"
|
|||
|
|
|
|||
|
|
# 预扫描: 检测哪些列是数值列(用于自动右对齐)
|
|||
|
|
numeric_cols = set()
|
|||
|
|
if ib_style and rows:
|
|||
|
|
for j in range(n_cols):
|
|||
|
|
num_count = sum(1 for row in rows if len(row) > j and _is_numeric_cell(str(row[j] or "")))
|
|||
|
|
if num_count > len(rows) * 0.5:
|
|||
|
|
numeric_cols.add(j)
|
|||
|
|
|
|||
|
|
# 表头
|
|||
|
|
for j, hdr in enumerate(headers):
|
|||
|
|
cell = table.cell(0, j)
|
|||
|
|
cell.text = str(hdr)
|
|||
|
|
align = "right" if (ib_style and j in numeric_cols) else "center"
|
|||
|
|
_style_cell(cell, bg=hdr_bg, color=header_color,
|
|||
|
|
font_name=font_name, font_size=font_size, bold=True,
|
|||
|
|
align=align)
|
|||
|
|
|
|||
|
|
# 数据行
|
|||
|
|
for i, row in enumerate(rows):
|
|||
|
|
is_highlight = (highlight_row is not None and i == highlight_row)
|
|||
|
|
for j, val in enumerate(row):
|
|||
|
|
cell = table.cell(i + 1, j)
|
|||
|
|
cell_text = str(val) if val is not None else ""
|
|||
|
|
cell.text = cell_text
|
|||
|
|
|
|||
|
|
# 背景色优先级: highlight_row > highlight_cols > zebra
|
|||
|
|
if is_highlight:
|
|||
|
|
bg = highlight_bg
|
|||
|
|
elif highlight_cols and j in highlight_cols:
|
|||
|
|
bg = highlight_col_bg
|
|||
|
|
else:
|
|||
|
|
bg = zebra_even if i % 2 == 0 else zebra_odd
|
|||
|
|
|
|||
|
|
# 对齐: 数值列右对齐, 首列左对齐, 其余居中
|
|||
|
|
if ib_style and j in numeric_cols:
|
|||
|
|
align = "right"
|
|||
|
|
elif j == 0:
|
|||
|
|
align = "left"
|
|||
|
|
else:
|
|||
|
|
align = "center"
|
|||
|
|
|
|||
|
|
_style_cell(cell, bg=bg, color="212121",
|
|||
|
|
font_name=font_name, font_size=font_size,
|
|||
|
|
bold=is_highlight,
|
|||
|
|
align=align)
|
|||
|
|
|
|||
|
|
# 投行级: 移除垂直边框,仅保留水平线
|
|||
|
|
if ib_style:
|
|||
|
|
_remove_vertical_borders(table, n_rows, n_cols, hdr_bg)
|
|||
|
|
|
|||
|
|
return table_shape
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _remove_vertical_borders(table, n_rows, n_cols, header_color):
|
|||
|
|
"""移除垂直边框,仅保留表头上下 + 表格底部水平线"""
|
|||
|
|
from pptx.oxml.ns import qn
|
|||
|
|
from lxml import etree
|
|||
|
|
|
|||
|
|
for i in range(n_rows):
|
|||
|
|
for j in range(n_cols):
|
|||
|
|
cell = table.cell(i, j)
|
|||
|
|
tc = cell._tc
|
|||
|
|
tcPr = tc.find(qn("a:tcPr"))
|
|||
|
|
if tcPr is None:
|
|||
|
|
tcPr = etree.SubElement(tc, qn("a:tcPr"))
|
|||
|
|
|
|||
|
|
# 清除左右边框
|
|||
|
|
for border_name in ["lnL", "lnR"]:
|
|||
|
|
border = tcPr.find(qn(f"a:{border_name}"))
|
|||
|
|
if border is not None:
|
|||
|
|
tcPr.remove(border)
|
|||
|
|
border = etree.SubElement(tcPr, qn(f"a:{border_name}"))
|
|||
|
|
border.set("w", "0")
|
|||
|
|
noFill = etree.SubElement(border, qn("a:noFill"))
|
|||
|
|
|
|||
|
|
# 设置水平线: 表头底部 + 表格底部用主色细线,其余极浅
|
|||
|
|
for border_name in ["lnT", "lnB"]:
|
|||
|
|
border = tcPr.find(qn(f"a:{border_name}"))
|
|||
|
|
if border is not None:
|
|||
|
|
tcPr.remove(border)
|
|||
|
|
|
|||
|
|
is_header_bottom = (i == 0 and border_name == "lnB")
|
|||
|
|
is_table_bottom = (i == n_rows - 1 and border_name == "lnB")
|
|||
|
|
is_header_top = (i == 0 and border_name == "lnT")
|
|||
|
|
|
|||
|
|
border = etree.SubElement(tcPr, qn(f"a:{border_name}"))
|
|||
|
|
if is_header_top or is_header_bottom or is_table_bottom:
|
|||
|
|
border.set("w", "12700") # 1pt
|
|||
|
|
solidFill = etree.SubElement(border, qn("a:solidFill"))
|
|||
|
|
srgbClr = etree.SubElement(solidFill, qn("a:srgbClr"))
|
|||
|
|
srgbClr.set("val", header_color)
|
|||
|
|
else:
|
|||
|
|
border.set("w", "6350") # 0.5pt
|
|||
|
|
solidFill = etree.SubElement(border, qn("a:solidFill"))
|
|||
|
|
srgbClr = etree.SubElement(solidFill, qn("a:srgbClr"))
|
|||
|
|
srgbClr.set("val", "E2E8F0")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _style_cell(cell, *, bg, color, font_name, font_size, bold=False, align="left"):
|
|||
|
|
"""设置单元格样式"""
|
|||
|
|
cell.fill.solid()
|
|||
|
|
cell.fill.fore_color.rgb = _rgb(bg)
|
|||
|
|
|
|||
|
|
# 内边距
|
|||
|
|
cell.margin_left = Pt(6)
|
|||
|
|
cell.margin_right = Pt(6)
|
|||
|
|
cell.margin_top = Pt(4)
|
|||
|
|
cell.margin_bottom = Pt(4)
|
|||
|
|
|
|||
|
|
align_map = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
|
|||
|
|
|
|||
|
|
for paragraph in cell.text_frame.paragraphs:
|
|||
|
|
paragraph.alignment = align_map.get(align, PP_ALIGN.LEFT)
|
|||
|
|
for run in paragraph.runs:
|
|||
|
|
run.font.size = Pt(font_size)
|
|||
|
|
run.font.name = font_name
|
|||
|
|
run.font.color.rgb = _rgb(color)
|
|||
|
|
run.font.bold = bold
|