Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user