1114 lines
39 KiB
Python
1114 lines
39 KiB
Python
"""Native PowerPoint table conversion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from .marker_attributes import native_import_source
|
|
|
|
from ..drawingml.context import ConvertContext, ShapeResult
|
|
from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml
|
|
from ..drawingml.utils import (
|
|
_xml_escape,
|
|
detect_text_lang,
|
|
font_px_to_hpt,
|
|
text_has_rtl_characters,
|
|
text_uses_rtl,
|
|
)
|
|
from .chart_style import _font_face_xml
|
|
from .marker_common import (
|
|
TABLE_URI,
|
|
_bool_attr,
|
|
_bounds,
|
|
_clean_hex,
|
|
_compact_key,
|
|
_first_present,
|
|
_font_size_hpt,
|
|
_hex_or_none,
|
|
_normalized_fallback_text,
|
|
_number,
|
|
_powerpoint_emu,
|
|
_powerpoint_line_width_emu,
|
|
_visible_fallback_texts,
|
|
)
|
|
|
|
|
|
def _table_text_run(
|
|
text: str,
|
|
*,
|
|
color: str | None,
|
|
bold: bool | None,
|
|
font_size: int | None,
|
|
font_face: str | None,
|
|
language: str | None,
|
|
default_language: str | None,
|
|
theme_color_spec: ThemeColorSpec | None,
|
|
italic: bool | None = None,
|
|
underline: bool | None = None,
|
|
strike: bool | None = None,
|
|
alt_language: str | None = None,
|
|
exact_font_face: bool = False,
|
|
) -> str:
|
|
size_attr = f' sz="{font_size}"' if font_size is not None else ""
|
|
bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else ""
|
|
italic_attr = f' i="{_bool_attr(italic)}"' if italic is not None else ""
|
|
underline_attr = (
|
|
f' u="{"sng" if underline else "none"}"'
|
|
if underline is not None else ""
|
|
)
|
|
strike_attr = (
|
|
f' strike="{"sngStrike" if strike else "noStrike"}"'
|
|
if strike is not None else ""
|
|
)
|
|
resolved_language = language or detect_text_lang(text, default_language)
|
|
language_attr = f' lang="{_xml_escape(resolved_language)}"'
|
|
alt_language_attr = (
|
|
f' altLang="{_xml_escape(alt_language)}"' if alt_language else ""
|
|
)
|
|
color_xml = (
|
|
f'<a:solidFill>{color_node_xml(color, theme_color_spec, "text")}</a:solidFill>'
|
|
if color else ""
|
|
)
|
|
if exact_font_face and font_face:
|
|
escaped_face = _xml_escape(font_face)
|
|
font_xml = (
|
|
f'<a:latin typeface="{escaped_face}"/>'
|
|
f'<a:ea typeface="{escaped_face}"/>'
|
|
f'<a:cs typeface="{escaped_face}"/>'
|
|
)
|
|
else:
|
|
font_xml = _font_face_xml(font_face)
|
|
rtl_xml = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
|
space_attr = ' xml:space="preserve"' if text != text.strip() else ""
|
|
return (
|
|
f'<a:r><a:rPr{language_attr}{alt_language_attr}{size_attr}{bold_attr}'
|
|
f'{italic_attr}{underline_attr}{strike_attr}>'
|
|
f'{color_xml}'
|
|
f'{font_xml}'
|
|
f'{rtl_xml}'
|
|
"</a:rPr>"
|
|
f"<a:t{space_attr}>{_xml_escape(text)}</a:t></a:r>"
|
|
)
|
|
|
|
|
|
def _table_paragraph_properties(
|
|
align: str,
|
|
*,
|
|
emit_align: bool,
|
|
text: str,
|
|
language: str | None,
|
|
) -> str:
|
|
"""Build table paragraph properties with project-aware direction."""
|
|
attrs = []
|
|
if emit_align:
|
|
attrs.append(f'algn="{align}"')
|
|
if text_uses_rtl(text, language):
|
|
attrs.append('rtl="1"')
|
|
suffix = f" {' '.join(attrs)}" if attrs else ''
|
|
return f'<a:pPr{suffix}/>'
|
|
|
|
|
|
def _cell_payload(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
return {"text": "" if value is None else str(value)}
|
|
|
|
|
|
_TABLE_CANONICAL_SPAN_KEYS = {
|
|
"col_span",
|
|
"row_span",
|
|
}
|
|
_TABLE_UNSUPPORTED_SPAN_KEYS = {
|
|
"colSpan",
|
|
"grid_span",
|
|
"gridSpan",
|
|
"hMerge",
|
|
"merge",
|
|
"merged",
|
|
"rowSpan",
|
|
"vMerge",
|
|
}
|
|
_TABLE_TOP_LEVEL_SPAN_KEYS = {
|
|
"merge_cells",
|
|
"merged_cells",
|
|
"merges",
|
|
"spans",
|
|
}
|
|
_TABLE_MAX_ROWS = 1000
|
|
_TABLE_MAX_COLUMNS = 1000
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _TableMergeRegion:
|
|
row: int
|
|
col: int
|
|
row_span: int
|
|
col_span: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _TableBorderSpec:
|
|
style: str
|
|
color: str | None = None
|
|
width: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _TableRun:
|
|
text: str
|
|
bold: bool | None = None
|
|
italic: bool | None = None
|
|
underline: bool | None = None
|
|
strike: bool | None = None
|
|
color: str | None = None
|
|
font_size: int | None = None
|
|
font_family: str | None = None
|
|
lang: str | None = None
|
|
alt_lang: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _TableParagraph:
|
|
text: str
|
|
align: str | None = None
|
|
runs: tuple[_TableRun, ...] | None = None
|
|
|
|
|
|
def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
|
|
columns = payload.get("columns") or []
|
|
rows = payload.get("rows") or []
|
|
if not isinstance(columns, list) or not isinstance(rows, list):
|
|
raise RuntimeError("Native PPTX table requires columns/rows lists")
|
|
for idx, row in enumerate(rows, start=1):
|
|
if not isinstance(row, list):
|
|
raise RuntimeError(f"Native PPTX table row {idx} must be a list")
|
|
|
|
table_rows = [list(columns)] if columns else []
|
|
table_rows.extend(list(row) for row in rows)
|
|
return table_rows
|
|
|
|
|
|
def _table_cell_paragraphs(
|
|
cell_data: dict[str, Any],
|
|
) -> tuple[_TableParagraph, ...] | None:
|
|
if "paragraphs" not in cell_data:
|
|
return None
|
|
if "text" in cell_data:
|
|
raise RuntimeError(
|
|
"Native PPTX table cell text and paragraphs are mutually exclusive"
|
|
)
|
|
raw_paragraphs = cell_data.get("paragraphs")
|
|
if not isinstance(raw_paragraphs, list) or not raw_paragraphs:
|
|
raise RuntimeError(
|
|
"Native PPTX table cell paragraphs must be a non-empty list"
|
|
)
|
|
|
|
paragraphs: list[_TableParagraph] = []
|
|
for idx, value in enumerate(raw_paragraphs, start=1):
|
|
if isinstance(value, str):
|
|
paragraphs.append(_TableParagraph(value))
|
|
continue
|
|
if not isinstance(value, dict):
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} must be a string or object"
|
|
)
|
|
if set(value) - {"text", "runs", "align"}:
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} accepts text/runs/align only"
|
|
)
|
|
has_text = "text" in value
|
|
has_runs = "runs" in value
|
|
if has_text == has_runs:
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} requires exactly one of text/runs"
|
|
)
|
|
align = value.get("align")
|
|
if align is not None and align not in {"l", "ctr", "r"}:
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} align must be l, ctr, or r"
|
|
)
|
|
if has_text:
|
|
text = value.get("text")
|
|
if not isinstance(text, str):
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} text must be a string"
|
|
)
|
|
paragraphs.append(_TableParagraph(text, align))
|
|
continue
|
|
|
|
raw_runs = value.get("runs")
|
|
if not isinstance(raw_runs, list) or not raw_runs:
|
|
raise RuntimeError(
|
|
f"Native PPTX table paragraph {idx} runs must be a non-empty list"
|
|
)
|
|
runs = tuple(
|
|
_table_run(run, paragraph_idx=idx, run_idx=run_idx)
|
|
for run_idx, run in enumerate(raw_runs, start=1)
|
|
)
|
|
paragraphs.append(
|
|
_TableParagraph("".join(run.text for run in runs), align, runs)
|
|
)
|
|
return tuple(paragraphs)
|
|
|
|
|
|
def _table_run(
|
|
value: Any,
|
|
*,
|
|
paragraph_idx: int,
|
|
run_idx: int,
|
|
) -> _TableRun:
|
|
label = f"paragraph {paragraph_idx} run {run_idx}"
|
|
if not isinstance(value, dict) or "text" not in value:
|
|
raise RuntimeError(f"Native PPTX table {label} must be a text object")
|
|
allowed = {
|
|
"text", "bold", "italic", "underline", "strike", "color",
|
|
"font_size", "font_family", "lang", "alt_lang",
|
|
}
|
|
unknown = set(value) - allowed
|
|
if unknown:
|
|
fields = ", ".join(sorted(unknown))
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} contains unsupported field(s): {fields}"
|
|
)
|
|
text = value.get("text")
|
|
if not isinstance(text, str):
|
|
raise RuntimeError(f"Native PPTX table {label} text must be a string")
|
|
|
|
booleans: dict[str, bool | None] = {}
|
|
for field in ("bold", "italic", "underline", "strike"):
|
|
raw = value.get(field)
|
|
if raw is not None and not isinstance(raw, bool):
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} {field} must be a JSON boolean"
|
|
)
|
|
booleans[field] = raw
|
|
|
|
color: str | None = None
|
|
if value.get("color") is not None:
|
|
if not isinstance(value["color"], str):
|
|
raise RuntimeError(f"Native PPTX table {label} color must be a string")
|
|
color = _hex_or_none(value["color"])
|
|
if color is None:
|
|
raise RuntimeError(f"Native PPTX table {label} color is unsupported")
|
|
|
|
font_size: int | None = None
|
|
if value.get("font_size") is not None:
|
|
font_size_px = _number(value["font_size"], f"table {label} font_size")
|
|
if not 100 / 75 <= font_size_px <= 400000 / 75:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} font_size is outside DrawingML range"
|
|
)
|
|
font_size = font_px_to_hpt(font_size_px)
|
|
if not 100 <= font_size <= 400000:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} font_size is outside DrawingML range"
|
|
)
|
|
|
|
font_family: str | None = None
|
|
if value.get("font_family") is not None:
|
|
if not isinstance(value["font_family"], str):
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} font_family must be a string"
|
|
)
|
|
font_family = value["font_family"].strip()
|
|
if not font_family or "," in font_family:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} font_family must be one typeface"
|
|
)
|
|
|
|
languages: dict[str, str | None] = {}
|
|
for field in ("lang", "alt_lang"):
|
|
raw = value.get(field)
|
|
if raw is None:
|
|
languages[field] = None
|
|
continue
|
|
if not isinstance(raw, str) or not raw.strip():
|
|
raise RuntimeError(
|
|
f"Native PPTX table {label} {field} must be a non-empty string"
|
|
)
|
|
languages[field] = raw.strip()
|
|
|
|
return _TableRun(
|
|
text=text,
|
|
bold=booleans["bold"],
|
|
italic=booleans["italic"],
|
|
underline=booleans["underline"],
|
|
strike=booleans["strike"],
|
|
color=color,
|
|
font_size=font_size,
|
|
font_family=font_family,
|
|
lang=languages["lang"],
|
|
alt_lang=languages["alt_lang"],
|
|
)
|
|
|
|
|
|
def _table_span_value(
|
|
cell_data: dict[str, Any],
|
|
key: str,
|
|
*,
|
|
row_idx: int,
|
|
col_idx: int,
|
|
) -> int:
|
|
value = cell_data.get(key, 1)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
raise RuntimeError(
|
|
f"Native PPTX table cell R{row_idx}C{col_idx} {key} must be a "
|
|
"positive JSON integer"
|
|
)
|
|
return value
|
|
|
|
|
|
def _merge_covered_cell_is_blank(value: Any) -> bool:
|
|
if value is None or value == "":
|
|
return True
|
|
if not isinstance(value, dict):
|
|
return False
|
|
if any(key != "text" for key in value):
|
|
return False
|
|
text = value.get("text")
|
|
return text is None or text == ""
|
|
|
|
|
|
def _resolve_table_merge_layout(
|
|
payload: dict[str, Any],
|
|
table_rows: list[list[Any]],
|
|
col_count: int,
|
|
) -> dict[tuple[int, int], _TableMergeRegion]:
|
|
for key in _TABLE_TOP_LEVEL_SPAN_KEYS:
|
|
if key in payload:
|
|
raise RuntimeError(
|
|
f"Native PPTX table uses unsupported top-level merged-cell field: {key}"
|
|
)
|
|
|
|
owners: dict[tuple[int, int], _TableMergeRegion] = {}
|
|
for row_idx, row in enumerate(table_rows, start=1):
|
|
for col_idx, cell in enumerate(row, start=1):
|
|
if isinstance(cell, dict):
|
|
used_keys = sorted(
|
|
key for key in _TABLE_UNSUPPORTED_SPAN_KEYS if key in cell
|
|
)
|
|
if used_keys:
|
|
keys = ", ".join(used_keys)
|
|
raise RuntimeError(
|
|
f"Native PPTX table cell R{row_idx}C{col_idx} uses "
|
|
f"unsupported merged-cell field(s): {keys}; use row_span/col_span "
|
|
"on the merge anchor"
|
|
)
|
|
|
|
position = (row_idx - 1, col_idx - 1)
|
|
owner = owners.get(position)
|
|
if owner is not None:
|
|
if isinstance(cell, dict) and any(
|
|
key in cell for key in _TABLE_CANONICAL_SPAN_KEYS
|
|
):
|
|
raise RuntimeError(
|
|
f"Native PPTX table merge anchor R{row_idx}C{col_idx} overlaps "
|
|
f"merge rooted at R{owner.row + 1}C{owner.col + 1}"
|
|
)
|
|
if not _merge_covered_cell_is_blank(cell):
|
|
raise RuntimeError(
|
|
f"Native PPTX table merge-covered cell R{row_idx}C{col_idx} "
|
|
"must be blank"
|
|
)
|
|
continue
|
|
|
|
cell_data = _cell_payload(cell)
|
|
row_span = _table_span_value(
|
|
cell_data, "row_span", row_idx=row_idx, col_idx=col_idx,
|
|
)
|
|
col_span = _table_span_value(
|
|
cell_data, "col_span", row_idx=row_idx, col_idx=col_idx,
|
|
)
|
|
if row_span == 1 and col_span == 1:
|
|
continue
|
|
if (
|
|
row_idx - 1 + row_span > len(table_rows)
|
|
or col_idx - 1 + col_span > col_count
|
|
):
|
|
raise RuntimeError(
|
|
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} exceeds "
|
|
f"the resolved {len(table_rows)}x{col_count} grid"
|
|
)
|
|
|
|
region = _TableMergeRegion(
|
|
row=row_idx - 1,
|
|
col=col_idx - 1,
|
|
row_span=row_span,
|
|
col_span=col_span,
|
|
)
|
|
for covered_row in range(region.row, region.row + region.row_span):
|
|
for covered_col in range(region.col, region.col + region.col_span):
|
|
covered_position = (covered_row, covered_col)
|
|
prior = owners.get(covered_position)
|
|
if prior is not None:
|
|
raise RuntimeError(
|
|
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} "
|
|
f"overlaps merge rooted at R{prior.row + 1}C{prior.col + 1}"
|
|
)
|
|
owners[covered_position] = region
|
|
return owners
|
|
|
|
|
|
def _grid_is_strict(payload: dict[str, Any]) -> bool:
|
|
value = payload.get("strict_grid", payload.get("strictGrid"))
|
|
return _table_bool(value, "strict_grid", default=False)
|
|
|
|
|
|
def _table_bool(value: Any, field_name: str, *, default: bool) -> bool:
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value in (0, 1):
|
|
return bool(value)
|
|
key = _compact_key(value)
|
|
if key in {"1", "on", "true", "yes"}:
|
|
return True
|
|
if key in {"0", "false", "no", "off"}:
|
|
return False
|
|
raise RuntimeError(f"Native PPTX table {field_name} must be a boolean")
|
|
|
|
|
|
def _table_header_rows(payload: dict[str, Any], row_count: int) -> int:
|
|
default = 1 if payload.get("columns") else 0
|
|
value = _number(payload.get("header_rows", default), "table header_rows")
|
|
if not value.is_integer():
|
|
raise RuntimeError("Native PPTX table header_rows must be an integer")
|
|
header_rows = int(value)
|
|
if not 0 <= header_rows <= row_count:
|
|
raise RuntimeError(
|
|
"Native PPTX table header_rows must be between zero and the resolved row count"
|
|
)
|
|
return header_rows
|
|
|
|
|
|
def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]) -> int:
|
|
if not table_rows:
|
|
raise RuntimeError("Native PPTX table requires at least one row")
|
|
col_count = max(len(row) for row in table_rows)
|
|
if col_count <= 0:
|
|
raise RuntimeError("Native PPTX table requires at least one column")
|
|
if len(table_rows) > _TABLE_MAX_ROWS or col_count > _TABLE_MAX_COLUMNS:
|
|
raise RuntimeError("Native PPTX table supports at most 1000 rows and columns")
|
|
if _grid_is_strict(payload) and any(len(row) != col_count for row in table_rows):
|
|
raise RuntimeError("Native PPTX table strict_grid requires every row to have the same length")
|
|
|
|
column_widths = payload.get("column_widths")
|
|
if column_widths is not None:
|
|
if not isinstance(column_widths, list) or len(column_widths) != col_count:
|
|
raise RuntimeError("Native PPTX table column_widths must match the resolved column count")
|
|
_table_weights(column_widths, "column_widths")
|
|
|
|
row_heights = payload.get("row_heights")
|
|
if row_heights is not None:
|
|
if not isinstance(row_heights, list) or len(row_heights) != len(table_rows):
|
|
raise RuntimeError("Native PPTX table row_heights must match the resolved row count")
|
|
_table_weights(row_heights, "row_heights")
|
|
|
|
return col_count
|
|
|
|
|
|
def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
|
|
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
|
_table_bool(style.get("band_row"), "style.band_row", default=True)
|
|
if "borders" in style:
|
|
raise RuntimeError(
|
|
"Native PPTX table per-side borders are supported on cells only"
|
|
)
|
|
for row in table_rows:
|
|
for cell in row:
|
|
cell_data = _cell_payload(cell)
|
|
_table_cell_paragraphs(cell_data)
|
|
if "bold" in cell_data:
|
|
_table_bool(cell_data["bold"], "cell bold", default=False)
|
|
for side in ("left", "right", "top", "bottom"):
|
|
_table_padding_value(cell_data, style, side)
|
|
for border in _table_border_specs(cell_data, style).values():
|
|
if border is None or border.style == "none":
|
|
continue
|
|
assert border.width is not None
|
|
_powerpoint_line_width_emu(
|
|
border.width,
|
|
"table border_width",
|
|
)
|
|
_table_anchor(cell_data, style)
|
|
|
|
|
|
def _validate_table_payload(
|
|
payload: dict[str, Any],
|
|
) -> tuple[list[list[Any]], int, dict[tuple[int, int], _TableMergeRegion]]:
|
|
table_rows = _table_rows(payload)
|
|
col_count = _validate_table_lengths(payload, table_rows)
|
|
for row in table_rows:
|
|
row.extend([""] * (col_count - len(row)))
|
|
merge_layout = _resolve_table_merge_layout(payload, table_rows, col_count)
|
|
_table_header_rows(payload, len(table_rows))
|
|
_validate_table_cell_formatting(payload, table_rows)
|
|
return table_rows, col_count, merge_layout
|
|
|
|
|
|
def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for row in table_rows:
|
|
for cell in row:
|
|
cell_data = _cell_payload(cell)
|
|
paragraphs = _table_cell_paragraphs(cell_data)
|
|
texts = (
|
|
[paragraph.text for paragraph in paragraphs]
|
|
if paragraphs is not None
|
|
else [cell_data.get("text")]
|
|
)
|
|
for value in texts:
|
|
text = _normalized_fallback_text(value)
|
|
if text:
|
|
counts[text] = counts.get(text, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _native_table_warnings(elem: ET.Element, table_rows: list[list[Any]]) -> list[str]:
|
|
fallback_texts = _visible_fallback_texts(elem)
|
|
if not fallback_texts:
|
|
return []
|
|
metadata_counts = _native_table_metadata_texts(table_rows)
|
|
missing: list[str] = []
|
|
seen_counts: dict[str, int] = {}
|
|
for text in fallback_texts:
|
|
seen_counts[text] = seen_counts.get(text, 0) + 1
|
|
if seen_counts[text] > metadata_counts.get(text, 0):
|
|
missing.append(text)
|
|
if not missing:
|
|
return []
|
|
|
|
sample = ", ".join(repr(text) for text in missing[:5])
|
|
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
|
|
return [
|
|
"Native PPTX table fallback text is missing from metadata columns/rows "
|
|
f"and will disappear with --native-charts-and-tables: {sample}{suffix}"
|
|
]
|
|
|
|
|
|
def _weighted_lengths(
|
|
total: int,
|
|
count: int,
|
|
weights: list[Any] | None,
|
|
*,
|
|
field_name: str,
|
|
) -> list[int]:
|
|
if total < count:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {field_name} cannot fit {count} positive grid lengths"
|
|
)
|
|
if weights is None:
|
|
base, remainder = divmod(total, count)
|
|
return [base + (1 if idx < remainder else 0) for idx in range(count)]
|
|
|
|
numeric = _table_weights(weights, field_name)
|
|
largest = max(numeric)
|
|
normalized = [weight / largest for weight in numeric]
|
|
normalized_total = sum(normalized)
|
|
distributable = total - count
|
|
quotas = [distributable * weight / normalized_total for weight in normalized]
|
|
extras = [int(quota) for quota in quotas]
|
|
remainder = distributable - sum(extras)
|
|
if remainder < 0 or remainder > count:
|
|
raise RuntimeError(f"Native PPTX table {field_name} allocation overflowed")
|
|
order = sorted(
|
|
range(count),
|
|
key=lambda idx: (quotas[idx] - extras[idx], normalized[idx], -idx),
|
|
reverse=True,
|
|
)
|
|
for idx in order[:remainder]:
|
|
extras[idx] += 1
|
|
return [extra + 1 for extra in extras]
|
|
|
|
|
|
def _table_weights(weights: list[Any], field_name: str) -> list[float]:
|
|
numeric = [
|
|
_number(weight, f"{field_name}[{idx}]")
|
|
for idx, weight in enumerate(weights, start=1)
|
|
]
|
|
if any(weight < 0 for weight in numeric):
|
|
raise RuntimeError(f"Native PPTX table {field_name} values must be non-negative")
|
|
if max(numeric, default=0.0) <= 0:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {field_name} values must sum to a positive number"
|
|
)
|
|
return numeric
|
|
|
|
|
|
def _table_padding_value(
|
|
cell_data: dict[str, Any],
|
|
style: dict[str, Any],
|
|
side: str,
|
|
) -> int | None:
|
|
side_keys = {
|
|
"left": ("left", "l", "padding_left", "paddingLeft"),
|
|
"right": ("right", "r", "padding_right", "paddingRight"),
|
|
"top": ("top", "t", "padding_top", "paddingTop"),
|
|
"bottom": ("bottom", "b", "padding_bottom", "paddingBottom"),
|
|
}
|
|
|
|
def from_source(source: dict[str, Any]) -> Any:
|
|
for key in side_keys[side]:
|
|
if key in source:
|
|
return source[key]
|
|
padding = source.get("padding", source.get("cell_padding"))
|
|
if isinstance(padding, dict):
|
|
for key in side_keys[side]:
|
|
if key in padding:
|
|
return padding[key]
|
|
elif padding is not None:
|
|
return padding
|
|
return None
|
|
|
|
value = from_source(cell_data)
|
|
if value is None:
|
|
value = from_source(style)
|
|
if value is None:
|
|
return None
|
|
pixels = max(_number(value, f"table {side} padding"), 0.0)
|
|
return _powerpoint_emu(pixels, f"table {side} padding")
|
|
|
|
|
|
def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
|
|
attrs = []
|
|
for attr, side in (
|
|
("marL", "left"),
|
|
("marR", "right"),
|
|
("marT", "top"),
|
|
("marB", "bottom"),
|
|
):
|
|
value = _table_padding_value(cell_data, style, side)
|
|
if value is not None:
|
|
attrs.append(f'{attr}="{value}"')
|
|
return (" " + " ".join(attrs)) if attrs else ""
|
|
|
|
|
|
def _table_anchor(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
|
|
raw = _first_present(
|
|
cell_data.get("valign"),
|
|
cell_data.get("vertical_align"),
|
|
style.get("valign"),
|
|
style.get("vertical_align"),
|
|
"middle",
|
|
)
|
|
aliases = {
|
|
"bottom": "b",
|
|
"b": "b",
|
|
"center": "ctr",
|
|
"ctr": "ctr",
|
|
"middle": "ctr",
|
|
"top": "t",
|
|
"t": "t",
|
|
}
|
|
anchor = aliases.get(_compact_key(raw))
|
|
if not anchor:
|
|
raise RuntimeError("Native PPTX table valign must be one of: top, middle, bottom")
|
|
return anchor
|
|
|
|
|
|
def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> float:
|
|
width_raw = cell_data.get("border_width", cell_data.get("borderWidth", style.get("border_width")))
|
|
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
|
|
if width_raw is None and color_raw is None:
|
|
return 0.0
|
|
return _number(1 if width_raw is None else width_raw, "table border_width")
|
|
|
|
|
|
_TABLE_BORDER_SIDES = ("left", "right", "top", "bottom")
|
|
_TABLE_BORDER_TAGS = {
|
|
"left": "lnL",
|
|
"right": "lnR",
|
|
"top": "lnT",
|
|
"bottom": "lnB",
|
|
}
|
|
|
|
|
|
def _strict_table_border_color(value: Any, side: str) -> str:
|
|
raw = value if isinstance(value, str) else ""
|
|
if len(raw) != 7 or not raw.startswith("#"):
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} border color must be #RRGGBB"
|
|
)
|
|
try:
|
|
int(raw[1:], 16)
|
|
except ValueError as exc:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} border color must be #RRGGBB"
|
|
) from exc
|
|
return raw[1:].upper()
|
|
|
|
|
|
def _table_border_override(value: Any, side: str) -> _TableBorderSpec:
|
|
if not isinstance(value, dict):
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} border must be an object"
|
|
)
|
|
border_style = value.get("style")
|
|
if border_style == "none":
|
|
if set(value) != {"style"}:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} border style none accepts no other fields"
|
|
)
|
|
return _TableBorderSpec("none")
|
|
if border_style != "solid":
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} border style must be solid or none"
|
|
)
|
|
if set(value) != {"style", "color", "width"}:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} solid border requires style/color/width only"
|
|
)
|
|
width = _number(value.get("width"), f"table {side} border width")
|
|
if width <= 0:
|
|
raise RuntimeError(
|
|
f"Native PPTX table {side} solid border width must be positive"
|
|
)
|
|
_powerpoint_line_width_emu(width, f"table {side} border width")
|
|
return _TableBorderSpec(
|
|
"solid",
|
|
color=_strict_table_border_color(value.get("color"), side),
|
|
width=width,
|
|
)
|
|
|
|
|
|
def _table_border_specs(
|
|
cell_data: dict[str, Any],
|
|
style: dict[str, Any],
|
|
) -> dict[str, _TableBorderSpec | None]:
|
|
raw_borders = cell_data.get("borders")
|
|
if raw_borders is None:
|
|
border_overrides: dict[str, Any] = {}
|
|
elif not isinstance(raw_borders, dict):
|
|
raise RuntimeError("Native PPTX table cell borders must be an object")
|
|
else:
|
|
unknown = sorted(set(raw_borders) - set(_TABLE_BORDER_SIDES))
|
|
if unknown:
|
|
raise RuntimeError(
|
|
"Native PPTX table cell borders use unsupported side(s): "
|
|
+ ", ".join(unknown)
|
|
)
|
|
border_overrides = raw_borders
|
|
|
|
legacy_width = _table_border_width(cell_data, style)
|
|
legacy_spec = (
|
|
_TableBorderSpec(
|
|
"solid",
|
|
color=_clean_hex(
|
|
cell_data.get(
|
|
"border_color",
|
|
cell_data.get("borderColor", style.get("border_color")),
|
|
),
|
|
"#D9DEE7",
|
|
),
|
|
width=legacy_width,
|
|
)
|
|
if legacy_width > 0
|
|
else None
|
|
)
|
|
return {
|
|
side: (
|
|
_table_border_override(border_overrides[side], side)
|
|
if side in border_overrides
|
|
else legacy_spec
|
|
)
|
|
for side in _TABLE_BORDER_SIDES
|
|
}
|
|
|
|
|
|
def _table_border_xml(
|
|
cell_data: dict[str, Any],
|
|
style: dict[str, Any],
|
|
theme_color_spec: ThemeColorSpec | None,
|
|
) -> str:
|
|
border_xml: list[str] = []
|
|
for side, border in _table_border_specs(cell_data, style).items():
|
|
if border is None:
|
|
continue
|
|
tag = _TABLE_BORDER_TAGS[side]
|
|
if border.style == "none":
|
|
border_xml.append(f'<a:{tag}><a:noFill/></a:{tag}>')
|
|
continue
|
|
assert border.color is not None and border.width is not None
|
|
line_width = _powerpoint_line_width_emu(
|
|
border.width, f"table {side} border width",
|
|
)
|
|
border_xml.append(
|
|
f'<a:{tag} w="{line_width}">'
|
|
f'<a:solidFill>{color_node_xml(border.color, theme_color_spec, "stroke")}'
|
|
'</a:solidFill>'
|
|
'<a:prstDash val="solid"/>'
|
|
f'</a:{tag}>'
|
|
)
|
|
return "".join(border_xml)
|
|
|
|
|
|
def _table_merge_attrs(
|
|
region: _TableMergeRegion | None,
|
|
row_idx: int,
|
|
col_idx: int,
|
|
) -> str:
|
|
if region is None:
|
|
return ""
|
|
attrs: list[str] = []
|
|
if row_idx == region.row and region.row_span > 1:
|
|
attrs.append(f'rowSpan="{region.row_span}"')
|
|
if col_idx == region.col and region.col_span > 1:
|
|
attrs.append(f'gridSpan="{region.col_span}"')
|
|
if col_idx > region.col:
|
|
attrs.append('hMerge="1"')
|
|
if row_idx > region.row:
|
|
attrs.append('vMerge="1"')
|
|
return (" " + " ".join(attrs)) if attrs else ""
|
|
|
|
|
|
def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
|
|
table_rows, col_count, merge_layout = _validate_table_payload(payload)
|
|
header_rows = _table_header_rows(payload, len(table_rows))
|
|
preserve_source_style = native_import_source(elem) == "pptx"
|
|
|
|
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
|
header_fill = _clean_hex(style.get("header_fill"), "#1F4E79")
|
|
header_text = _clean_hex(style.get("header_text"), "#FFFFFF")
|
|
body_fill = _clean_hex(style.get("body_fill"), "#FFFFFF")
|
|
body_text = _clean_hex(style.get("body_text"), "#1F2937")
|
|
band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA")
|
|
font_face = str(style["font_family"]) if style.get("font_family") else None
|
|
body_font_size = _font_size_hpt(style.get("font_size"), 18)
|
|
band_rows_enabled = _table_bool(
|
|
style.get("band_row"),
|
|
"style.band_row",
|
|
default=True,
|
|
)
|
|
header_font_size = _font_size_hpt(
|
|
style.get("header_font_size", style.get("font_size")),
|
|
18,
|
|
)
|
|
|
|
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
|
|
|
|
column_widths = payload.get("column_widths")
|
|
grid_widths = _weighted_lengths(
|
|
ext_cx,
|
|
col_count,
|
|
column_widths if isinstance(column_widths, list) else None,
|
|
field_name="column_widths",
|
|
)
|
|
row_heights_raw = payload.get("row_heights")
|
|
row_heights = _weighted_lengths(
|
|
ext_cy,
|
|
len(table_rows),
|
|
row_heights_raw if isinstance(row_heights_raw, list) else None,
|
|
field_name="row_heights",
|
|
)
|
|
|
|
grid_xml = "".join(f'<a:gridCol w="{width}"/>' for width in grid_widths)
|
|
rows_xml: list[str] = []
|
|
for row_idx, row in enumerate(table_rows):
|
|
is_header = row_idx < header_rows
|
|
cells_xml: list[str] = []
|
|
for col_idx, cell in enumerate(row):
|
|
merge_region = merge_layout.get((row_idx, col_idx))
|
|
merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx)
|
|
if merge_region is not None and (
|
|
row_idx != merge_region.row or col_idx != merge_region.col
|
|
):
|
|
cells_xml.append(
|
|
f'<a:tc{merge_attrs}>'
|
|
'<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>'
|
|
'<a:tcPr/>'
|
|
'</a:tc>'
|
|
)
|
|
continue
|
|
|
|
cell_data = _cell_payload(cell)
|
|
if preserve_source_style:
|
|
fill = (
|
|
_clean_hex(cell_data.get("fill"), "#FFFFFF")
|
|
if cell_data.get("fill") is not None else None
|
|
)
|
|
color = (
|
|
_clean_hex(cell_data.get("color"), "#000000")
|
|
if cell_data.get("color") is not None else None
|
|
)
|
|
align = str(cell_data.get("align") or "l")
|
|
else:
|
|
fill = _clean_hex(
|
|
cell_data.get("fill"),
|
|
header_fill if is_header else (
|
|
band_fill
|
|
if band_rows_enabled and row_idx % 2 == 0 and row_idx
|
|
else body_fill
|
|
),
|
|
)
|
|
color = _clean_hex(
|
|
cell_data.get("color"),
|
|
header_text if is_header else body_text,
|
|
)
|
|
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
|
|
if align not in {"l", "ctr", "r"}:
|
|
align = "l"
|
|
paragraphs = _table_cell_paragraphs(cell_data)
|
|
if preserve_source_style:
|
|
bold = (
|
|
_table_bool(cell_data["bold"], "cell bold", default=False)
|
|
if "bold" in cell_data else None
|
|
)
|
|
cell_font_size = (
|
|
_font_size_hpt(cell_data.get("font_size"), 18)
|
|
if "font_size" in cell_data else None
|
|
)
|
|
else:
|
|
bold = _table_bool(cell_data.get("bold"), "cell bold", default=is_header)
|
|
cell_font_size = (
|
|
_font_size_hpt(cell_data.get("font_size"), 18)
|
|
if "font_size" in cell_data
|
|
else body_font_size
|
|
)
|
|
if is_header and "font_size" not in cell_data:
|
|
cell_font_size = header_font_size
|
|
language = (
|
|
str(cell_data.get("lang") or style.get("lang") or "").strip()
|
|
or None
|
|
)
|
|
if paragraphs is None:
|
|
text = (
|
|
"" if cell_data.get("text") is None
|
|
else str(cell_data.get("text"))
|
|
)
|
|
default_language = language or ctx.primary_language
|
|
paragraph_props = _table_paragraph_properties(
|
|
align,
|
|
emit_align=align != "l",
|
|
text=text,
|
|
language=default_language,
|
|
)
|
|
text_run_xml = _table_text_run(
|
|
text,
|
|
color=color,
|
|
bold=bold,
|
|
font_size=cell_font_size,
|
|
font_face=font_face,
|
|
language=language,
|
|
default_language=ctx.primary_language,
|
|
theme_color_spec=ctx.theme_color_spec,
|
|
)
|
|
paragraphs_xml = (
|
|
f"<a:p>{paragraph_props}{text_run_xml}</a:p>"
|
|
)
|
|
else:
|
|
paragraph_parts: list[str] = []
|
|
for paragraph in paragraphs:
|
|
paragraph_align = paragraph.align or align
|
|
paragraph_text = (
|
|
paragraph.text
|
|
if paragraph.runs is None
|
|
else ''.join(run.text for run in paragraph.runs)
|
|
)
|
|
paragraph_props = _table_paragraph_properties(
|
|
paragraph_align,
|
|
emit_align=(
|
|
paragraph.align is not None
|
|
or paragraph_align != "l"
|
|
),
|
|
text=paragraph_text,
|
|
language=language or ctx.primary_language,
|
|
)
|
|
if paragraph.runs is None:
|
|
text_run_xml = _table_text_run(
|
|
paragraph.text,
|
|
color=color,
|
|
bold=bold,
|
|
font_size=cell_font_size,
|
|
font_face=font_face,
|
|
language=language,
|
|
default_language=ctx.primary_language,
|
|
theme_color_spec=ctx.theme_color_spec,
|
|
)
|
|
else:
|
|
text_run_xml = "".join(
|
|
_table_text_run(
|
|
run.text,
|
|
color=run.color or color,
|
|
bold=run.bold if run.bold is not None else bold,
|
|
font_size=(
|
|
run.font_size
|
|
if run.font_size is not None
|
|
else cell_font_size
|
|
),
|
|
font_face=run.font_family or font_face,
|
|
language=run.lang or language,
|
|
default_language=ctx.primary_language,
|
|
theme_color_spec=ctx.theme_color_spec,
|
|
italic=run.italic,
|
|
underline=run.underline,
|
|
strike=run.strike,
|
|
alt_language=run.alt_lang,
|
|
exact_font_face=run.font_family is not None,
|
|
)
|
|
for run in paragraph.runs
|
|
)
|
|
paragraph_parts.append(
|
|
f"<a:p>{paragraph_props}{text_run_xml}</a:p>"
|
|
)
|
|
paragraphs_xml = "".join(paragraph_parts)
|
|
anchor_keys = {"valign", "vertical_align"}
|
|
anchor_attr = ""
|
|
if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style):
|
|
anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
|
|
tc_pr_attrs = f'{anchor_attr}{_table_padding_attrs(cell_data, style)}'
|
|
border_xml = _table_border_xml(
|
|
cell_data,
|
|
style,
|
|
ctx.theme_color_spec,
|
|
)
|
|
fill_xml = (
|
|
'<a:solidFill>'
|
|
f'{color_node_xml(fill, ctx.theme_color_spec, "fill")}'
|
|
'</a:solidFill>'
|
|
if fill else ""
|
|
)
|
|
cells_xml.append(
|
|
f"<a:tc{merge_attrs}>"
|
|
"<a:txBody><a:bodyPr/><a:lstStyle/>"
|
|
f"{paragraphs_xml}"
|
|
"</a:txBody>"
|
|
f'<a:tcPr{tc_pr_attrs}>{border_xml}{fill_xml}</a:tcPr>'
|
|
"</a:tc>"
|
|
)
|
|
rows_xml.append(f'<a:tr h="{row_heights[row_idx]}">{"".join(cells_xml)}</a:tr>')
|
|
|
|
shape_id = ctx.next_id()
|
|
first_row = _bool_attr(header_rows > 0)
|
|
band_row = _bool_attr(band_rows_enabled)
|
|
table_style_id = style.get("table_style_id")
|
|
if table_style_id is None and not preserve_source_style:
|
|
table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
|
|
table_style_xml = (
|
|
f'<a:tableStyleId>{_xml_escape(str(table_style_id))}</a:tableStyleId>'
|
|
if table_style_id else ""
|
|
)
|
|
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Table {shape_id}"))
|
|
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="{TABLE_URI}">
|
|
<a:tbl>
|
|
<a:tblPr firstRow="{first_row}" bandRow="{band_row}">
|
|
{table_style_xml}
|
|
</a:tblPr>
|
|
<a:tblGrid>{grid_xml}</a:tblGrid>
|
|
{''.join(rows_xml)}
|
|
</a:tbl>
|
|
</a:graphicData>
|
|
</a:graphic>
|
|
</p:graphicFrame>'''
|
|
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
|