返回 ppt-master
mask-gradient-smoke.md
根目录 / skills / ppt-master / scripts / docs / mask-gradient-smoke.md
1 # Mask and Gradient Maintenance Smoke
2
3 Run this manual smoke from the repository root after changing gradient
4 validation, gradient import/export, native background promotion, icon
5 expansion, or mask rejection. It keeps XML in memory except for temporary SVG
6 fixtures; do not turn it into a test framework or example deck.
7
8 ```bash
9 python3 - <<'PY'
10 import math
11 import re
12 import sys
13 import tempfile
14 from pathlib import Path
15 from xml.etree import ElementTree as ET
16
17 scripts = Path("skills/ppt-master/scripts").resolve()
18 sys.path.insert(0, str(scripts))
19
20 from pptx_to_svg.color_resolver import ColorPalette
21 from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints, resolve_fill
22 from svg_quality_checker import SVGQualityChecker
23 from svg_to_pptx.drawingml.converter import (
24 SvgNativeConversionError,
25 convert_svg_to_slide_shapes,
26 )
27 from svg_to_pptx.drawingml.styles import build_gradient_fill
28 from svg_to_pptx.drawingml.utils import (
29 parse_project_linear_gradient_coordinate,
30 project_gradient_errors,
31 project_mask_errors,
32 )
33
34 SVG_NS = "http://www.w3.org/2000/svg"
35
36
37 def svg(fragment):
38 return ET.fromstring(
39 f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" '
40 f'data-pptx-page-role="content">{fragment}</svg>'
41 )
42
43
44 valid = svg(
45 """<defs>
46 <linearGradient id="linear">
47 <stop offset="0" stop-color="#2563EB"/>
48 <stop offset="100%" stop-color="#F97316" stop-opacity="0.4"/>
49 </linearGradient>
50 <radialGradient id="radial" cx="0.25" cy="0.7" r="0.8">
51 <stop offset="0" stop-color="#FFFFFF"/>
52 <stop offset="1" stop-color="#0F172A"/>
53 </radialGradient>
54 </defs>
55 <rect id="bg" x="0" y="0" width="1280" height="720"
56 fill="url(#linear)"/>"""
57 )
58 assert not project_gradient_errors(valid)
59 linear, radial = list(valid.find(f"{{{SVG_NS}}}defs"))
60 linear_xml = build_gradient_fill(linear)
61 radial_xml = build_gradient_fill(radial)
62 assert '<a:lin ang="0" scaled="1"/>' in linear_xml
63 assert '<a:alpha val="40000"/>' in linear_xml
64 assert '<a:path path="circle">' in radial_xml
65 assert (
66 '<a:fillToRect l="25000" t="70000" r="75000" b="30000"/>'
67 in radial_xml
68 )
69 radial.set("fx", "0.8")
70 radial.set("fy", "0.2")
71 focused_xml = build_gradient_fill(radial)
72 assert (
73 '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>'
74 in focused_xml
75 )
76 native_gradient = ET.fromstring(
77 f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
78 f'{focused_xml}</root>'
79 )[0]
80 restored = resolve_fill(native_gradient, None)
81 assert 'cx="0.5" cy="0.5" r="0.5" fx="0.8" fy="0.2"' in restored.defs[0]
82 assert '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>' in (
83 build_gradient_fill(ET.fromstring(restored.defs[0]))
84 )
85 radial.set("fx", "0")
86 radial.set("fy", "0")
87 outside_focus_errors = project_gradient_errors(valid)
88 assert any(
89 "must lie within the canonical circle" in error
90 for error in outside_focus_errors
91 ), outside_focus_errors
92 try:
93 build_gradient_fill(radial)
94 except ValueError as exc:
95 assert "must lie within the canonical circle" in str(exc)
96 else:
97 raise AssertionError("outside radial focus reached DrawingML")
98 radial.set("fx", "0.8")
99 radial.set("fy", "0.2")
100
101 diagnostics = []
102 palette = ColorPalette(
103 None,
104 None,
105 strict=False,
106 diagnostic_sink=lambda code, message, fallback: diagnostics.append(
107 (code, message, fallback)
108 ),
109 )
110 outside_native_xml = focused_xml.replace(
111 'l="80000" t="20000" r="20000" b="80000"',
112 'l="0" t="0" r="100000" b="100000"',
113 )
114 outside_native_gradient = ET.fromstring(
115 f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
116 f"{outside_native_xml}</root>"
117 )[0]
118 normalized = resolve_fill(outside_native_gradient, palette)
119 assert " fx=" not in normalized.defs[0]
120 assert " fy=" not in normalized.defs[0]
121 assert any(
122 code == "path-gradient-focus-normalized"
123 for code, _message, _fallback in diagnostics
124 )
125
126 with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp:
127 source = Path(tmp) / "gradient.svg"
128 source.write_text(
129 ET.tostring(valid, encoding="unicode"),
130 encoding="utf-8",
131 )
132 trace = []
133 slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace)
134 assert slide_xml.count("<p:bg>") == 1
135 assert "<a:gradFill>" in slide_xml
136 assert trace[0]["summary"]["promoted_backgrounds"] == 1
137 assert any(
138 event.get("decision") == "native-background"
139 for event in trace[0]["events"]
140 )
141
142 invalid_gradients = [
143 (
144 """<linearGradient id="single">
145 <stop offset="0" stop-color="#2563EB"/>
146 </linearGradient>""",
147 "requires at least two direct <stop> children",
148 ),
149 (
150 """<linearGradient id="descending">
151 <stop offset="1" stop-color="#2563EB"/>
152 <stop offset="0" stop-color="#F97316"/>
153 </linearGradient>""",
154 "offsets must be non-decreasing",
155 ),
156 (
157 """<linearGradient id="zero" x1="0.5" y1="0.5" x2="0.5" y2="0.5">
158 <stop offset="0" stop-color="#2563EB"/>
159 <stop offset="1" stop-color="#F97316"/>
160 </linearGradient>""",
161 "linear gradient axis must not collapse to one point",
162 ),
163 ]
164 for definition, expected in invalid_gradients:
165 errors = project_gradient_errors(svg(f"<defs>{definition}</defs>"))
166 assert any(expected in error for error in errors), errors
167
168 mask_cases = [
169 """<defs><mask id="fade"><rect width="1" height="1"/></mask></defs>""",
170 """<rect width="100" height="100" mask="url(#fade)"/>""",
171 """<rect width="100" height="100" style="mask: url(#fade)"/>""",
172 ]
173 for fragment in mask_cases:
174 errors = project_mask_errors(svg(fragment))
175 assert any("unsupported SVG mask" in error for error in errors), errors
176
177 checker = SVGQualityChecker()
178 with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp:
179 for index, fragment in enumerate(mask_cases, start=1):
180 source = Path(tmp) / f"mask-{index}.svg"
181 source.write_text(
182 ET.tostring(svg(fragment), encoding="unicode"),
183 encoding="utf-8",
184 )
185 checked = checker.check_file(str(source))
186 assert any("mask" in error.lower() for error in checked["errors"])
187 try:
188 convert_svg_to_slide_shapes(source)
189 except SvgNativeConversionError as exc:
190 assert "invalid project mask" in str(exc)
191 else:
192 raise AssertionError(f"native export accepted {source.name}")
193
194 with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp:
195 project = Path(tmp)
196 icon_dir = project / "icons" / "imported"
197 icon_dir.mkdir(parents=True)
198 (icon_dir / "masked.svg").write_text(
199 f"""<svg xmlns="{SVG_NS}" viewBox="0 0 24 24">
200 <defs>
201 <mask id="fade">
202 <rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>
203 </mask>
204 </defs>
205 <rect x="0" y="0" width="24" height="24"
206 fill="#000000" mask="url(#fade)"/>
207 </svg>""",
208 encoding="utf-8",
209 )
210 source = project / "icon-mask.svg"
211 source.write_text(
212 ET.tostring(
213 svg(
214 """<use data-icon="imported/masked"
215 x="20" y="20" width="24" height="24"/>"""
216 ),
217 encoding="unicode",
218 ),
219 encoding="utf-8",
220 )
221 checked = checker.check_file(str(source))
222 assert any(
223 "Icon imported/masked" in error and "mask" in error.lower()
224 for error in checked["errors"]
225 )
226 try:
227 convert_svg_to_slide_shapes(source)
228 except SvgNativeConversionError as exc:
229 assert "invalid project mask" in str(exc)
230 else:
231 raise AssertionError("native export accepted a masked icon")
232
233 x1, y1, x2, y2 = _angle_to_unit_endpoints(30)
234 assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2))
235 for value in (x1, y1, x2, y2):
236 assert math.isclose(
237 parse_project_linear_gradient_coordinate(str(value)),
238 value,
239 abs_tol=1e-9,
240 )
241 roundtrip = svg(
242 f"""<defs>
243 <linearGradient id="roundtrip"
244 x1="{x1:.9f}" y1="{y1:.9f}" x2="{x2:.9f}" y2="{y2:.9f}">
245 <stop offset="0" stop-color="#2563EB"/>
246 <stop offset="1" stop-color="#F97316"/>
247 </linearGradient>
248 </defs>"""
249 )
250 assert not project_gradient_errors(roundtrip)
251 roundtrip_gradient = roundtrip.find(
252 f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient"
253 )
254 angle = int(
255 re.search(
256 r'<a:lin ang="(\d+)"',
257 build_gradient_fill(roundtrip_gradient),
258 ).group(1)
259 ) / 60000
260 assert math.isclose(angle, 30, abs_tol=0.01), angle
261
262 print("Mask and gradient smoke: passed")
263 PY
264 ```
265
266 The three invalid-gradient cases, the outside-circle radial focus, all three
267 direct mask forms, and a mask hidden inside a `data-icon` asset must produce
268 the named shared-validator errors in both Checker and direct export. The legal
269 cases must retain stop alpha, round-trip an in-circle focus, center an imported
270 outside-circle focus with a diagnostic, promote the full-canvas gradient to
271 one native `p:bg`, default an unpositioned linear gradient to horizontal, and
272 recover approximately 30 degrees from the importer's out-of-unit-box endpoint
273 form.
274
274 lines MARKDOWN