mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 13:05:28 +08:00
fa0fa64bdc
Add support for template formats (.dotx, .potx, .xltx) across validation and the helper scripts. Consolidate the shared office helpers into a single module, replacing the pack/unpack pipeline with explicit zip/unzip steps. Extraction now rejects symlink and path-traversal archive entries. Move run merging to a standalone docx script, since it only ever applied to Word documents. Fix the redlining validator so it compares against the original even when a document has no tracked changes, which is when an untracked edit would otherwise go unreported. Provision a LibreOffice user profile per invocation so conversions work in sandboxed environments. Trim the skill docs to the guidance that earns its place.
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
|