"""Find chart XML that PowerPoint refuses but the schema accepts. Detection only: for either fault more than one repair is valid, and only the author knows which was meant. """ from __future__ import annotations import re from typing import Mapping from . import part_text _CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") _GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") _DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") def _strip_ext_lst(text: str) -> str: out, cursor = [], 0 for lo, hi in _ext_lst_spans(text): out.append(text[cursor:lo]) cursor = hi out.append(text[cursor:]) return "".join(out) _BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) ILLEGAL_ON_STACKED = frozenset({"outEnd"}) LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") def _check_stacked_label_positions(part: str, xml: str) -> list[str]: problems: list[str] = [] for match in _BAR_GROUP_RE.finditer(xml): block = _strip_ext_lst(match.group(0)) group = match.group(1) grouping = _GROUPING_RE.search(block) if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: continue bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] for pos in sorted(set(bad)): problems.append( f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' f"{grouping.group(1)} {group}; PowerPoint allows only " f"{', '.join(LEGAL_ON_STACKED)} there" ) return problems _ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) _AXID_RE = re.compile( r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" ) _AXIS_DECL_RE = re.compile( r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" ) AXID_LIMIT = { "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, "bubbleChart": 2, "radarChart": 2, "stockChart": 2, "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, "surfaceChart": 3, "surface3DChart": 3, } AXID_MINIMUM = { "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, "bubbleChart": 2, "radarChart": 2, "stockChart": 2, "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, "line3DChart": 3, "surface3DChart": 3, } def _declared_axes(xml: str) -> dict[str, list[str]]: axes: dict[str, list[str]] = {} for kind, axid in _AXIS_DECL_RE.findall(xml): axes.setdefault(kind, []).append(axid) return axes def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: category = axes.get("catAx", []) + axes.get("dateAx", []) value = axes.get("valAx", []) series = axes.get("serAx", []) if len(category) != 1 or len(value) != 1 or len(series) > 1: return None ids = [category[0], value[0]] if limit >= 3 and series: ids.append(series[0]) return ids def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: if kind not in AXID_LIMIT: return None ids = _AXID_RE.findall(block) declared = {i for group in axes.values() for i in group} if len([i for i in ids if i in declared]) >= 2: return None return ids def _check_chart_axis_references(part: str, xml: str) -> list[str]: axes = _declared_axes(xml) problems: list[str] = [] declared = {i for group in axes.values() for i in group} for match in _ANY_CHART_GROUP_RE.finditer(xml): kind, block = match.group(1), match.group(0) ids = _undeclared_axes(kind, block, axes) if ids is None: continue if not ids: problems.append( f"{part}: declares no this part can resolve; a chart " f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" ) continue dead = [i for i in ids if i not in declared] canonical = _canonical_ids(axes, AXID_LIMIT[kind]) if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" else: hint = ("Fix: the part declares several axes of a kind -- declare the " "secondary axes the series expects, or drop them") detail = (f"of which {', '.join(dead)} name no declared axis" if dead else f"only {len(ids)} of which this part declares") problems.append( f"{part}: references axId {', '.join(ids)}, {detail}, " f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" ) return problems def _ext_lst_spans(text: str) -> list[tuple[int, int]]: spans: list[tuple[int, int]] = [] depth = 0 start = 0 for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): closing, self_closing = match.group(1), match.group(2) if self_closing: continue if closing: depth -= 1 if depth == 0: spans.append((start, match.end())) else: if depth == 0: start = match.start() depth += 1 return spans CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: problems: list[str] = [] for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): xml = part_text(files[part]) for check in CHART_CHECKS: problems.extend(check(part, xml)) return problems