| 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 |