Files
Templated-PPT-Master/runtime/ppt-master/scripts/docs/mask-gradient-smoke.md
T

274 lines
8.8 KiB
Markdown

# Mask and Gradient Maintenance Smoke
Run this manual smoke from the repository root after changing gradient
validation, gradient import/export, native background promotion, icon
expansion, or mask rejection. It keeps XML in memory except for temporary SVG
fixtures; do not turn it into a test framework or example deck.
```bash
python3 - <<'PY'
import math
import re
import sys
import tempfile
from pathlib import Path
from xml.etree import ElementTree as ET
scripts = Path("skills/ppt-master/scripts").resolve()
sys.path.insert(0, str(scripts))
from pptx_to_svg.color_resolver import ColorPalette
from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints, resolve_fill
from svg_quality_checker import SVGQualityChecker
from svg_to_pptx.drawingml.converter import (
SvgNativeConversionError,
convert_svg_to_slide_shapes,
)
from svg_to_pptx.drawingml.styles import build_gradient_fill
from svg_to_pptx.drawingml.utils import (
parse_project_linear_gradient_coordinate,
project_gradient_errors,
project_mask_errors,
)
SVG_NS = "http://www.w3.org/2000/svg"
def svg(fragment):
return ET.fromstring(
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" '
f'data-pptx-page-role="content">{fragment}</svg>'
)
valid = svg(
"""<defs>
<linearGradient id="linear">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="100%" stop-color="#F97316" stop-opacity="0.4"/>
</linearGradient>
<radialGradient id="radial" cx="0.25" cy="0.7" r="0.8">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#0F172A"/>
</radialGradient>
</defs>
<rect id="bg" x="0" y="0" width="1280" height="720"
fill="url(#linear)"/>"""
)
assert not project_gradient_errors(valid)
linear, radial = list(valid.find(f"{{{SVG_NS}}}defs"))
linear_xml = build_gradient_fill(linear)
radial_xml = build_gradient_fill(radial)
assert '<a:lin ang="0" scaled="1"/>' in linear_xml
assert '<a:alpha val="40000"/>' in linear_xml
assert '<a:path path="circle">' in radial_xml
assert (
'<a:fillToRect l="25000" t="70000" r="75000" b="30000"/>'
in radial_xml
)
radial.set("fx", "0.8")
radial.set("fy", "0.2")
focused_xml = build_gradient_fill(radial)
assert (
'<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>'
in focused_xml
)
native_gradient = ET.fromstring(
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
f'{focused_xml}</root>'
)[0]
restored = resolve_fill(native_gradient, None)
assert 'cx="0.5" cy="0.5" r="0.5" fx="0.8" fy="0.2"' in restored.defs[0]
assert '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>' in (
build_gradient_fill(ET.fromstring(restored.defs[0]))
)
radial.set("fx", "0")
radial.set("fy", "0")
outside_focus_errors = project_gradient_errors(valid)
assert any(
"must lie within the canonical circle" in error
for error in outside_focus_errors
), outside_focus_errors
try:
build_gradient_fill(radial)
except ValueError as exc:
assert "must lie within the canonical circle" in str(exc)
else:
raise AssertionError("outside radial focus reached DrawingML")
radial.set("fx", "0.8")
radial.set("fy", "0.2")
diagnostics = []
palette = ColorPalette(
None,
None,
strict=False,
diagnostic_sink=lambda code, message, fallback: diagnostics.append(
(code, message, fallback)
),
)
outside_native_xml = focused_xml.replace(
'l="80000" t="20000" r="20000" b="80000"',
'l="0" t="0" r="100000" b="100000"',
)
outside_native_gradient = ET.fromstring(
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
f"{outside_native_xml}</root>"
)[0]
normalized = resolve_fill(outside_native_gradient, palette)
assert " fx=" not in normalized.defs[0]
assert " fy=" not in normalized.defs[0]
assert any(
code == "path-gradient-focus-normalized"
for code, _message, _fallback in diagnostics
)
with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp:
source = Path(tmp) / "gradient.svg"
source.write_text(
ET.tostring(valid, encoding="unicode"),
encoding="utf-8",
)
trace = []
slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace)
assert slide_xml.count("<p:bg>") == 1
assert "<a:gradFill>" in slide_xml
assert trace[0]["summary"]["promoted_backgrounds"] == 1
assert any(
event.get("decision") == "native-background"
for event in trace[0]["events"]
)
invalid_gradients = [
(
"""<linearGradient id="single">
<stop offset="0" stop-color="#2563EB"/>
</linearGradient>""",
"requires at least two direct <stop> children",
),
(
"""<linearGradient id="descending">
<stop offset="1" stop-color="#2563EB"/>
<stop offset="0" stop-color="#F97316"/>
</linearGradient>""",
"offsets must be non-decreasing",
),
(
"""<linearGradient id="zero" x1="0.5" y1="0.5" x2="0.5" y2="0.5">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>""",
"linear gradient axis must not collapse to one point",
),
]
for definition, expected in invalid_gradients:
errors = project_gradient_errors(svg(f"<defs>{definition}</defs>"))
assert any(expected in error for error in errors), errors
mask_cases = [
"""<defs><mask id="fade"><rect width="1" height="1"/></mask></defs>""",
"""<rect width="100" height="100" mask="url(#fade)"/>""",
"""<rect width="100" height="100" style="mask: url(#fade)"/>""",
]
for fragment in mask_cases:
errors = project_mask_errors(svg(fragment))
assert any("unsupported SVG mask" in error for error in errors), errors
checker = SVGQualityChecker()
with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp:
for index, fragment in enumerate(mask_cases, start=1):
source = Path(tmp) / f"mask-{index}.svg"
source.write_text(
ET.tostring(svg(fragment), encoding="unicode"),
encoding="utf-8",
)
checked = checker.check_file(str(source))
assert any("mask" in error.lower() for error in checked["errors"])
try:
convert_svg_to_slide_shapes(source)
except SvgNativeConversionError as exc:
assert "invalid project mask" in str(exc)
else:
raise AssertionError(f"native export accepted {source.name}")
with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp:
project = Path(tmp)
icon_dir = project / "icons" / "imported"
icon_dir.mkdir(parents=True)
(icon_dir / "masked.svg").write_text(
f"""<svg xmlns="{SVG_NS}" viewBox="0 0 24 24">
<defs>
<mask id="fade">
<rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>
</mask>
</defs>
<rect x="0" y="0" width="24" height="24"
fill="#000000" mask="url(#fade)"/>
</svg>""",
encoding="utf-8",
)
source = project / "icon-mask.svg"
source.write_text(
ET.tostring(
svg(
"""<use data-icon="imported/masked"
x="20" y="20" width="24" height="24"/>"""
),
encoding="unicode",
),
encoding="utf-8",
)
checked = checker.check_file(str(source))
assert any(
"Icon imported/masked" in error and "mask" in error.lower()
for error in checked["errors"]
)
try:
convert_svg_to_slide_shapes(source)
except SvgNativeConversionError as exc:
assert "invalid project mask" in str(exc)
else:
raise AssertionError("native export accepted a masked icon")
x1, y1, x2, y2 = _angle_to_unit_endpoints(30)
assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2))
for value in (x1, y1, x2, y2):
assert math.isclose(
parse_project_linear_gradient_coordinate(str(value)),
value,
abs_tol=1e-9,
)
roundtrip = svg(
f"""<defs>
<linearGradient id="roundtrip"
x1="{x1:.9f}" y1="{y1:.9f}" x2="{x2:.9f}" y2="{y2:.9f}">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>
</defs>"""
)
assert not project_gradient_errors(roundtrip)
roundtrip_gradient = roundtrip.find(
f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient"
)
angle = int(
re.search(
r'<a:lin ang="(\d+)"',
build_gradient_fill(roundtrip_gradient),
).group(1)
) / 60000
assert math.isclose(angle, 30, abs_tol=0.01), angle
print("Mask and gradient smoke: passed")
PY
```
The three invalid-gradient cases, the outside-circle radial focus, all three
direct mask forms, and a mask hidden inside a `data-icon` asset must produce
the named shared-validator errors in both Checker and direct export. The legal
cases must retain stop alpha, round-trip an in-circle focus, center an imported
outside-circle focus with a diagnostic, promote the full-canvas gradient to
one native `p:bg`, default an unpositioned linear gradient to horizontal, and
recover approximately 30 degrees from the importer's out-of-unit-box endpoint
form.