171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""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"""<c:grouping\b[^>]*?\bval=["'](\w+)["']""")
|
|
_DLBL_POS_RE = re.compile(r"""<c:dLblPos\b[^>]*?\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"<c:(bar3DChart|barChart)\b[^>]*(?<!/)>.*?</c:\1\s*>", 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"<c:(\w+Chart)\b[^>]*(?<!/)>.*?</c:\1\s*>", re.DOTALL)
|
|
|
|
_AXID_RE = re.compile(
|
|
r"""\s*<c:axId\b[^>]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*</c:axId\s*>)"""
|
|
)
|
|
|
|
_AXIS_DECL_RE = re.compile(
|
|
r"""<c:(catAx|valAx|serAx|dateAx)\b[^>]*(?<!/)>\s*<c:axId\b[^>]*?\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}: <c:{kind}> declares no <c:axId> 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}: <c:{kind}> 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
|