Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,326 @@
"""Native PowerPoint table/chart converters for explicit SVG metadata markers."""
from __future__ import annotations
import sys
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import _xml_escape
from .chart_data import _chart_data
from .chart_style import (
_axis_titles,
_chart_companion_entries,
_chart_companion_text_xml,
_chart_text_sizes,
_classic_chart_style,
_native_chart_chrome_errors,
_native_chart_chrome_warnings,
_native_chart_export_payload,
_validate_chart_companion_boxes,
)
from .chart_xml import _chart_rels_xml, _chart_xml
from .chartex import (
_chart_ex_colors_xml,
_chart_ex_rels_xml,
_chart_ex_style_xml,
_chart_ex_xml,
)
from .fallback_hash import (
native_fallback_contract_warnings,
require_fresh_native_fallback,
snapshot_native_fallback_freshness,
stamp_native_fallback_baseline,
)
from .marker_common import (
CHART_CONTENT_TYPE,
CHARTEX_CONTENT_TYPE,
CHARTEX_REL_TYPE,
CHARTEX_URI,
CHART_COLOR_STYLE_CONTENT_TYPE,
CHART_REL_TYPE,
CHART_STYLE_CONTENT_TYPE,
CHART_URI,
_NATIVE_KINDS,
_bounds,
_load_payload,
_local_tag,
_native_marker_validation_context,
_validate_bounds_inputs,
native_marker_transform,
)
from .marker_attributes import (
NativeMarkerAttributeError,
native_fallback_kind,
native_import_source,
native_metadata_payload_matches,
native_marker_legacy_warnings,
native_replacement_kind,
native_replacement_status,
)
from .marker_status import native_marker_status_errors
from .table import (
_build_native_table,
_native_table_warnings,
_validate_table_payload,
)
from .workbook import (
_minimal_category_chart_workbook,
_minimal_chart_ex_workbook,
_minimal_xy_chart_workbook,
)
__all__ = [
"convert_native_object",
"NativeMarkerAttributeError",
"native_fallback_kind",
"native_import_source",
"native_metadata_payload_matches",
"native_marker_legacy_warnings",
"native_object_marker_warnings",
"native_replacement_kind",
"native_replacement_status",
"native_marker_transform",
"snapshot_native_fallback_freshness",
"stamp_native_fallback_baseline",
"validate_native_object_marker",
"validate_native_object_marker_with_warnings",
]
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
chart_data = _chart_data(payload)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
shape_id = ctx.next_id()
rel_id = ctx.next_rel_id()
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
part_index = ctx.slide_num * 100 + local_index
workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
workbook_part = f"ppt/embeddings/{workbook_name}"
if chart_data["kind"] == "chartex":
chart_name = f"chartEx{part_index}.xml"
style_name = f"style{part_index}.xml"
colors_name = f"colors{part_index}.xml"
chart_part = f"ppt/charts/{chart_name}"
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
style_part = f"ppt/charts/{style_name}"
colors_part = f"ppt/charts/{colors_name}"
graphic_uri = CHARTEX_URI
chart_ref_xml = (
f'<cx:chart xmlns:cx="{CHARTEX_URI}" '
f'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
f'r:id="{rel_id}"/>'
)
ctx.rel_entries.append({
"id": rel_id,
"type": CHARTEX_REL_TYPE,
"target": f"../charts/{chart_name}",
})
ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
ctx.package_files[chart_rels_part] = _chart_ex_rels_xml(
f"../embeddings/{workbook_name}",
style_name,
colors_name,
)
ctx.package_files[style_part] = _chart_ex_style_xml()
ctx.package_files[colors_part] = _chart_ex_colors_xml(payload)
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
else:
chart_name = f"chart{part_index}.xml"
chart_part = f"ppt/charts/{chart_name}"
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
graphic_uri = CHART_URI
chart_ref_xml = (
'<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" '
f'r:id="{rel_id}"/>'
)
ctx.rel_entries.append({
"id": rel_id,
"type": CHART_REL_TYPE,
"target": f"../charts/{chart_name}",
})
ctx.package_files[chart_part] = _chart_xml(
elem,
payload,
chart_rels_id="rId1",
chart_data=chart_data,
inherited_styles=ctx.inherited_styles,
primary_language=ctx.primary_language,
)
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
if chart_data["kind"] == "xy":
ctx.package_files[workbook_part] = _minimal_xy_chart_workbook(chart_data)
else:
ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data)
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
chart_frame_xml = f'''<p:graphicFrame>
<p:nvGraphicFramePr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
<p:nvPr/>
</p:nvGraphicFramePr>
<p:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></p:xfrm>
<a:graphic>
<a:graphicData uri="{graphic_uri}">
{chart_ref_xml}
</a:graphicData>
</a:graphic>
</p:graphicFrame>'''
text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles)
companion_xml = _chart_companion_text_xml(
ctx,
payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
chart_style=chart_style,
note_font_size=text_sizes["note"],
title_font_size=text_sizes["title"],
include_title=chart_data["kind"] == "chartex",
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
xml = chart_frame_xml + companion_xml
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
def _validate_native_object_marker_payload(
elem: ET.Element,
*,
validate_chrome: bool = True,
ctx: ConvertContext | None = None,
ancestors: tuple[ET.Element, ...] = (),
require_fresh_fallback: bool = False,
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
try:
kind = native_replacement_kind(elem)
except NativeMarkerAttributeError as exc:
raise RuntimeError(str(exc)) from exc
if not kind:
return "", {}, None
status_errors = native_marker_status_errors(elem)
if status_errors:
raise RuntimeError("; ".join(status_errors))
if kind not in _NATIVE_KINDS:
raise RuntimeError(f"Unsupported data-pptx-replace-with value: {kind}")
if _local_tag(elem) != "g":
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
native_marker_transform(elem.get("transform"))
if require_fresh_fallback:
require_fresh_native_fallback(elem, use_runtime_snapshot=True)
try:
payload = _load_payload(elem, kind)
except NativeMarkerAttributeError as exc:
raise RuntimeError(str(exc)) from exc
bounds_ctx = ctx or _native_marker_validation_context(elem, ancestors)
off_x, off_y, ext_cx, ext_cy, _ = _validate_bounds_inputs(elem, payload, bounds_ctx)
table_rows = None
if kind == "table":
table_rows, col_count, _merge_layout = _validate_table_payload(payload)
if ext_cx < col_count or ext_cy < len(table_rows):
raise RuntimeError(
"Native PPTX table bounds must provide at least one EMU per row and column"
)
else:
chart_data = _chart_data(payload)
_validate_chart_companion_boxes(
payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
include_title=chart_data["kind"] == "chartex",
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
if validate_chrome and native_import_source(elem) != "pptx":
chrome_errors = _native_chart_chrome_errors(elem, payload)
if chrome_errors:
raise RuntimeError("; ".join(chrome_errors))
return kind, payload, table_rows
def validate_native_object_marker(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
) -> None:
"""Validate a chart/table replacement marker without mutating the package."""
_validate_native_object_marker_payload(elem, ancestors=ancestors)
def validate_native_object_marker_with_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Validate a chart/table replacement marker and return non-fatal warnings."""
kind, payload, table_rows = _validate_native_object_marker_payload(
elem,
ancestors=ancestors,
)
warnings = (
native_fallback_contract_warnings(
elem,
document_root=document_root,
)
if kind else []
)
if kind == "table" and table_rows is not None:
warnings.extend(_native_table_warnings(elem, table_rows))
elif kind == "chart":
warnings.extend(_native_chart_chrome_warnings(elem, payload))
return warnings
def native_object_marker_warnings(
elem: ET.Element,
*,
ancestors: tuple[ET.Element, ...] = (),
document_root: ET.Element | None = None,
) -> list[str]:
"""Return non-fatal warnings for a chart/table replacement marker."""
return validate_native_object_marker_with_warnings(
elem,
ancestors=ancestors,
document_root=document_root,
)
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert a marked SVG group to a native PowerPoint table or chart."""
try:
kind = native_replacement_kind(elem)
except NativeMarkerAttributeError as exc:
raise RuntimeError(str(exc)) from exc
if not kind:
return None
kind, payload, _ = _validate_native_object_marker_payload(
elem,
validate_chrome=False,
ctx=ctx,
require_fresh_fallback=True,
)
marker_id = elem.get("id") or "<unnamed>"
for warning in native_fallback_contract_warnings(
elem,
use_runtime_snapshot=True,
):
print(
f" Warning: data-pptx-replace-with marker {marker_id}: {warning}",
file=sys.stderr,
)
if kind == "table":
return _build_native_table(elem, ctx, payload)
payload, warnings = _native_chart_export_payload(elem, payload)
for warning in warnings:
print(
f" Warning: data-pptx-replace-with marker {marker_id}: {warning}",
file=sys.stderr,
)
return _build_native_chart(elem, ctx, payload)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,868 @@
"""Native chart styling and companion text helpers."""
from __future__ import annotations
import math
from typing import Any
from xml.etree import ElementTree as ET
from .marker_attributes import native_import_source
from ..drawingml.context import ConvertContext
from ..drawingml.utils import (
_xml_escape,
detect_text_lang,
parse_font_family,
px_to_emu,
quantize_ooxml_alpha,
text_has_rtl_characters,
text_uses_rtl,
)
from .chart_data import _DEFAULT_CHART_COLORS
from .marker_common import (
_bool_attr,
_bounds,
_chart_bool,
_clean_hex,
_compact_key,
_fallback_fill_candidates,
_fallback_stroke_colors,
_fallback_text_colors,
_first_present,
_font_size_hpt,
_hex_or_none,
_inferred_chart_background,
_local_tag,
_maybe_number,
_most_common_color,
_number,
_normalized_fallback_text,
_powerpoint_emu,
_powerpoint_emu_value,
_relative_luminance,
_style_attr,
_visible_fallback_texts,
)
def _chart_style_value(payload: dict[str, Any], *keys: str) -> Any:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
for source in (payload, style):
for key in keys:
if source.get(key) is not None:
return source.get(key)
return None
def _chart_style_color(
payload: dict[str, Any],
keys: tuple[str, ...],
default: str | None,
) -> str | None:
raw = _chart_style_value(payload, *keys)
if raw is None:
return default
if str(raw).strip().lower() in {"none", "transparent"}:
return None
return _hex_or_none(raw) or default
def _fallback_text_attr_values(
elem: ET.Element,
attr: str,
inherited_value: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
own_value = _style_attr(elem, attr)
next_value = own_value if own_value is not None else inherited_value
values: list[str] = []
if tag in {"text", "tspan"} and next_value:
values.append(str(next_value).strip())
for child in elem:
values.extend(_fallback_text_attr_values(child, attr, next_value))
return values
def _most_common_value(values: list[str]) -> str | None:
if not values:
return None
counts: dict[str, int] = {}
for value in values:
counts[value] = counts.get(value, 0) + 1
return max(counts.items(), key=lambda item: item[1])[0]
def _most_common_font_size(values: list[str]) -> str | None:
if not values:
return None
counts: dict[str, int] = {}
for value in values:
counts[value] = counts.get(value, 0) + 1
max_count = max(counts.values())
candidates = [value for value, count in counts.items() if count == max_count]
numeric_candidates = [
(float(value), value)
for value in candidates
if _maybe_number(value) is not None
]
if numeric_candidates:
return min(numeric_candidates, key=lambda item: item[0])[1]
return candidates[0]
def _classic_chart_style(
payload: dict[str, Any],
elem: ET.Element,
inherited_styles: dict[str, str] | None = None,
) -> dict[str, str | None]:
inherited_styles = inherited_styles or {}
fallback_background = _inferred_chart_background(elem)
text_color = _most_common_color(
_fallback_text_colors(elem, inherited_styles.get("fill"))
) or "404040"
stroke_colors = _fallback_stroke_colors(elem, inherited_styles.get("stroke"))
darkest_stroke = min(stroke_colors, key=_relative_luminance) if stroke_colors else None
lightest_stroke = max(stroke_colors, key=_relative_luminance) if stroke_colors else None
raw_font_face = _chart_style_value(payload, "font_family", "fontFamily", "font_face", "fontFace")
fallback_font_face = _most_common_value(
_fallback_text_attr_values(
elem,
"font-family",
inherited_styles.get("font-family"),
)
)
font_face = str(raw_font_face).strip() if raw_font_face is not None else fallback_font_face
axis_color = darkest_stroke or text_color
grid_color = (
lightest_stroke
if lightest_stroke and _relative_luminance(lightest_stroke) > _relative_luminance(axis_color)
else "D9DED8"
)
chart_fill = _chart_style_color(
payload,
(
"chart_area_fill",
"chartAreaFill",
"chart_fill",
"chartFill",
"background",
"background_color",
"backgroundColor",
"fill",
),
fallback_background,
)
return {
"axis_color": _chart_style_color(
payload,
("axis_color", "axisColor", "axis_line_color", "axisLineColor"),
axis_color,
),
"chart_fill": chart_fill,
"grid_color": _chart_style_color(
payload,
("grid_color", "gridColor", "gridline_color", "gridlineColor"),
grid_color,
),
"plot_fill": _chart_style_color(
payload,
("plot_area_fill", "plotAreaFill", "plot_background", "plotBackground"),
None,
),
"text_color": _chart_style_color(
payload,
("text_color", "textColor", "label_color", "labelColor", "font_color", "fontColor"),
text_color,
),
"font_face": font_face or None,
}
def _chart_text_sizes(
payload: dict[str, Any],
elem: ET.Element | None = None,
inherited_styles: dict[str, str] | None = None,
) -> dict[str, int]:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
inherited_styles = inherited_styles or {}
fallback_font_size = (
_most_common_font_size(
_fallback_text_attr_values(
elem,
"font-size",
inherited_styles.get("font-size"),
)
)
if elem is not None else None
)
base_raw = _first_present(
payload.get("font_size"),
payload.get("chart_font_size"),
payload.get("chartFontSize"),
style.get("font_size"),
style.get("chart_font_size"),
style.get("chartFontSize"),
fallback_font_size,
)
axis_raw = _first_present(
payload.get("axis_font_size"),
payload.get("axisFontSize"),
payload.get("tick_font_size"),
payload.get("tickFontSize"),
style.get("axis_font_size"),
style.get("axisFontSize"),
style.get("tick_font_size"),
style.get("tickFontSize"),
base_raw,
)
axis_title_raw = _first_present(
payload.get("axis_title_font_size"),
payload.get("axisTitleFontSize"),
style.get("axis_title_font_size"),
style.get("axisTitleFontSize"),
axis_raw,
)
legend_raw = _first_present(
payload.get("legend_font_size"),
payload.get("legendFontSize"),
style.get("legend_font_size"),
style.get("legendFontSize"),
axis_raw,
)
title_raw = _first_present(
payload.get("title_font_size"),
payload.get("titleFontSize"),
style.get("title_font_size"),
style.get("titleFontSize"),
)
subtitle_raw = _first_present(
payload.get("subtitle_font_size"),
payload.get("subtitleFontSize"),
style.get("subtitle_font_size"),
style.get("subtitleFontSize"),
base_raw,
)
note_raw = _first_present(
payload.get("note_font_size"),
payload.get("noteFontSize"),
style.get("note_font_size"),
style.get("noteFontSize"),
style.get("caption_font_size"),
style.get("captionFontSize"),
base_raw,
)
return {
"axis": _font_size_hpt(axis_raw, 12),
"axis_title": _font_size_hpt(axis_title_raw, 12),
"base": _font_size_hpt(base_raw, 12),
"legend": _font_size_hpt(legend_raw, 12),
"note": _font_size_hpt(note_raw, 12),
"subtitle": _font_size_hpt(subtitle_raw, 12),
"title": _font_size_hpt(title_raw, 16),
}
def _solid_fill_xml(color: str | None) -> str:
if not color:
return "<a:noFill/>"
return f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
def _chart_area_sp_pr_xml(fill_color: str | None) -> str:
return f"<c:spPr>{_solid_fill_xml(fill_color)}<a:ln><a:noFill/></a:ln></c:spPr>"
def _chart_line_sp_pr_xml(color: str | None, *, width: int = 9525) -> str:
if not color:
line_xml = "<a:ln><a:noFill/></a:ln>"
else:
line_xml = (
f'<a:ln w="{width}" cap="flat" cmpd="sng" algn="ctr">'
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
"<a:round/></a:ln>"
)
return f"<c:spPr>{line_xml}</c:spPr>"
def _major_gridlines_xml(color: str | None) -> str:
return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
def _font_face_xml(font_face: str | None) -> str:
if not font_face:
return ""
fonts = parse_font_family(font_face)
latin_font = _xml_escape(fonts["latin"])
ea_font = _xml_escape(fonts["ea"])
return (
f'<a:latin typeface="{latin_font}"/>'
f'<a:ea typeface="{ea_font}"/>'
f'<a:cs typeface="{latin_font}"/>'
)
def _chart_tx_pr_xml(
font_size: int,
color: str | None = None,
*,
bold: bool = False,
font_face: str | None = None,
language: str | None = None,
) -> str:
fill_xml = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
if color else ""
)
bold_attr = ' b="1"' if bold else ""
resolved_language = language or 'en-US'
rtl_attr = ' rtl="1"' if text_uses_rtl('', language) else ''
return (
f"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr{rtl_attr}>"
f'<a:defRPr lang="{resolved_language}" sz="{font_size}"{bold_attr}>'
f'{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
f'</a:pPr><a:endParaRPr lang="{resolved_language}"/></a:p></c:txPr>'
)
def _chart_text_entry(value: Any) -> tuple[str, dict[str, Any]] | None:
if isinstance(value, dict):
text = _first_present(value.get("text"), value.get("value"), value.get("content"))
if text is None or not str(text).strip():
return None
return str(text).strip(), value
if value is None or not str(value).strip():
return None
return str(value).strip(), {}
def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
raw = _first_present(item.get("font_size"), item.get("fontSize"))
if raw is None:
return fallback
return _font_size_hpt(raw, 12)
def _chart_text_entry_color(item: dict[str, Any], fallback: str | None) -> str | None:
return _hex_or_none(_first_present(
item.get("color"),
item.get("font_color"),
item.get("fontColor"),
)) or fallback
def _chart_text_entry_font_face(item: dict[str, Any], fallback: str | None) -> str | None:
raw = _first_present(
item.get("font_family"),
item.get("fontFamily"),
item.get("font_face"),
item.get("fontFace"),
)
if raw is None:
return fallback
font_face = str(raw).strip()
return font_face or fallback
def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
if value is None:
return ""
if isinstance(value, bool):
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
try:
alpha = float(value)
except (TypeError, ValueError, OverflowError):
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
if not math.isfinite(alpha):
raise RuntimeError(f"Native PPTX chart {field_name} must be finite")
if alpha < 0 or alpha > 1:
raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
return f'<a:alpha val="{quantize_ooxml_alpha(alpha)}"/>'
def _axis_title_xml(
title: Any,
*,
font_size: int,
color: str | None = None,
font_face: str | None = None,
primary_language: str | None = None,
) -> str:
entry = _chart_text_entry(title)
if entry is None:
return ""
text, item = entry
text_color = _chart_text_entry_color(item, color)
fill_xml = (
f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
if text_color else ""
)
lang = detect_text_lang(text, primary_language)
rtl_attr = (
' rtl="1"'
if text_uses_rtl(text, primary_language)
else ''
)
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
return (
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
f'sz="{_chart_text_entry_font_size(item, font_size)}">'
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
f"{run_rtl}</a:rPr>"
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
)
_AXIS_TITLE_KEY_GROUPS = {
"category": (
("category",),
("category_axis_title", "categoryAxisTitle"),
),
"value": (
("value",),
("value_axis_title", "valueAxisTitle"),
),
"x": (
("x",),
("x_axis_title", "xAxisTitle"),
),
"y": (
("y",),
("y_axis_title", "yAxisTitle"),
),
"secondary_value": (
("secondary_value", "secondaryValue"),
("secondary_value_axis_title", "secondaryValueAxisTitle"),
),
}
def _axis_titles(payload: dict[str, Any]) -> dict[str, Any]:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
raw = payload.get("axis_titles", payload.get("axisTitles"))
style_raw = style.get("axis_titles", style.get("axisTitles"))
axis_map = raw if isinstance(raw, dict) else {}
style_axis_map = style_raw if isinstance(style_raw, dict) else {}
def pick(axis_keys: tuple[str, ...], root_keys: tuple[str, ...]) -> Any:
values: list[Any] = []
for key in root_keys:
values.extend((
payload.get(key),
style.get(key),
))
for key in axis_keys + root_keys:
values.extend((
axis_map.get(key),
style_axis_map.get(key),
))
return _first_present(*values)
return {
field_name: pick(axis_keys, root_keys)
for field_name, (axis_keys, root_keys) in _AXIS_TITLE_KEY_GROUPS.items()
}
def _metadata_text(value: Any) -> str | None:
entry = _chart_text_entry(value)
if entry is None:
return None
text, _ = entry
return _normalized_fallback_text(text)
def _native_chart_chrome_errors(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
fallback_texts = set(_visible_fallback_texts(elem))
missing: list[str] = []
for field_name in ("title", "subtitle"):
text = _metadata_text(payload.get(field_name))
if text and text not in fallback_texts:
missing.append(f"{field_name}={text!r}")
for field_name, value in _axis_titles(payload).items():
text = _metadata_text(value)
if text and text not in fallback_texts:
missing.append(f"axis_titles.{field_name}={text!r}")
if not missing:
return []
sample = ", ".join(missing[:5])
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
return [
"Native PPTX chart metadata contains title/axis text that is not visible "
"inside the fallback marker and would appear only after "
f"--native-charts-and-tables: {sample}{suffix}. "
"Use `name` for object naming, or draw the same text in the chart fallback."
]
def _native_chart_export_payload(
elem: ET.Element,
payload: dict[str, Any],
) -> tuple[dict[str, Any], list[str]]:
if native_import_source(elem) == "pptx":
return payload, []
fallback_texts = set(_visible_fallback_texts(elem))
output = payload
messages: list[str] = []
def mutable_payload() -> dict[str, Any]:
nonlocal output
if output is payload:
output = dict(payload)
return output
def mutable_style(target: dict[str, Any]) -> dict[str, Any] | None:
style = target.get("style")
if not isinstance(style, dict):
return None
if target.get("style") is payload.get("style"):
style = dict(style)
target["style"] = style
return style
def drop_map_keys(source: dict[str, Any], map_key: str, keys: tuple[str, ...]) -> None:
raw_map = source.get(map_key)
if not isinstance(raw_map, dict):
return
next_map = dict(raw_map)
for key in keys:
next_map.pop(key, None)
if next_map:
source[map_key] = next_map
else:
source.pop(map_key, None)
for key in ("title", "subtitle"):
text = _metadata_text(payload.get(key))
if text and text not in fallback_texts:
mutable_payload().pop(key, None)
messages.append(
f"omitted native chart {key} {text!r} because it is not visible in the fallback"
)
for field_name, value in _axis_titles(payload).items():
text = _metadata_text(value)
if not text or text in fallback_texts:
continue
axis_keys, root_keys = _AXIS_TITLE_KEY_GROUPS[field_name]
target = mutable_payload()
for key in root_keys:
target.pop(key, None)
for map_key in ("axis_titles", "axisTitles"):
drop_map_keys(target, map_key, axis_keys + root_keys)
style = mutable_style(target)
if style is not None:
for key in root_keys:
style.pop(key, None)
for map_key in ("axis_titles", "axisTitles"):
drop_map_keys(style, map_key, axis_keys + root_keys)
messages.append(
f"omitted native chart axis_titles.{field_name} {text!r} "
"because it is not visible in the fallback"
)
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
show_legend = payload.get("show_legend", style.get("show_legend", False))
if show_legend:
legend_texts = _legend_candidate_texts(payload)
if legend_texts and not any(text in fallback_texts for text in legend_texts):
mutable_payload()["show_legend"] = False
messages.append(
"omitted native chart legend because show_legend=true has no "
"visible fallback legend text"
)
return output, messages
def _legend_candidate_texts(payload: dict[str, Any]) -> list[str]:
series_values: list[str] = []
category_values: list[str] = []
categories = payload.get("categories")
if isinstance(categories, list):
category_values.extend(str(item) for item in categories if str(item).strip())
series = payload.get("series")
if isinstance(series, list):
for item in series:
if isinstance(item, dict) and item.get("name") is not None:
series_values.append(str(item.get("name")))
plots = payload.get("plots")
if isinstance(plots, list):
for plot in plots:
if not isinstance(plot, dict):
continue
plot_series = plot.get("series")
if not isinstance(plot_series, list):
continue
for item in plot_series:
if isinstance(item, dict) and item.get("name") is not None:
series_values.append(str(item.get("name")))
chart_type = _compact_key(payload.get("type") or payload.get("chart_type") or "")
category_legend_types = {"pie", "doughnut", "donut", "ofpie", "pieofpie", "barofpie"}
values = category_values if chart_type in category_legend_types else series_values
normalized: list[str] = []
for value in values:
text = _normalized_fallback_text(value)
if text:
normalized.append(text)
return normalized
def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
fallback_texts = set(_visible_fallback_texts(elem))
warnings: list[str] = []
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
show_legend = payload.get("show_legend", style.get("show_legend", False))
if show_legend:
legend_texts = _legend_candidate_texts(payload)
if legend_texts and not any(text in fallback_texts for text in legend_texts):
warnings.append(
"Native PPTX chart has show_legend=true, but no series/category "
"legend text is visible inside the fallback marker. Add the "
"fallback legend or remove show_legend."
)
companion_entries = _chart_companion_entries(
payload,
include_title=False,
include_subtitle_as_caption=False,
)
missing_companion: list[str] = []
for item in companion_entries:
text = _normalized_fallback_text(item.get("text"))
if text and text not in fallback_texts:
missing_companion.append(text)
if missing_companion:
sample = ", ".join(repr(text) for text in missing_companion[:5])
suffix = "" if len(missing_companion) <= 5 else f", and {len(missing_companion) - 5} more"
warnings.append(
"Native PPTX chart companion text is not visible inside the fallback "
"marker and may appear only after --native-charts-and-tables: "
f"{sample}{suffix}. "
"Keep companion metadata aligned with visible chart annotations."
)
return warnings
def _text_box_xml(
ctx: ConvertContext,
*,
text: str,
role: str,
off_x: int,
off_y: int,
ext_cx: int,
ext_cy: int,
font_size: int,
color: str | None,
align: str = "l",
bold: bool = False,
font_face: str | None = None,
) -> str:
shape_id = ctx.next_id()
align_key = _compact_key(align)
algn = {
"center": "ctr",
"centre": "ctr",
"ctr": "ctr",
"middle": "ctr",
"right": "r",
"r": "r",
"left": "l",
"l": "l",
}.get(align_key, "l")
fill_xml = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
if color else ""
)
bold_attr = ' b="1"' if bold else ""
lang = detect_text_lang(text, ctx.primary_language)
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
run_properties_xml = (
f'{fill_xml}{_font_face_xml(font_face)}'
f'{run_rtl}'
)
rtl_attr = (
' rtl="1"'
if text_uses_rtl(text, ctx.primary_language)
else ''
)
name = _xml_escape(f"Chart {role.title()} {shape_id}")
return f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvSpPr txBox="1"/><p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:noFill/>
<a:ln><a:noFill/></a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
<a:lstStyle/>
<a:p><a:pPr algn="{algn}"{rtl_attr}/>
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
</a:p>
</p:txBody>
</p:sp>'''
def _chart_companion_entries(
payload: dict[str, Any],
*,
include_title: bool,
include_subtitle_as_caption: bool,
) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
def add(role: str, value: Any) -> None:
if value is None:
return
values = value if isinstance(value, list) else [value]
for item in values:
if isinstance(item, dict):
text = _first_present(item.get("text"), item.get("value"), item.get("content"))
if text:
entries.append({"role": role, **item, "text": str(text)})
elif str(item).strip():
entries.append({"role": role, "text": str(item)})
if include_title:
add("title", payload.get("title"))
caption_value = payload.get("caption")
if caption_value is None and include_subtitle_as_caption:
caption_value = payload.get("subtitle")
add("caption", caption_value)
add("source", payload.get("source"))
for key in ("note", "notes", "footnote", "footnotes"):
add("note", payload.get(key))
return entries
def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
"""Validate and resolve an optional explicit companion text box."""
box_keys = ("x", "y", "width", "height")
provided_box_keys = [key for key in box_keys if key in item]
if provided_box_keys and len(provided_box_keys) != len(box_keys):
raise RuntimeError(
"Native PPTX chart companion text boxes require x/y/width/height together"
)
if not provided_box_keys:
return None
return (
_powerpoint_emu(item["x"], "companion text x"),
_powerpoint_emu(item["y"], "companion text y"),
_powerpoint_emu(item["width"], "companion text width", positive=True),
_powerpoint_emu(item["height"], "companion text height", positive=True),
)
def _validate_chart_companion_boxes(
payload: dict[str, Any],
*,
chart_bounds: tuple[int, int, int, int],
include_title: bool,
include_subtitle_as_caption: bool,
) -> None:
"""Validate companion boxes without allocating shapes or relationships."""
_, chart_off_y, _, chart_ext_cy = chart_bounds
below_index = 0
for item in _chart_companion_entries(
payload,
include_title=include_title,
include_subtitle_as_caption=include_subtitle_as_caption,
):
text = str(item.get("text") or "").strip()
if not text:
continue
if _chart_companion_box(item) is not None:
continue
if str(item.get("role") or "note") != "title":
_powerpoint_emu_value(
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
"companion text y",
)
below_index += 1
def _chart_companion_text_xml(
ctx: ConvertContext,
payload: dict[str, Any],
*,
chart_bounds: tuple[int, int, int, int],
chart_style: dict[str, str | None],
note_font_size: int,
title_font_size: int,
include_title: bool,
include_subtitle_as_caption: bool,
) -> str:
entries = _chart_companion_entries(
payload,
include_title=include_title,
include_subtitle_as_caption=include_subtitle_as_caption,
)
if not entries:
return ""
chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
parts: list[str] = []
below_index = 0
for item in entries:
role = str(item.get("role") or "note")
text = str(item.get("text") or "").strip()
if not text:
continue
font_size = _font_size_hpt(item.get("font_size", item.get("fontSize")), 16 if role == "title" else 12)
if role == "title" and item.get("font_size") is None and item.get("fontSize") is None:
font_size = title_font_size
elif item.get("font_size") is None and item.get("fontSize") is None:
font_size = note_font_size
color = _hex_or_none(item.get("color")) or chart_style.get("text_color")
font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
align = str(item.get("align") or ("ctr" if role == "title" else "l"))
bold = bool(item.get("bold", role == "title"))
explicit_box = _chart_companion_box(item)
if explicit_box is not None:
off_x, off_y, ext_cx, ext_cy = explicit_box
elif role == "title":
off_x = chart_off_x
off_y = chart_off_y
ext_cx = chart_ext_cx
ext_cy = px_to_emu(28)
else:
off_x = chart_off_x
off_y = _powerpoint_emu_value(
chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18),
"companion text y",
)
ext_cx = chart_ext_cx
ext_cy = px_to_emu(16)
below_index += 1
parts.append(_text_box_xml(
ctx,
text=text,
role=role,
off_x=off_x,
off_y=off_y,
ext_cx=ext_cx,
ext_cy=ext_cy,
font_size=font_size,
color=color,
align=align,
bold=bold,
font_face=font_face,
))
return "".join(parts)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
"""Visible-fallback fingerprint contract for native chart/table markers."""
from __future__ import annotations
import re
import secrets
from xml.etree import ElementTree as ET
from pptx_shapes import (
NATIVE_FALLBACK_SHA256_ATTR,
svg_native_fallback_fingerprint,
)
from .marker_attributes import native_import_source, native_replacement_kind
NATIVE_FALLBACK_RUNTIME_ATTR = "data-pptx-runtime-fallback-unchanged"
_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR = "data-pptx-runtime-fallback-token"
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_RUNTIME_TOKEN = secrets.token_hex(16)
def stamp_native_fallback_baseline(
elem: ET.Element,
*,
document_root: ET.Element | None = None,
) -> str:
"""Record the current canonical visible-subtree hash on one marker."""
digest = svg_native_fallback_fingerprint(
elem,
document_root=document_root,
)
elem.set(NATIVE_FALLBACK_SHA256_ATTR, digest)
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
return digest
def snapshot_native_fallback_freshness(root: ET.Element) -> None:
"""Snapshot raw marker freshness before exporter preprocessing mutates SVG."""
for elem in root.iter():
if elem.tag.rsplit("}", 1)[-1] == "metadata":
continue
if not native_replacement_kind(elem):
continue
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
elem.set(NATIVE_FALLBACK_RUNTIME_ATTR, "invalid")
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
elif expected is None:
elem.attrib.pop(NATIVE_FALLBACK_RUNTIME_ATTR, None)
elem.attrib.pop(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, None)
else:
actual = svg_native_fallback_fingerprint(
elem,
document_root=root,
)
elem.set(
NATIVE_FALLBACK_RUNTIME_ATTR,
"1" if actual == expected else "0",
)
elem.set(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR, _RUNTIME_TOKEN)
def native_fallback_contract_warnings(
elem: ET.Element,
*,
use_runtime_snapshot: bool = False,
document_root: ET.Element | None = None,
) -> list[str]:
"""Return non-blocking diagnostics for default/checker compatibility."""
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
return [
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256; "
"the shape-based SVG fallback remains available, but "
"--native-charts-and-tables will fail"
]
if expected is None:
if native_import_source(elem) != "pptx":
return []
return [
f"imported marker has no {NATIVE_FALLBACK_SHA256_ATTR} baseline; "
"the marker remains compatible with Chart/Table replacement, but "
"stale fallback edits cannot be detected"
]
if _native_fallback_is_fresh(
elem,
expected,
use_runtime_snapshot=use_runtime_snapshot,
document_root=document_root,
):
return []
return [
"visible SVG fallback differs from its recorded baseline; default "
"shape-based fallback export remains available, but "
"--native-charts-and-tables will fail"
]
def require_fresh_native_fallback(
elem: ET.Element,
*,
use_runtime_snapshot: bool = False,
document_root: ET.Element | None = None,
) -> None:
"""Fail Chart/Table replacement when a recorded fallback is stale."""
expected, invalid = _expected_native_fallback_hash(elem)
if invalid:
raise RuntimeError(
f"{NATIVE_FALLBACK_SHA256_ATTR} must be a 64-digit SHA-256"
)
if expected is None:
return
if _native_fallback_is_fresh(
elem,
expected,
use_runtime_snapshot=use_runtime_snapshot,
document_root=document_root,
):
return
raise RuntimeError(
"Visible native-object SVG fallback was edited after its baseline was "
"recorded; --native-charts-and-tables stopped to avoid discarding the "
"SVG edit. "
"Use the default fallback export, or deliberately update the native "
"metadata and baseline together"
)
def _expected_native_fallback_hash(
elem: ET.Element,
) -> tuple[str | None, bool]:
raw = elem.get(NATIVE_FALLBACK_SHA256_ATTR)
if raw is None:
return None, False
if raw != raw.strip() or _SHA256_RE.fullmatch(raw) is None:
return None, True
return raw.lower(), False
def _native_fallback_is_fresh(
elem: ET.Element,
expected: str,
*,
use_runtime_snapshot: bool,
document_root: ET.Element | None,
) -> bool:
if (
use_runtime_snapshot
and elem.get(_NATIVE_FALLBACK_RUNTIME_TOKEN_ATTR) == _RUNTIME_TOKEN
):
snapshot = elem.get(NATIVE_FALLBACK_RUNTIME_ATTR)
if snapshot == "1":
return True
if snapshot in {"0", "invalid"}:
return False
return svg_native_fallback_fingerprint(
elem,
document_root=document_root,
) == expected
@@ -0,0 +1,182 @@
"""Canonical and compatible attribute access for chart/table replacements."""
from __future__ import annotations
from collections.abc import Callable
from xml.etree import ElementTree as ET
REPLACE_WITH_ATTR = "data-pptx-replace-with"
LEGACY_REPLACE_WITH_ATTR = "data-pptx-native"
REPLACEMENT_STATUS_ATTR = "data-pptx-replacement-status"
LEGACY_REPLACEMENT_STATUS_ATTR = "data-pptx-native-status"
IMPORT_SOURCE_ATTR = "data-pptx-import-source"
LEGACY_IMPORT_SOURCE_ATTR = "data-pptx-native-source"
FALLBACK_KIND_ATTR = "data-pptx-fallback-kind"
LEGACY_FALLBACK_KIND_ATTR = "data-pptx-visual-status"
LEGACY_ROUTE_STATUS_ATTR = "data-pptx-route-status"
class NativeMarkerAttributeError(ValueError):
"""Raised when canonical and compatible marker attributes conflict."""
def _normalized_token(value: str) -> str:
return value.strip().lower()
def _resolved_alias(
elem: ET.Element,
canonical: str,
legacy: str,
*,
normalize: Callable[[str], str] = _normalized_token,
) -> str | None:
canonical_raw = elem.get(canonical)
legacy_raw = elem.get(legacy)
canonical_value = normalize(canonical_raw) if canonical_raw is not None else None
legacy_value = normalize(legacy_raw) if legacy_raw is not None else None
if (
canonical_value is not None
and legacy_value is not None
and canonical_value != legacy_value
):
raise NativeMarkerAttributeError(
f"{canonical}={canonical_raw!r} conflicts with compatible "
f"{legacy}={legacy_raw!r}"
)
return canonical_value if canonical_value is not None else legacy_value
def native_replacement_kind(elem: ET.Element) -> str:
"""Return the requested chart/table replacement kind, or an empty string."""
return _resolved_alias(elem, REPLACE_WITH_ATTR, LEGACY_REPLACE_WITH_ATTR) or ""
def native_replacement_status(elem: ET.Element) -> str:
"""Return the fallback-only replacement status, or an empty string."""
return (
_resolved_alias(
elem,
REPLACEMENT_STATUS_ATTR,
LEGACY_REPLACEMENT_STATUS_ATTR,
normalize=str.strip,
)
or ""
)
def native_import_source(elem: ET.Element) -> str:
"""Return replacement payload provenance, or an empty string."""
return (
_resolved_alias(
elem,
IMPORT_SOURCE_ATTR,
LEGACY_IMPORT_SOURCE_ATTR,
normalize=str.strip,
)
or ""
)
def native_fallback_kind(elem: ET.Element) -> str | None:
"""Return the visible fallback classification when declared."""
return _resolved_alias(
elem,
FALLBACK_KIND_ATTR,
LEGACY_FALLBACK_KIND_ATTR,
normalize=str.strip,
)
def native_metadata_payload_matches(
elem: ET.Element,
parent_kind: str,
) -> bool:
"""Return whether one metadata node carries this replacement payload.
Compatible metadata kind attributes remain readable, but any declared
kind must agree with both its alias and the parent replacement marker.
"""
native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR)
compatible_kind = elem.get("data-pptx-kind")
for attr, raw in (
(LEGACY_REPLACE_WITH_ATTR, native_kind),
("data-pptx-kind", compatible_kind),
):
if raw is not None and raw != raw.strip():
raise NativeMarkerAttributeError(
f"metadata {attr} must not contain surrounding whitespace"
)
if native_kind is not None and compatible_kind is not None:
if _normalized_token(native_kind) != _normalized_token(compatible_kind):
raise NativeMarkerAttributeError(
"metadata data-pptx-native conflicts with data-pptx-kind"
)
declared_raw = native_kind if native_kind is not None else compatible_kind
declared_kind = _normalized_token(declared_raw) if declared_raw is not None else None
if declared_kind is not None and declared_kind != parent_kind:
raise NativeMarkerAttributeError(
f"metadata kind {declared_kind!r} conflicts with parent "
f"replacement kind {parent_kind!r}"
)
metadata_type_raw = elem.get("type")
if metadata_type_raw is not None and metadata_type_raw != metadata_type_raw.strip():
raise NativeMarkerAttributeError(
"metadata type must not contain surrounding whitespace"
)
metadata_type = (metadata_type_raw or "").lower()
return metadata_type == "application/json" or declared_kind == parent_kind
def native_marker_legacy_warnings(elem: ET.Element) -> list[str]:
"""Return migration advice for compatible legacy marker spellings."""
tag = elem.tag.rsplit("}", 1)[-1]
if tag == "metadata":
native_kind = elem.get(LEGACY_REPLACE_WITH_ATTR)
compatible_kind = elem.get("data-pptx-kind")
if (
native_kind is not None
and compatible_kind is not None
and _normalized_token(native_kind) != _normalized_token(compatible_kind)
):
return []
warnings = []
for legacy in (LEGACY_REPLACE_WITH_ATTR, "data-pptx-kind"):
if elem.get(legacy) is not None:
warnings.append(
f"legacy metadata attribute {legacy} is compatible; use "
'type="application/json" because the parent replacement '
"marker determines the payload kind. No change is required "
"for export."
)
return warnings
try:
native_replacement_kind(elem)
native_replacement_status(elem)
native_import_source(elem)
native_fallback_kind(elem)
except NativeMarkerAttributeError:
return []
warnings = []
replacements = (
(LEGACY_REPLACE_WITH_ATTR, REPLACE_WITH_ATTR),
(LEGACY_REPLACEMENT_STATUS_ATTR, REPLACEMENT_STATUS_ATTR),
(LEGACY_IMPORT_SOURCE_ATTR, IMPORT_SOURCE_ATTR),
(LEGACY_FALLBACK_KIND_ATTR, FALLBACK_KIND_ATTR),
)
for legacy, canonical in replacements:
if elem.get(legacy) is not None:
warnings.append(
f"legacy attribute {legacy} is compatible; use {canonical} "
"for project-canonical SVG. No change is required for export."
)
if elem.get(LEGACY_ROUTE_STATUS_ATTR) is not None:
warnings.append(
f"legacy attribute {LEGACY_ROUTE_STATUS_ATTR} is compatible; omit it "
f"because {FALLBACK_KIND_ATTR}='placeholder' already implies the "
"reconstruction-only route. No change is required for export."
)
return warnings
@@ -0,0 +1,806 @@
"""Shared helpers for native PowerPoint object conversion."""
from __future__ import annotations
import json
import math
import re
from collections.abc import Iterable
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, IDENTITY_MATRIX
from ..drawingml.paths import (
parse_svg_path,
parse_svg_points,
svg_path_to_absolute,
)
from ..drawingml.utils import (
EMU_PER_PX,
ctx_h,
ctx_w,
ctx_x,
ctx_y,
font_px_to_hpt,
matrix_multiply,
parse_project_geometry_length,
parse_transform_operations,
parse_transform_matrix,
transform_point,
)
from .marker_attributes import (
NativeMarkerAttributeError,
native_metadata_payload_matches,
)
TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table"
CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
CHARTEX_REL_TYPE = "http://schemas.microsoft.com/office/2014/relationships/chartEx"
CHART_COLOR_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle"
CHART_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartStyle"
PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
CHARTEX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml"
CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
_NATIVE_KINDS = {"table", "chart"}
_POWERPOINT_COORD_MIN = -(2**31)
_POWERPOINT_COORD_MAX = 2**31 - 1
_POWERPOINT_LINE_WIDTH_MAX = 20116800
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_RGB_RE = re.compile(r"^rgba?\(([^)]+)\)$", re.IGNORECASE)
_CSS_NAMED_COLORS = {
"aliceblue": "F0F8FF",
"black": "000000",
"blue": "0000FF",
"brown": "A52A2A",
"cyan": "00FFFF",
"darkgray": "A9A9A9",
"darkgrey": "A9A9A9",
"gold": "FFD700",
"gray": "808080",
"green": "008000",
"grey": "808080",
"lightgray": "D3D3D3",
"lightgrey": "D3D3D3",
"magenta": "FF00FF",
"navy": "000080",
"orange": "FFA500",
"purple": "800080",
"red": "FF0000",
"silver": "C0C0C0",
"transparent": None,
"white": "FFFFFF",
"yellow": "FFFF00",
}
def _local_tag(elem: ET.Element) -> str:
return elem.tag.rsplit("}", 1)[-1] if "}" in elem.tag else elem.tag
def _clean_hex(value: Any, default: str) -> str:
return _hex_or_none(value) or _hex_or_none(default) or "000000"
def _hex_or_none(value: Any) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
named = _CSS_NAMED_COLORS.get(raw.lower())
if named is not None or raw.lower() in _CSS_NAMED_COLORS:
return named
match = _HEX_RE.match(raw)
if match:
color = match.group(1).upper()
if len(color) == 3:
return "".join(channel * 2 for channel in color)
return color
match = _RGB_RE.match(raw)
if not match:
return None
parts = [part.strip() for part in match.group(1).split(",")]
if len(parts) not in {3, 4}:
return None
channels: list[int] = []
for part in parts[:3]:
try:
if part.endswith("%"):
value_float = float(part[:-1]) * 255.0 / 100.0
else:
value_float = float(part)
except ValueError:
return None
if not math.isfinite(value_float):
return None
channels.append(max(0, min(255, int(round(value_float)))))
return "".join(f"{channel:02X}" for channel in channels)
def _style_attr(elem: ET.Element, name: str) -> str | None:
if elem.get(name) is not None:
return elem.get(name)
style = elem.get("style")
if not style:
return None
for part in style.split(";"):
if ":" not in part:
continue
key, value = part.split(":", 1)
if key.strip() == name:
return value.strip()
return None
def _paint_visible(elem: ET.Element, paint: str) -> bool:
for name in ("opacity", f"{paint}-opacity"):
raw = _style_attr(elem, name)
if raw is None:
continue
try:
if float(raw) <= 0:
return False
except ValueError:
continue
return True
def _normalized_fallback_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _visible_fallback_texts(elem: ET.Element, *, include_metadata: bool = False) -> list[str]:
texts: list[str] = []
def visit(node: ET.Element, hidden: bool) -> None:
tag = _local_tag(node)
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
return
if tag == "metadata" and not include_metadata:
return
node_hidden = (
hidden
or _style_attr(node, "display") == "none"
or _style_attr(node, "visibility") == "hidden"
)
if tag == "text" and not node_hidden:
text = _normalized_fallback_text("".join(node.itertext()))
if text:
texts.append(text)
return
for child in node:
visit(child, node_hidden)
visit(elem, False)
return texts
def _number(value: Any, field_name: str) -> float:
if isinstance(value, bool):
raise RuntimeError(f"Native PPTX object requires numeric {field_name}")
try:
number = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise RuntimeError(f"Native PPTX object requires numeric {field_name}") from exc
if not math.isfinite(number):
raise RuntimeError(f"Native PPTX object requires finite numeric {field_name}")
return number
def _maybe_number(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
try:
number = float(value)
except (TypeError, ValueError, OverflowError):
return None
return number if math.isfinite(number) else None
def _project_geometry_number(
elem: ET.Element,
attribute: str,
default: float = 0.0,
) -> float:
"""Read one fallback geometry value through the project length contract."""
raw = elem.get(attribute)
if raw is None:
return default
try:
return parse_project_geometry_length(raw, attribute)
except ValueError as exc:
tag = _local_tag(elem)
elem_id = elem.get("id")
label = f"<{tag} id={elem_id!r}>" if elem_id else f"<{tag}>"
raise RuntimeError(
f"Native PPTX fallback {label} {attribute}={raw!r}: {exc}"
) from exc
def _powerpoint_emu_value(
emu: int,
field_name: str,
*,
positive: bool = False,
) -> int:
"""Validate an already-resolved EMU value for a PowerPoint coordinate."""
lower_bound = 1 if positive else _POWERPOINT_COORD_MIN
if emu < lower_bound or emu > _POWERPOINT_COORD_MAX:
qualifier = "positive " if positive else ""
raise RuntimeError(
f"Native PPTX object {field_name} must resolve to a {qualifier}"
"32-bit PowerPoint coordinate"
)
return emu
def _powerpoint_emu(value: Any, field_name: str, *, positive: bool = False) -> int:
"""Convert SVG px to an EMU value that PowerPoint can represent."""
number = _number(value, field_name)
scaled = number * EMU_PER_PX
if not math.isfinite(scaled):
raise RuntimeError(f"Native PPTX object {field_name} exceeds PowerPoint coordinates")
return _powerpoint_emu_value(round(scaled), field_name, positive=positive)
def _powerpoint_line_width_emu(value: Any, field_name: str) -> int:
"""Convert SVG px to a legal DrawingML ``ST_LineWidth`` value."""
emu = _powerpoint_emu(value, field_name, positive=True)
if emu > _POWERPOINT_LINE_WIDTH_MAX:
raise RuntimeError(
f"Native PPTX object {field_name} exceeds DrawingML line-width range"
)
return emu
def native_marker_transform(transform: str | None) -> tuple[float, float, float, float]:
"""Return a strict native-marker transform as ``dx, dy, sx, sy``."""
raw = (transform or "").strip()
if not raw:
return 0.0, 0.0, 1.0, 1.0
try:
operations = parse_transform_operations(raw)
except ValueError as exc:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
) from exc
if any(name not in {"translate", "scale"} for name, _args in operations):
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
a, b, c, d, e, f = parse_transform_matrix(raw)
components = (a, b, c, d, e, f)
if not all(math.isfinite(value) for value in components):
raise RuntimeError("Native PPTX marker transform exceeds finite coordinates")
if b != 0.0 or c != 0.0:
raise RuntimeError(
"Native PPTX table/chart markers support translate/scale transforms only"
)
return e, f, a, d
def _native_marker_validation_context(
elem: ET.Element,
ancestors: Iterable[ET.Element] = (),
) -> ConvertContext:
"""Build the scalar context used by native export for one marker path."""
ctx = ConvertContext()
for current in (*ancestors, elem):
dx, dy, sx, sy = native_marker_transform(current.get("transform"))
ctx = ctx.child(
dx=ctx.scale_x * dx,
dy=ctx.scale_y * dy,
sx=sx,
sy=sy,
)
return ctx
def _bbox_union(
first: tuple[float, float, float, float] | None,
second: tuple[float, float, float, float] | None,
) -> tuple[float, float, float, float] | None:
if first is None:
return second
if second is None:
return first
return (
min(first[0], second[0]),
min(first[1], second[1]),
max(first[2], second[2]),
max(first[3], second[3]),
)
def _bbox_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float] | None:
if not points:
return None
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return min(xs), min(ys), max(xs), max(ys)
def _apply_matrix_bbox(
bbox: tuple[float, float, float, float],
matrix: tuple[float, float, float, float, float, float],
) -> tuple[float, float, float, float]:
x1, y1, x2, y2 = bbox
points = [
transform_point(matrix, x1, y1),
transform_point(matrix, x2, y1),
transform_point(matrix, x2, y2),
transform_point(matrix, x1, y2),
]
result = _bbox_from_points(points)
if result is None:
raise RuntimeError("Native PPTX object fallback bbox inference failed")
return result
def _points_attr_bbox(
value: str | None,
*,
min_points: int,
) -> tuple[float, float, float, float] | None:
try:
points = parse_svg_points(value or "", min_points=min_points)
except ValueError as exc:
raise RuntimeError(f"Invalid native fallback points: {exc}") from exc
return _bbox_from_points(points)
def _path_bbox(value: str | None) -> tuple[float, float, float, float] | None:
try:
commands = svg_path_to_absolute(parse_svg_path(value or ""))
except ValueError as exc:
raise RuntimeError(f"Invalid native fallback path d: {exc}") from exc
points: list[tuple[float, float]] = []
subpath_x = 0.0
subpath_y = 0.0
for command in commands:
values = command.args
if command.cmd == "M":
subpath_x, subpath_y = values
points.append((subpath_x, subpath_y))
elif command.cmd in {"L", "T"}:
points.append((values[0], values[1]))
elif command.cmd == "C":
points.extend(
(values[index], values[index + 1])
for index in range(0, 6, 2)
)
elif command.cmd in {"S", "Q"}:
points.extend(
(values[index], values[index + 1])
for index in range(0, 4, 2)
)
elif command.cmd == "A":
points.append((values[5], values[6]))
elif command.cmd == "Z":
points.append((subpath_x, subpath_y))
return _bbox_from_points(points)
def _element_local_bbox(elem: ET.Element) -> tuple[float, float, float, float] | None:
tag = _local_tag(elem)
if tag == "metadata":
return None
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
return None
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return None
if tag in {"g", "svg", "a"}:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child))
return bbox
if tag in {"rect", "image", "use"}:
x = _project_geometry_number(elem, "x")
y = _project_geometry_number(elem, "y")
width = _project_geometry_number(elem, "width")
height = _project_geometry_number(elem, "height")
if width <= 0 or height <= 0:
return None
return x, y, x + width, y + height
if tag == "circle":
cx = _project_geometry_number(elem, "cx")
cy = _project_geometry_number(elem, "cy")
r = _project_geometry_number(elem, "r")
if r <= 0:
return None
return cx - r, cy - r, cx + r, cy + r
if tag == "ellipse":
cx = _project_geometry_number(elem, "cx")
cy = _project_geometry_number(elem, "cy")
rx = _project_geometry_number(elem, "rx")
ry = _project_geometry_number(elem, "ry")
if rx <= 0 or ry <= 0:
return None
return cx - rx, cy - ry, cx + rx, cy + ry
if tag == "line":
points = [
(
_project_geometry_number(elem, "x1"),
_project_geometry_number(elem, "y1"),
),
(
_project_geometry_number(elem, "x2"),
_project_geometry_number(elem, "y2"),
),
]
return _bbox_from_points(points)
if tag in {"polygon", "polyline"}:
return _points_attr_bbox(
elem.get("points"),
min_points=3 if tag == "polygon" else 2,
)
if tag == "path":
# This intentionally approximates path geometry from command endpoints.
# Explicit metadata remains the precise path for complex arcs/curves.
return _path_bbox(elem.get("d"))
if tag == "text":
x = _project_geometry_number(elem, "x")
y = _project_geometry_number(elem, "y")
font_size = _maybe_number(elem.get("font-size")) or 16.0
text = "".join(elem.itertext())
width = max(len(text), 1) * font_size * 0.55
height = font_size * 1.25
return x, y - height * 0.8, x + width, y + height * 0.2
return None
def _fallback_bbox(
elem: ET.Element,
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
) -> tuple[float, float, float, float] | None:
local_matrix = matrix
transform = elem.get("transform")
if transform:
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
tag = _local_tag(elem)
if tag in {"g", "svg", "a"}:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child, local_matrix))
return bbox
local_bbox = _element_local_bbox(elem)
if local_bbox is None:
return None
return _apply_matrix_bbox(local_bbox, local_matrix)
def _inferred_bounds(elem: ET.Element) -> tuple[float, float, float, float] | None:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child))
return bbox
def _fallback_fill_candidates(
elem: ET.Element,
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
inherited_fill: str | None = None,
) -> list[tuple[float, str]]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "fill"):
return []
local_matrix = matrix
transform = elem.get("transform")
if transform:
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
fill = _style_attr(elem, "fill")
next_fill = fill if fill is not None else inherited_fill
if tag in {"g", "svg", "a"}:
candidates: list[tuple[float, str]] = []
for child in elem:
candidates.extend(_fallback_fill_candidates(child, local_matrix, next_fill))
return candidates
if tag != "rect":
return []
if not next_fill or next_fill.strip().lower() in {"none", "transparent"}:
return []
color = _hex_or_none(next_fill)
if not color:
return []
local_bbox = _element_local_bbox(elem)
if local_bbox is None:
return []
x1, y1, x2, y2 = _apply_matrix_bbox(local_bbox, local_matrix)
area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
return [(area, color)] if area > 0 else []
def _inferred_chart_background(elem: ET.Element) -> str | None:
bounds = _inferred_bounds(elem)
if bounds is None:
return None
x1, y1, x2, y2 = bounds
bounds_area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
if bounds_area <= 0:
return None
candidates: list[tuple[float, str]] = []
for child in elem:
candidates.extend(_fallback_fill_candidates(child))
if not candidates:
return None
area, color = max(candidates, key=lambda item: item[0])
# Avoid mistaking a large data bar for a chart background when no panel /
# plot-area rectangle exists in the fallback drawing.
return color if area >= bounds_area * 0.25 else None
def _fallback_text_colors(
elem: ET.Element,
inherited_fill: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "fill"):
return []
fill = _style_attr(elem, "fill")
next_fill = fill if fill is not None else inherited_fill
colors: list[str] = []
if tag in {"text", "tspan"} and next_fill:
color = _hex_or_none(next_fill)
if color:
colors.append(color)
for child in elem:
colors.extend(_fallback_text_colors(child, next_fill))
return colors
def _fallback_stroke_colors(
elem: ET.Element,
inherited_stroke: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "stroke"):
return []
stroke = _style_attr(elem, "stroke")
next_stroke = stroke if stroke is not None else inherited_stroke
colors: list[str] = []
if tag in {"circle", "ellipse", "line", "path", "polygon", "polyline", "rect"} and next_stroke:
color = _hex_or_none(next_stroke)
if color:
colors.append(color)
for child in elem:
colors.extend(_fallback_stroke_colors(child, next_stroke))
return colors
def _most_common_color(colors: list[str]) -> str | None:
if not colors:
return None
counts: dict[str, int] = {}
for color in colors:
counts[color] = counts.get(color, 0) + 1
return max(counts.items(), key=lambda item: item[1])[0]
def _relative_luminance(color: str) -> float:
channels = [int(color[idx:idx + 2], 16) / 255.0 for idx in (0, 2, 4)]
linear = [
channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
for channel in channels
]
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
def _resolved_bounds(
elem: ET.Element,
payload: dict[str, Any],
ctx: ConvertContext,
) -> tuple[float, float, float, float, bool]:
"""Resolve object bounds in SVG px plus whether all bounds were explicit."""
if ctx.use_transform_matrix:
raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
raw_x = payload.get("x", elem.get("data-pptx-x"))
raw_y = payload.get("y", elem.get("data-pptx-y"))
raw_width = payload.get("width", elem.get("data-pptx-width"))
raw_height = payload.get("height", elem.get("data-pptx-height"))
explicit_bounds = all(value is not None for value in (raw_x, raw_y, raw_width, raw_height))
inferred = None
if not explicit_bounds:
inferred = _inferred_bounds(elem)
if inferred is None:
raise RuntimeError(
"Native PPTX object requires x/y/width/height or visible fallback geometry"
)
x = _number(raw_x, "x") if raw_x is not None else inferred[0] # type: ignore[index]
y = _number(raw_y, "y") if raw_y is not None else inferred[1] # type: ignore[index]
width = (
_number(raw_width, "width")
if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
)
height = (
_number(raw_height, "height")
if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
)
if width <= 0 or height <= 0:
raise RuntimeError("Native PPTX object width/height must be positive")
if explicit_bounds:
resolved_x = x
resolved_y = y
resolved_w = width
resolved_h = height
else:
resolved_x = ctx_x(x, ctx)
resolved_y = ctx_y(y, ctx)
resolved_w = ctx_w(width, ctx)
resolved_h = ctx_h(height, ctx)
return resolved_x, resolved_y, resolved_w, resolved_h, explicit_bounds
def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
"""Return object bounds as DrawingML EMU tuple."""
x, y, width, height, _ = _resolved_bounds(elem, payload, ctx)
return (
_powerpoint_emu(x, "x"),
_powerpoint_emu(y, "y"),
_powerpoint_emu(width, "width", positive=True),
_powerpoint_emu(height, "height", positive=True),
)
def _validate_bounds_inputs(
elem: ET.Element,
payload: dict[str, Any],
ctx: ConvertContext,
) -> tuple[int, int, int, int, bool]:
x, y, width, height, explicit_bounds = _resolved_bounds(elem, payload, ctx)
return (
_powerpoint_emu(x, "x"),
_powerpoint_emu(y, "y"),
_powerpoint_emu(width, "width", positive=True),
_powerpoint_emu(height, "height", positive=True),
explicit_bounds,
)
def _validate_payload_xml_strings(value: Any) -> None:
"""Reject JSON strings that cannot be serialized into PPTX XML 1.0 parts."""
if isinstance(value, dict):
for key, item in value.items():
_validate_payload_xml_strings(key)
_validate_payload_xml_strings(item)
return
if isinstance(value, list):
for item in value:
_validate_payload_xml_strings(item)
return
if not isinstance(value, str):
return
for char in value:
codepoint = ord(char)
if (
codepoint in {0x09, 0x0A, 0x0D}
or 0x20 <= codepoint <= 0xD7FF
or 0xE000 <= codepoint <= 0xFFFD
or 0x10000 <= codepoint <= 0x10FFFF
):
continue
raise RuntimeError(
"Native PPTX metadata contains an XML 1.0-invalid character "
f"U+{codepoint:04X}"
)
def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
raw = elem.get("data-pptx-json") or elem.get("data-pptx-data")
if raw is None:
for child in elem:
if _local_tag(child) != "metadata":
continue
if native_metadata_payload_matches(child, kind):
raw = "".join(child.itertext()).strip()
break
if not raw:
raise RuntimeError(
f"PPTX {kind} replacement marker requires JSON metadata; add "
'<metadata type="application/json"> for a real data-backed '
"object, or remove data-pptx-replace-with from SVG-only "
"KPI/diagram groups"
)
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Native PPTX {kind} metadata is not valid JSON: {exc.msg}") from exc
except (ValueError, RecursionError) as exc:
raise RuntimeError(f"Native PPTX {kind} metadata cannot be decoded") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Native PPTX {kind} metadata must be a JSON object")
_validate_payload_xml_strings(payload)
return payload
def _font_size_hpt(value: Any, default_px: int = 18) -> int:
def convert(raw: Any) -> int | None:
try:
px = float(raw)
except (TypeError, ValueError, OverflowError):
return None
try:
return font_px_to_hpt(px)
except ValueError:
return None
return convert(value) or convert(default_px) or 1350
def _first_present(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def _bool_attr(value: bool) -> str:
return "1" if value else "0"
def _chart_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
key = _compact_key(value)
if key in {"1", "on", "true", "yes"}:
return True
if key in {"0", "false", "no", "off"}:
return False
return bool(value)
def _excel_col(index: int) -> str:
result = ""
while index:
index, remainder = divmod(index - 1, 26)
result = chr(65 + remainder) + result
return result or "A"
def _compact_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
@@ -0,0 +1,188 @@
"""Validate chart/table replacement fallback and release-route attributes."""
from __future__ import annotations
from xml.etree import ElementTree as ET
from .marker_attributes import (
FALLBACK_KIND_ATTR,
IMPORT_SOURCE_ATTR,
LEGACY_FALLBACK_KIND_ATTR,
LEGACY_IMPORT_SOURCE_ATTR,
LEGACY_REPLACEMENT_STATUS_ATTR,
LEGACY_REPLACE_WITH_ATTR,
LEGACY_ROUTE_STATUS_ATTR,
REPLACEMENT_STATUS_ATTR,
REPLACE_WITH_ATTR,
NativeMarkerAttributeError,
native_fallback_kind,
native_import_source,
native_replacement_kind,
native_replacement_status,
)
VISUAL_STATUSES = frozenset({"source-preview", "normalized", "placeholder"})
ROUTE_STATUSES = frozenset({"reconstruction-only"})
REPLACEMENT_KINDS = frozenset({"chart", "table"})
# Closed importer outputs from chart_to_svg, chartex_to_svg, and tbl_to_svg.
# This includes codes forwarded through their dynamic ``status`` parameters.
REPLACEMENT_STATUS_CODES = frozenset({
"unsupported-3d-chart",
"unsupported-chart-analysis-features",
"unsupported-chart-axis-number-format",
"unsupported-chart-axis-options",
"unsupported-chart-axis-titles",
"unsupported-chart-bar-options",
"unsupported-chart-bubble-options",
"unsupported-chart-cache",
"unsupported-chart-category-format",
"unsupported-chart-data-labels",
"unsupported-chart-data-table",
"unsupported-chart-doughnut-options",
"unsupported-chart-legend-position",
"unsupported-chart-line-style",
"unsupported-chart-of-pie-options",
"unsupported-chart-parse",
"unsupported-chart-part",
"unsupported-chart-pie-options",
"unsupported-chart-plot",
"unsupported-chart-point-labels",
"unsupported-chart-radar-style",
"unsupported-chart-reference",
"unsupported-chart-relationship",
"unsupported-chart-scatter-style",
"unsupported-chart-schema",
"unsupported-chart-series-data-labels",
"unsupported-chart-series-order",
"unsupported-chart-series-style",
"unsupported-chart-type",
"unsupported-chart-uri",
"unsupported-chartex-cache",
"unsupported-chartex-data-id",
"unsupported-chartex-dimension",
"unsupported-chartex-parse",
"unsupported-chartex-part",
"unsupported-chartex-schema",
"unsupported-chartex-series",
"unsupported-chartex-structure",
"unsupported-chartex-type",
"unsupported-combo-category-format",
"unsupported-combo-category-layout",
"unsupported-combo-chart",
"unsupported-combo-series-order",
"unsupported-date-axis",
"unsupported-date-system",
"unsupported-formatted-category-cache",
"unsupported-merge-topology",
"unsupported-native-transform",
"unsupported-stock-chart",
"unsupported-table-direct-formatting",
"unsupported-table-geometry",
"unsupported-table-size",
"unsupported-table-style",
})
def native_marker_status_errors(elem: ET.Element) -> list[str]:
"""Return invalid or contradictory replacement status declarations."""
errors: list[str] = []
native_raw_values = {
REPLACE_WITH_ATTR: elem.get(REPLACE_WITH_ATTR),
LEGACY_REPLACE_WITH_ATTR: elem.get(LEGACY_REPLACE_WITH_ATTR),
}
fallback_status_raw_values = {
REPLACEMENT_STATUS_ATTR: elem.get(REPLACEMENT_STATUS_ATTR),
LEGACY_REPLACEMENT_STATUS_ATTR: elem.get(LEGACY_REPLACEMENT_STATUS_ATTR),
}
import_source_raw_values = {
IMPORT_SOURCE_ATTR: elem.get(IMPORT_SOURCE_ATTR),
LEGACY_IMPORT_SOURCE_ATTR: elem.get(LEGACY_IMPORT_SOURCE_ATTR),
}
visual_raw = elem.get(FALLBACK_KIND_ATTR)
legacy_visual_raw = elem.get(LEGACY_FALLBACK_KIND_ATTR)
route_raw = elem.get(LEGACY_ROUTE_STATUS_ATTR)
try:
visual = native_fallback_kind(elem)
native = native_replacement_kind(elem)
fallback = native_replacement_status(elem)
native_import_source(elem)
except NativeMarkerAttributeError as exc:
errors.append(str(exc))
return errors
route = route_raw.strip() if route_raw is not None else None
for attr, raw in (
*native_raw_values.items(),
*fallback_status_raw_values.items(),
*import_source_raw_values.items(),
):
if raw is not None and raw != raw.strip():
errors.append(f"{attr} must not contain surrounding whitespace")
canonical_kind_raw = native_raw_values[REPLACE_WITH_ATTR]
if (
canonical_kind_raw is not None
and canonical_kind_raw == canonical_kind_raw.strip()
and canonical_kind_raw != canonical_kind_raw.lower()
):
errors.append(f"{REPLACE_WITH_ATTR} must use lowercase chart or table")
if visual_raw is not None and visual_raw != visual_raw.strip():
errors.append(f"{FALLBACK_KIND_ATTR} must not contain surrounding whitespace")
if legacy_visual_raw is not None and legacy_visual_raw != legacy_visual_raw.strip():
errors.append(
f"{LEGACY_FALLBACK_KIND_ATTR} must not contain surrounding whitespace"
)
if route_raw is not None and route_raw != route:
errors.append(f"{LEGACY_ROUTE_STATUS_ATTR} must not contain surrounding whitespace")
if visual is not None and visual not in VISUAL_STATUSES:
errors.append(f"unsupported {FALLBACK_KIND_ATTR} value: {visual!r}")
if route is not None and route not in ROUTE_STATUSES:
errors.append(f"unsupported {LEGACY_ROUTE_STATUS_ATTR} value: {route!r}")
if any(raw is not None for raw in native_raw_values.values()):
if native not in REPLACEMENT_KINDS:
errors.append(f"unsupported {REPLACE_WITH_ATTR} value: {native!r}")
if any(raw is not None for raw in fallback_status_raw_values.values()):
if not fallback:
errors.append(f"{REPLACEMENT_STATUS_ATTR} must not be empty")
elif fallback not in REPLACEMENT_STATUS_CODES:
errors.append(
f"unsupported {REPLACEMENT_STATUS_ATTR} value: {fallback!r}"
)
if any(raw is not None for raw in import_source_raw_values.values()):
source = native_import_source(elem)
if source != "pptx":
errors.append(f"unsupported {IMPORT_SOURCE_ATTR} value: {source!r}")
if (
visual_raw is None
and legacy_visual_raw is not None
and visual == "placeholder"
and route != "reconstruction-only"
):
errors.append(
f"{LEGACY_FALLBACK_KIND_ATTR}='placeholder' requires "
f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only'"
)
if route == "reconstruction-only" and visual != "placeholder":
errors.append(
f"{LEGACY_ROUTE_STATUS_ATTR}='reconstruction-only' requires "
f"{FALLBACK_KIND_ATTR}='placeholder'"
)
if native and fallback:
errors.append(
f"data-pptx-replace-with and {REPLACEMENT_STATUS_ATTR} are mutually exclusive"
)
return errors
def native_marker_release_block_reason(elem: ET.Element) -> str | None:
"""Return invalid status metadata that must block an export.
A valid ``reconstruction-only`` declaration is diagnostic rather than a
release block: default export keeps its visible placeholder, while an
an active replacement marker may still reconstruct a native Chart/Table.
"""
errors = native_marker_status_errors(elem)
if errors:
return f"invalid-status: {errors[0]}"
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
"""Minimal workbook generation for native chart data."""
from __future__ import annotations
import io
import zipfile
from typing import Any
try:
from xlsxwriter import Workbook as XlsxWriterWorkbook
except ImportError: # pragma: no cover - optional compatibility enhancement
XlsxWriterWorkbook = None
try:
from openpyxl import Workbook as OpenpyxlWorkbook
except ImportError: # pragma: no cover - optional compatibility enhancement
OpenpyxlWorkbook = None
from ..drawingml.utils import _xml_escape
from .marker_common import _excel_col
def _xlsx_cell_ref(row: int, col: int) -> str:
return f"{_excel_col(col)}{row}"
def _xlsx_cell(value: Any, row: int, col: int) -> str:
if value is None:
return ""
ref = _xlsx_cell_ref(row, col)
if isinstance(value, (int, float)) and not isinstance(value, bool):
return f'<c r="{ref}"><v>{value}</v></c>'
return (
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(str(value))}</t></is></c>'
)
def _minimal_workbook(rows: list[list[Any]]) -> bytes:
if XlsxWriterWorkbook is not None:
buffer = io.BytesIO()
workbook = XlsxWriterWorkbook(buffer, {
"in_memory": True,
"strings_to_formulas": False,
"strings_to_urls": False,
})
worksheet = workbook.add_worksheet("Sheet1")
for row_index, row in enumerate(rows):
for col_index, value in enumerate(row):
worksheet.write(row_index, col_index, value)
workbook.close()
return buffer.getvalue()
if OpenpyxlWorkbook is not None:
workbook = OpenpyxlWorkbook()
worksheet = workbook.active
worksheet.title = "Sheet1"
for row_index, row in enumerate(rows, start=1):
for col_index, value in enumerate(row, start=1):
cell = worksheet.cell(row=row_index, column=col_index, value=value)
if isinstance(value, str):
cell.data_type = "s"
buffer = io.BytesIO()
workbook.save(buffer)
workbook.close()
return buffer.getvalue()
sheet_rows = []
for row_index, values in enumerate(rows, start=1):
cells = "".join(
_xlsx_cell(value, row_index, col_index)
for col_index, value in enumerate(values, start=1)
)
sheet_rows.append(f'<row r="{row_index}">{cells}</row>')
entries = {
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="xl/workbook.xml"/>
</Relationships>''',
"xl/workbook.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
</workbook>''',
"xl/_rels/workbook.xml.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
Target="styles.xml"/>
</Relationships>''',
"xl/worksheets/sheet1.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheetData>{''.join(sheet_rows)}</sheetData>
</worksheet>''',
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
</styleSheet>''',
}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, data in entries.items():
zout.writestr(name, data.encode("utf-8"))
return buffer.getvalue()
def _minimal_category_chart_workbook(chart_data: dict[str, Any]) -> bytes:
if chart_data.get("kind") == "combo" and chart_data.get("independent_categories"):
plots = chart_data["plots"]
column_count = max(
int(plot["start_column"]) + len(plot["series"]) - 1
for plot in plots
)
rows: list[list[Any]] = [[None] * column_count]
for plot in plots:
category_column = int(plot["category_column"]) - 1
start_column = int(plot["start_column"]) - 1
for offset, series in enumerate(plot["series"]):
rows[0][start_column + offset] = series["name"]
for point_index, category in enumerate(plot["categories"], start=1):
while len(rows) <= point_index:
rows.append([None] * column_count)
rows[point_index][category_column] = category
for offset, series in enumerate(plot["series"]):
rows[point_index][start_column + offset] = (
series["values"][point_index - 1]
)
return _minimal_workbook(rows)
categories = chart_data["categories"]
series = chart_data["series"]
rows: list[list[Any]] = [[None] + [item["name"] for item in series]]
for row_index, category in enumerate(categories):
rows.append([category] + [item["values"][row_index] for item in series])
return _minimal_workbook(rows)
def _minimal_xy_chart_workbook(chart_data: dict[str, Any]) -> bytes:
series = chart_data["series"]
is_bubble = chart_data["type"] == "bubble"
rows: list[list[Any]] = [[]]
for item in series:
rows[0].extend([f"{item['name']} X", item["name"]])
if is_bubble:
rows[0].append(f"{item['name']} Size")
max_points = max(len(item["x"]) for item in series)
for point_idx in range(max_points):
row: list[Any] = []
for item in series:
if point_idx < len(item["x"]):
row.extend([item["x"][point_idx], item["y"][point_idx]])
if is_bubble:
row.append(item["sizes"][point_idx])
else:
row.extend(["", ""])
if is_bubble:
row.append("")
rows.append(row)
return _minimal_workbook(rows)
def _minimal_chart_ex_workbook(chart_data: dict[str, Any]) -> bytes:
chart_type = chart_data["type"]
if chart_type in {"sunburst", "treemap"}:
levels = chart_data["levels"]
rows: list[list[Any]] = [[f"Level {idx + 1}" for idx in range(len(levels))] + ["Value"]]
for row_idx, value in enumerate(chart_data["values"]):
rows.append([level[row_idx] for level in levels] + [value])
return _minimal_workbook(rows)
if chart_type == "histogram":
return _minimal_workbook([["Value"]] + [[value] for value in chart_data["values"]])
if chart_type in {"funnel", "pareto", "waterfall"}:
rows = [["Category", "Value"]]
rows.extend(
[category, chart_data["values"][idx]]
for idx, category in enumerate(chart_data["categories"])
)
return _minimal_workbook(rows)
if chart_type == "box_whisker":
rows = [[]]
for item in chart_data["series"]:
rows[0].extend([f"{item['name']} Category", item["name"]])
max_rows = max(len(item["values"]) for item in chart_data["series"])
for row_idx in range(max_rows):
row: list[Any] = []
for item in chart_data["series"]:
if row_idx < len(item["values"]):
row.extend([item["categories"][row_idx], item["values"][row_idx]])
else:
row.extend(["", ""])
rows.append(row)
return _minimal_workbook(rows)
raise RuntimeError(f"Native PPTX {chart_type} chart is outside current basic chart support")