Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user