|
@@ -147,14 +147,18 @@ def _resolve_fonts(config: ReportConfig, profile) -> dict:
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _duplicate_master_slide(prs, profile, page_type: str):
|
|
|
|
|
- """Duplicate the appropriate master slide for the given page_type."""
|
|
|
|
|
|
|
+def _duplicate_master_slide(prs, profile, page_type: str, keep_shapes: bool = False):
|
|
|
|
|
+ """Duplicate the appropriate master slide for the given page_type.
|
|
|
|
|
+
|
|
|
|
|
+ keep_shapes=True: keep layout-inherited placeholders (cover/toc/end pages).
|
|
|
|
|
+ keep_shapes=False: remove layout placeholders and copy from source (content pages).
|
|
|
|
|
+ """
|
|
|
idx = profile.get_master_index_for(page_type)
|
|
idx = profile.get_master_index_for(page_type)
|
|
|
if 0 <= idx < len(prs.slides):
|
|
if 0 <= idx < len(prs.slides):
|
|
|
source = prs.slides[idx]
|
|
source = prs.slides[idx]
|
|
|
else:
|
|
else:
|
|
|
source = prs.slides[0]
|
|
source = prs.slides[0]
|
|
|
- return _duplicate_slide(prs, source)
|
|
|
|
|
|
|
+ return _duplicate_slide(prs, source, keep_shapes=keep_shapes)
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_forecast_page_type(page_type: str) -> bool:
|
|
def _is_forecast_page_type(page_type: str) -> bool:
|
|
@@ -228,13 +232,61 @@ def _delete_template_slides(prs, count=None):
|
|
|
del prs.slides._sldIdLst[0]
|
|
del prs.slides._sldIdLst[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _duplicate_slide(prs, source_slide):
|
|
|
|
|
- # Use last available layout (typically blank) to avoid index errors on custom templates
|
|
|
|
|
- layout_idx = min(6, len(prs.slide_layouts) - 1)
|
|
|
|
|
- blank_layout = prs.slide_layouts[layout_idx]
|
|
|
|
|
- new_slide = prs.slides.add_slide(blank_layout)
|
|
|
|
|
-
|
|
|
|
|
- # Copy slide background (solid, gradient, image) from source
|
|
|
|
|
|
|
+def copy_layout_decorative_shapes(slide, layout):
|
|
|
|
|
+ """Copy non-placeholder decorative shapes from a layout to a slide.
|
|
|
|
|
+
|
|
|
|
|
+ python-pptx's add_slide(layout) does NOT copy layout-level decorative
|
|
|
|
|
+ shapes (gradient rectangles, logos, decorative lines) to the slide's
|
|
|
|
|
+ spTree. PowerPoint renders them from the layout reference, but this
|
|
|
|
|
+ is unreliable across PowerPoint versions.
|
|
|
|
|
+
|
|
|
|
|
+ This function deep-copies all <p:sp> elements from the layout's spTree
|
|
|
|
|
+ that do NOT contain a <p:ph> (placeholder) element into the slide's spTree.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ slide: The slide to add shapes to (from prs.slides.add_slide(layout)).
|
|
|
|
|
+ layout: The SlideLayout whose decorative shapes should be copied.
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: Number of shapes copied.
|
|
|
|
|
+ """
|
|
|
|
|
+ from copy import deepcopy
|
|
|
|
|
+ from pptx.oxml.ns import qn
|
|
|
|
|
+
|
|
|
|
|
+ layout_spTree = layout._element.find(qn('p:cSld')).find(qn('p:spTree'))
|
|
|
|
|
+ slide_spTree = slide._element.find(qn('p:cSld')).find(qn('p:spTree'))
|
|
|
|
|
+
|
|
|
|
|
+ count = 0
|
|
|
|
|
+ for child in list(layout_spTree):
|
|
|
|
|
+ tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
|
|
|
|
+ if tag == 'sp':
|
|
|
|
|
+ # Check if this shape is a placeholder (has <p:ph> element)
|
|
|
|
|
+ ph = child.find('.//' + qn('p:ph'))
|
|
|
|
|
+ if ph is None:
|
|
|
|
|
+ new_shape = deepcopy(child)
|
|
|
|
|
+ slide_spTree.append(new_shape)
|
|
|
|
|
+ count += 1
|
|
|
|
|
+ return count
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _duplicate_slide(prs, source_slide, keep_shapes: bool = False):
|
|
|
|
|
+ # Use the SOURCE slide's own layout to preserve:
|
|
|
|
|
+ # - layout-level background (gradient, color, image)
|
|
|
|
|
+ # - layout-level shapes (company logo, decorative icons)
|
|
|
|
|
+ # - theme colors, fonts
|
|
|
|
|
+ # Previously used blank_layout which stripped all of the above.
|
|
|
|
|
+ source_layout = source_slide.slide_layout
|
|
|
|
|
+ new_slide = prs.slides.add_slide(source_layout)
|
|
|
|
|
+
|
|
|
|
|
+ if not keep_shapes:
|
|
|
|
|
+ # Remove layout-default shapes (placeholders) from the new slide —
|
|
|
|
|
+ # they'll be replaced by shapes deep-copied from the source slide.
|
|
|
|
|
+ # Layout-level decorative shapes (logos, backgrounds) are NOT in
|
|
|
|
|
+ # slide.shapes and remain intact via layout inheritance.
|
|
|
|
|
+ for shape in list(new_slide.shapes):
|
|
|
|
|
+ sp = shape._element
|
|
|
|
|
+ sp.getparent().remove(sp)
|
|
|
|
|
+
|
|
|
|
|
+ # Copy slide-level background override if present (rare, but safe)
|
|
|
try:
|
|
try:
|
|
|
src_cSld = source_slide._element.cSld
|
|
src_cSld = source_slide._element.cSld
|
|
|
new_cSld = new_slide._element.cSld
|
|
new_cSld = new_slide._element.cSld
|
|
@@ -245,11 +297,12 @@ def _duplicate_slide(prs, source_slide):
|
|
|
new_cSld.insert(0, new_bg)
|
|
new_cSld.insert(0, new_bg)
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
-
|
|
|
|
|
- for shape in source_slide.shapes:
|
|
|
|
|
- el = shape.element
|
|
|
|
|
- new_el = copy.deepcopy(el)
|
|
|
|
|
- new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if not keep_shapes:
|
|
|
|
|
+ for shape in source_slide.shapes:
|
|
|
|
|
+ el = shape.element
|
|
|
|
|
+ new_el = copy.deepcopy(el)
|
|
|
|
|
+ new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
|
|
|
return new_slide
|
|
return new_slide
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1180,7 +1233,25 @@ def _build_without_save(data_file, temp_config, original_config):
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_cover_page(prs, config, colors, fonts, template_profile):
|
|
def _build_cover_page(prs, config, colors, fonts, template_profile):
|
|
|
- slide = _duplicate_master_slide(prs, template_profile, 'cover')
|
|
|
|
|
|
|
+ """Build cover page from template.
|
|
|
|
|
+
|
|
|
|
|
+ Two-pass strategy:
|
|
|
|
|
+ 1. Pattern-based: _replace_all_placeholders() for templates with
|
|
|
|
|
+ {report_title}/{date}/{department} text markers in placeholders.
|
|
|
|
|
+ 2. Idx-based fallback: for templates where placeholders are empty or
|
|
|
|
|
+ have template-default text (e.g. Wuling's 封面半版), fill by
|
|
|
|
|
+ placeholder_format.idx directly.
|
|
|
|
|
+
|
|
|
|
|
+ IMPORTANT — text color pitfall:
|
|
|
|
|
+ Many template covers have a decorative gradient/banner that covers only
|
|
|
|
|
+ the top portion of the slide. The TITLE placeholder may be positioned
|
|
|
|
|
+ BELOW the colored area (on white background). Using white/light text
|
|
|
|
|
+ on a white background makes it invisible.
|
|
|
|
|
+ → Always use dark text (C_PRIMARY) in the idx fallback to avoid this.
|
|
|
|
|
+ """
|
|
|
|
|
+ slide = _duplicate_master_slide(prs, template_profile, 'cover', keep_shapes=True)
|
|
|
|
|
+
|
|
|
|
|
+ # ---- Pass 1: pattern-based replacement ----
|
|
|
_replace_all_placeholders(slide, {
|
|
_replace_all_placeholders(slide, {
|
|
|
'{report_title}': config.title,
|
|
'{report_title}': config.title,
|
|
|
'{report_type}': '数据报告',
|
|
'{report_type}': '数据报告',
|
|
@@ -1190,8 +1261,90 @@ def _build_cover_page(prs, config, colors, fonts, template_profile):
|
|
|
'{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
'{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
|
}, fonts)
|
|
}, fonts)
|
|
|
_remove_empty_cover_kpi_placeholders(slide)
|
|
_remove_empty_cover_kpi_placeholders(slide)
|
|
|
|
|
+
|
|
|
|
|
+ # ---- Pass 2: idx-based fallback ----
|
|
|
|
|
+ # If the template has no {report_title} etc. text markers, pass 1 is a
|
|
|
|
|
+ # no-op. Detect unfilled placeholders by idx and fill them directly.
|
|
|
|
|
+ # Common idx mappings (from OOXML spec + Wuling/real-world templates):
|
|
|
|
|
+ # idx=0 → TITLE placeholder → report title
|
|
|
|
|
+ # idx=10 → SUBTITLE placeholder → date / subtitle (if idx=21 absent)
|
|
|
|
|
+ # idx=21 → BODY quarter-size → date / period string
|
|
|
|
|
+ # idx=22 → BODY quarter-size → department / source
|
|
|
|
|
+ TEMPLATE_DEFAULT_PATTERNS = {
|
|
|
|
|
+ '单击此处编辑母版标题样式', '单击此处添加标题',
|
|
|
|
|
+ '单击此处编辑母版文本样式', '单击此处添加文本',
|
|
|
|
|
+ '单击此处添加副标题',
|
|
|
|
|
+ }
|
|
|
|
|
+ _colors = colors or {}
|
|
|
|
|
+ _C_PRIMARY = _colors.get('primary', C_PRIMARY)
|
|
|
|
|
+ _C_TEXT_GRAY = _colors.get('text_gray', C_TEXT_GRAY)
|
|
|
|
|
+ _title_font = (fonts or {}).get('title_font', '微软雅黑')
|
|
|
|
|
+ _body_font = (fonts or {}).get('body_font', '微软雅黑')
|
|
|
|
|
+
|
|
|
|
|
+ date_text = config.period_str or (
|
|
|
|
|
+ config.date_range[0].strftime('%Y年%m月') if config.date_range else ''
|
|
|
|
|
+ )
|
|
|
|
|
+ dept_text = config.source_label or ''
|
|
|
|
|
+
|
|
|
|
|
+ filled_title = False
|
|
|
|
|
+ filled_date = False
|
|
|
|
|
+ for shape in slide.shapes:
|
|
|
|
|
+ if not shape.is_placeholder or not shape.has_text_frame:
|
|
|
|
|
+ continue
|
|
|
|
|
+ ph = shape.placeholder_format
|
|
|
|
|
+ tf = shape.text_frame
|
|
|
|
|
+ current_text = tf.text.strip()
|
|
|
|
|
+ is_unfilled = (
|
|
|
|
|
+ not current_text
|
|
|
|
|
+ or current_text in TEMPLATE_DEFAULT_PATTERNS
|
|
|
|
|
+ or any(tpl in current_text for tpl in TEMPLATE_DEFAULT_PATTERNS)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # idx=0 TITLE — report title (highest priority)
|
|
|
|
|
+ if ph.idx == 0 and (is_unfilled or not filled_title):
|
|
|
|
|
+ p = tf.paragraphs[0]
|
|
|
|
|
+ _set_para_text(p, config.title, _C_PRIMARY, Pt(36),
|
|
|
|
|
+ bold=True, font_name=_title_font)
|
|
|
|
|
+ filled_title = True
|
|
|
|
|
+
|
|
|
|
|
+ # idx=10 SUBTITLE — date (only if idx=21 was not filled)
|
|
|
|
|
+ elif ph.idx == 10 and (is_unfilled or not filled_date):
|
|
|
|
|
+ p = tf.paragraphs[0]
|
|
|
|
|
+ _set_para_text(p, date_text, _C_PRIMARY, Pt(18),
|
|
|
|
|
+ font_name=_body_font)
|
|
|
|
|
+ filled_date = True
|
|
|
|
|
+
|
|
|
|
|
+ # idx=21 BODY quarter-size — date/period
|
|
|
|
|
+ elif ph.idx == 21 and (is_unfilled or not filled_date):
|
|
|
|
|
+ p = tf.paragraphs[0]
|
|
|
|
|
+ _set_para_text(p, date_text, _C_PRIMARY, Pt(18),
|
|
|
|
|
+ font_name=_body_font)
|
|
|
|
|
+ filled_date = True
|
|
|
|
|
+
|
|
|
|
|
+ # idx=22 BODY quarter-size — department/source
|
|
|
|
|
+ elif ph.idx == 22 and is_unfilled:
|
|
|
|
|
+ p = tf.paragraphs[0]
|
|
|
|
|
+ _set_para_text(p, dept_text, _C_TEXT_GRAY, Pt(12),
|
|
|
|
|
+ font_name=_body_font)
|
|
|
|
|
+
|
|
|
total = len([p for p in config.pages if p.selected]) or len(config.pages)
|
|
total = len([p for p in config.pages if p.selected]) or len(config.pages)
|
|
|
- _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}', slide_width=prs.slide_width, colors=colors)
|
|
|
|
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}',
|
|
|
|
|
+ slide_width=prs.slide_width, colors=colors)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _set_para_text(para, text, color, size, bold=False, font_name=None):
|
|
|
|
|
+ """Set paragraph text + formatting, reusing existing run or creating new one."""
|
|
|
|
|
+ para.text = ''
|
|
|
|
|
+ if para.runs:
|
|
|
|
|
+ run = para.runs[0]
|
|
|
|
|
+ else:
|
|
|
|
|
+ run = para.add_run()
|
|
|
|
|
+ run.text = text
|
|
|
|
|
+ run.font.color.rgb = color
|
|
|
|
|
+ run.font.size = size
|
|
|
|
|
+ run.font.bold = bold
|
|
|
|
|
+ if font_name:
|
|
|
|
|
+ run.font.name = font_name
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, fonts, content_top, ctx=None):
|
|
def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, fonts, content_top, ctx=None):
|
|
@@ -1314,7 +1467,7 @@ def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, c
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_toc_page(prs, config, colors, fonts, template_profile):
|
|
def _build_toc_page(prs, config, colors, fonts, template_profile):
|
|
|
- slide = _duplicate_master_slide(prs, template_profile, 'toc')
|
|
|
|
|
|
|
+ slide = _duplicate_master_slide(prs, template_profile, 'toc', keep_shapes=True)
|
|
|
active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
|
|
active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
|
|
|
_replace_all_placeholders(slide, {
|
|
_replace_all_placeholders(slide, {
|
|
|
'{report_title}': config.title,
|
|
'{report_title}': config.title,
|
|
@@ -1844,7 +1997,7 @@ def _build_summary_page(prs, config, metrics, profile, colors, fonts, content_to
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_end_page(prs, config, colors, fonts, template_profile):
|
|
def _build_end_page(prs, config, colors, fonts, template_profile):
|
|
|
- slide = _duplicate_master_slide(prs, template_profile, "end")
|
|
|
|
|
|
|
+ slide = _duplicate_master_slide(prs, template_profile, "end", keep_shapes=True)
|
|
|
total = len([p for p in config.pages if p.selected])
|
|
total = len([p for p in config.pages if p.selected])
|
|
|
_add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}', colors=colors)
|
|
_add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}', colors=colors)
|
|
|
_replace_all_placeholders(slide, {
|
|
_replace_all_placeholders(slide, {
|