Skip to main content

rust_office_art/
lib.rs

1use std::fmt::Write;
2
3use base64::Engine;
4
5use crate::xml::{
6    direct_child_tag, extract_balanced_tag_slices, extract_direct_child_tag_ranges,
7    get_attribute_ref,
8};
9
10use crate::xml::escape_xml_text;
11
12mod effects;
13mod geometry;
14pub mod model3d;
15mod raster;
16mod shapes;
17pub mod smartart;
18mod xml;
19
20pub use shapes::{render_shape, StyleReferenceKind};
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(default, rename_all = "camelCase")]
24pub struct ParseOptions {
25    #[serde(rename = "include3dModels")]
26    pub include_3d_models: bool,
27}
28
29#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct PaintBounds {
32    pub x_px: f64,
33    pub y_px: f64,
34    pub width_px: f64,
35    pub height_px: f64,
36    pub layout_width_px: f64,
37    pub layout_height_px: f64,
38}
39
40#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct RenderedPicture {
43    pub svg: String,
44    pub paint_bounds: Option<PaintBounds>,
45}
46
47#[derive(Clone, Debug)]
48pub struct ResolvedColor {
49    pub color: String,
50    pub opacity: Option<f64>,
51}
52
53pub trait PictureContext {
54    fn resolve_color(&self, xml: &str) -> Option<ResolvedColor>;
55
56    fn resolve_image(&self, _relationship_id: &str) -> Option<String> {
57        None
58    }
59
60    fn resolve_style_reference(
61        &self,
62        _kind: StyleReferenceKind,
63        _index: u32,
64        _placeholder_xml: &str,
65    ) -> Option<String> {
66        None
67    }
68}
69
70pub fn normalize_raster(source: &str) -> Option<String> {
71    raster::normalize(source)
72}
73
74/// Apply DrawingML effects to trusted, generated SVG in a local coordinate frame.
75/// The caller owns SVG validation, resource resolution and unique identifier scoping.
76pub fn render_effects(
77    properties_xml: &str,
78    svg_body: &str,
79    width: f64,
80    height: f64,
81    context: &impl PictureContext,
82) -> Option<RenderedPicture> {
83    if !width.is_finite()
84        || !height.is_finite()
85        || width <= 0.0
86        || height <= 0.0
87        || width > 1_000_000.0
88        || height > 1_000_000.0
89        || properties_xml.len() > 4 * 1024 * 1024
90        || svg_body.len() > 32 * 1024 * 1024
91    {
92        return None;
93    }
94    let mut defs = String::new();
95    let (body, bounds) = apply_shape_effects(
96        properties_xml,
97        svg_body,
98        "",
99        width,
100        height,
101        0.0,
102        Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
103        context,
104        &mut defs,
105        true,
106    )?;
107    let paint_width = bounds.right - bounds.left;
108    let paint_height = bounds.bottom - bounds.top;
109    let svg = format!(
110        r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{paint_width}" height="{paint_height}" viewBox="{} {} {paint_width} {paint_height}"><defs>{defs}</defs>{body}</svg>"##,
111        bounds.left, bounds.top,
112    );
113    Some(RenderedPicture {
114        svg,
115        paint_bounds: Some(PaintBounds {
116            x_px: bounds.left,
117            y_px: bounds.top,
118            width_px: paint_width,
119            height_px: paint_height,
120            layout_width_px: width,
121            layout_height_px: height,
122        }),
123    })
124}
125
126fn resolve_drawing_color_from_xml(
127    xml: Option<&str>,
128    context: &impl PictureContext,
129) -> Option<ResolvedColor> {
130    context.resolve_color(xml?)
131}
132
133fn number(xml: &str, name: &str, default: f64) -> Option<f64> {
134    match get_attribute_ref(xml, name) {
135        Some(value) => value
136            .trim()
137            .parse::<f64>()
138            .ok()
139            .filter(|value| value.is_finite()),
140        None => Some(default),
141    }
142}
143
144fn percentage(xml: &str, name: &str, default: f64) -> Option<f64> {
145    let Some(value) = get_attribute_ref(xml, name) else {
146        return Some(default);
147    };
148    let value = value.trim();
149    let (value, divisor) = match value.strip_suffix('%') {
150        Some(value) => (value, 100.0),
151        None => (value, 100_000.0),
152    };
153    value
154        .parse::<f64>()
155        .ok()
156        .filter(|value| value.is_finite())
157        .map(|value| value / divisor)
158}
159
160fn enabled(xml: &str, name: &str) -> bool {
161    get_attribute_ref(xml, name).is_some_and(|value| matches!(value, "1" | "true" | "on"))
162}
163
164fn rectangle(xml: Option<&str>) -> Option<[f64; 4]> {
165    let xml = xml.unwrap_or("");
166    Some([
167        percentage(xml, "l", 0.0)?,
168        percentage(xml, "t", 0.0)?,
169        percentage(xml, "r", 0.0)?,
170        percentage(xml, "b", 0.0)?,
171    ])
172}
173
174fn grayscale(out: &mut String) {
175    out.push_str(r##"<feColorMatrix type="matrix" values="0.2125 0.7154 0.0721 0 0 0.2125 0.7154 0.0721 0 0 0.2125 0.7154 0.0721 0 0 0 0 0 1 0"/>"##);
176}
177
178fn threshold(out: &mut String, channels: &[&str], threshold: f64) {
179    let _ = write!(out, "<feComponentTransfer>");
180    for channel in channels {
181        let _ = write!(
182            out,
183            r##"<feFunc{channel} type="linear" slope="1" intercept="{}"/>"##,
184            0.5 - threshold
185        );
186    }
187    out.push_str("</feComponentTransfer><feComponentTransfer>");
188    for channel in channels {
189        let _ = write!(
190            out,
191            r##"<feFunc{channel} type="discrete" tableValues="0 1"/>"##
192        );
193    }
194    out.push_str("</feComponentTransfer>");
195}
196
197fn rgb(xml: &str, colors: &impl PictureContext) -> Option<[f64; 3]> {
198    let resolved = resolve_drawing_color_from_xml(Some(xml), colors)?;
199    let mut channels = [0.0; 3];
200    for (index, channel) in channels.iter_mut().enumerate() {
201        *channel = u8::from_str_radix(resolved.color.get(1 + index * 2..3 + index * 2)?, 16).ok()?
202            as f64
203            / 255.0;
204    }
205    Some(channels)
206}
207
208fn replace_color(out: &mut String, color: [f64; 3]) {
209    out.push_str("<feComponentTransfer>");
210    for (channel, value) in ["R", "G", "B"].into_iter().zip(color) {
211        let _ = write!(
212            out,
213            r##"<feFunc{channel} type="linear" slope="0" intercept="{value}"/>"##
214        );
215    }
216    out.push_str("</feComponentTransfer>");
217}
218
219fn linear_rgb(out: &mut String, slope: f64, intercept: f64) {
220    out.push_str("<feComponentTransfer>");
221    for channel in ["R", "G", "B"] {
222        let _ = write!(
223            out,
224            r##"<feFunc{channel} type="linear" slope="{slope}" intercept="{intercept}"/>"##
225        );
226    }
227    out.push_str("</feComponentTransfer>");
228}
229
230fn blur_radius(xml: &str, name: &str) -> Option<f64> {
231    let radius = number(xml, name, 0.0)? / 9525.0;
232    (0.0..=10_000.0).contains(&radius).then_some(radius)
233}
234
235fn blip_effects(blip: &str) -> Vec<xml::TaggedRange> {
236    let mut effects = extract_direct_child_tag_ranges(blip);
237    effects.sort_by_key(|effect| {
238        !matches!(
239            effect.tag_name.as_str(),
240            "a:clrChange" | "a:alphaBiLevel" | "a:alphaCeiling" | "a:alphaFloor" | "a:biLevel"
241        )
242    });
243    effects
244}
245
246fn blip_filter(blip: &str, colors: &impl PictureContext) -> Option<String> {
247    let mut out = String::new();
248    for child in blip_effects(blip) {
249        let xml = &blip[child.start..child.end];
250        match child.tag_name.as_str() {
251            "a:extLst" => {}
252            "a:grayscl" => grayscale(&mut out),
253            "a:biLevel" => {
254                grayscale(&mut out);
255                threshold(
256                    &mut out,
257                    &["R", "G", "B"],
258                    percentage(xml, "thresh", 0.5)?.clamp(0.0, 1.0),
259                );
260            }
261            "a:alphaBiLevel" => threshold(
262                &mut out,
263                &["A"],
264                percentage(xml, "thresh", 0.5)?.clamp(0.0, 1.0),
265            ),
266            "a:alphaFloor" => threshold(&mut out, &["A"], 1.0),
267            "a:alphaModFix" => {
268                let amount = percentage(xml, "amt", 1.0)?.max(0.0);
269                let _ = write!(
270                    out,
271                    r##"<feComponentTransfer><feFuncA type="linear" slope="{amount}" intercept="0"/></feComponentTransfer>"##
272                );
273            }
274            "a:alphaRepl" => {
275                let amount = percentage(xml, "a", 1.0)?.clamp(0.0, 1.0);
276                let _ = write!(
277                    out,
278                    r##"<feComponentTransfer><feFuncA type="linear" slope="0" intercept="{amount}"/></feComponentTransfer>"##
279                );
280            }
281            "a:alphaInv" => {
282                out.push_str(r##"<feComponentTransfer><feFuncA type="linear" slope="-1" intercept="1"/></feComponentTransfer>"##);
283                let color = if extract_direct_child_tag_ranges(xml).is_empty() {
284                    [0.0; 3]
285                } else {
286                    rgb(xml, colors)?
287                };
288                replace_color(&mut out, color);
289            }
290            "a:clrRepl" => replace_color(&mut out, rgb(xml, colors)?),
291            "a:lum" => {
292                let brightness = percentage(xml, "bright", 0.0)?.clamp(-1.0, 1.0);
293                let contrast = percentage(xml, "contrast", 0.0)?.clamp(-1.0, 1.0);
294                let brightness = (brightness * 0.5) as f32;
295                let slope = (if contrast > 0.0 {
296                    1.0 / (1.0 - contrast.min(0.99999))
297                } else {
298                    1.0 + contrast
299                }) as f32;
300                let intercept = slope * (brightness - 0.5) + brightness + 0.5;
301                linear_rgb(&mut out, f64::from(slope), f64::from(intercept));
302            }
303            "a:blur" => {
304                let deviation = blur_radius(xml, "rad")? / 2.0;
305                let _ = write!(out, r##"<feGaussianBlur stdDeviation="{deviation}"/>"##);
306            }
307            "a:duotone" => {
308                let children = extract_direct_child_tag_ranges(xml);
309                let [dark, light] = children.as_slice() else {
310                    return None;
311                };
312                let dark = rgb(&xml[dark.start..dark.end], colors)?;
313                let light = rgb(&xml[light.start..light.end], colors)?;
314                grayscale(&mut out);
315                out.push_str("<feComponentTransfer>");
316                for (index, channel) in ["R", "G", "B"].into_iter().enumerate() {
317                    let _ = write!(
318                        out,
319                        r##"<feFunc{channel} type="table" tableValues="{} {}"/>"##,
320                        dark[index], light[index]
321                    );
322                }
323                out.push_str("</feComponentTransfer>");
324            }
325            _ => return None,
326        }
327    }
328    Some(out)
329}
330
331fn adjusted_value(geometry: &str, name: &str, default: f64) -> Option<f64> {
332    let Some(adjustments) = direct_child_tag(geometry, "a:avLst") else {
333        return Some(default);
334    };
335    for adjustment in extract_direct_child_tag_ranges(adjustments) {
336        let xml = &adjustments[adjustment.start..adjustment.end];
337        if get_attribute_ref(xml, "name") == Some(name) {
338            let formula = get_attribute_ref(xml, "fmla")?
339                .trim()
340                .strip_prefix("val ")?;
341            return formula
342                .trim()
343                .parse::<f64>()
344                .ok()
345                .filter(|value| value.is_finite());
346        }
347    }
348    Some(default)
349}
350
351fn geometry_path(properties: &str, width: f64, height: f64) -> Option<String> {
352    if direct_child_tag(properties, "a:custGeom").is_none() {
353        if let Some(path) = legacy_geometry_path(properties, width, height) {
354            return Some(path);
355        }
356    }
357    let paths = geometry::geometry_paths(properties, width, height)?;
358    let path = paths
359        .into_iter()
360        .filter(|path| path.fill != geometry::PathFill::None)
361        .map(|path| path.d)
362        .collect::<Vec<_>>()
363        .join(" ");
364    (!path.is_empty()).then_some(path)
365}
366
367fn legacy_geometry_path(properties: &str, width: f64, height: f64) -> Option<String> {
368    if let Some(geometry) = direct_child_tag(properties, "a:custGeom") {
369        let paths = direct_child_tag(geometry, "a:pathLst")?;
370        let paths = extract_direct_child_tag_ranges(paths);
371        if paths.len() != 1 {
372            return None;
373        }
374        let path = extract_balanced_tag_slices(geometry, "a:path")
375            .into_iter()
376            .next()?;
377        let path_width = number(path, "w", 21600.0)?;
378        let path_height = number(path, "h", 21600.0)?;
379        if path_width <= 0.0 || path_height <= 0.0 {
380            return None;
381        }
382        let mut output = String::new();
383        for command in extract_direct_child_tag_ranges(path) {
384            let (expected, operation) = match command.tag_name.as_str() {
385                "a:moveTo" => (1, 'M'),
386                "a:lnTo" => (1, 'L'),
387                "a:cubicBezTo" => (3, 'C'),
388                "a:quadBezTo" => (2, 'Q'),
389                "a:close" => (0, 'Z'),
390                _ => return None,
391            };
392            let command = &path[command.start..command.end];
393            let points = extract_direct_child_tag_ranges(command);
394            if points.len() != expected {
395                return None;
396            }
397            output.push(operation);
398            for point in points {
399                if point.tag_name != "a:pt" {
400                    return None;
401                }
402                let point = &command[point.start..point.end];
403                let x = get_attribute_ref(point, "x")?
404                    .parse::<f64>()
405                    .ok()?
406                    .mul_add(width / path_width, 0.0);
407                let y = get_attribute_ref(point, "y")?
408                    .parse::<f64>()
409                    .ok()?
410                    .mul_add(height / path_height, 0.0);
411                if !x.is_finite() || !y.is_finite() {
412                    return None;
413                }
414                let _ = write!(output, "{x} {y} ");
415            }
416        }
417        return (!output.is_empty()).then_some(output);
418    }
419    let geometry = direct_child_tag(properties, "a:prstGeom").unwrap_or("");
420    let preset = get_attribute_ref(geometry, "prst").unwrap_or("rect");
421    let x_mid = width / 2.0;
422    let y_mid = height / 2.0;
423    Some(match preset {
424        "rect" => format!("M0 0 H{width} V{height} H0 Z"),
425        "ellipse" => format!(
426            "M0 {y_mid} A{x_mid} {y_mid} 0 1 0 {width} {y_mid} A{x_mid} {y_mid} 0 1 0 0 {y_mid} Z"
427        ),
428        "roundRect" => {
429            let radius = width.min(height)
430                * (adjusted_value(geometry, "adj", 16667.0)? / 100000.0).clamp(0.0, 0.5);
431            let right = width - radius;
432            let bottom = height - radius;
433            format!("M{radius} 0 H{right} A{radius} {radius} 0 0 1 {width} {radius} V{bottom} A{radius} {radius} 0 0 1 {right} {height} H{radius} A{radius} {radius} 0 0 1 0 {bottom} V{radius} A{radius} {radius} 0 0 1 {radius} 0 Z")
434        }
435        "triangle" => {
436            let apex =
437                width * (adjusted_value(geometry, "adj", 50000.0)? / 100000.0).clamp(0.0, 1.0);
438            format!("M{apex} 0 L{width} {height} L0 {height} Z")
439        }
440        "diamond" => format!("M{x_mid} 0 L{width} {y_mid} L{x_mid} {height} L0 {y_mid} Z"),
441        "rtTriangle" => format!("M0 {height} L{width} {height} L0 0 Z"),
442        "flowChartDelay" => format!("M0 0 H{x_mid} A{x_mid} {y_mid} 0 0 1 {x_mid} {height} H0 Z"),
443        _ => return None,
444    })
445}
446
447fn preset_dash_segments(preset: &str) -> Option<&'static [f64]> {
448    Some(match preset {
449        "solid" => &[],
450        "dot" => &[1.0, 3.0],
451        "dash" => &[4.0, 3.0],
452        "dashDot" => &[4.0, 3.0, 1.0, 3.0],
453        "sysDot" => &[1.0, 1.0],
454        "sysDash" => &[3.0, 1.0],
455        "sysDashDot" => &[3.0, 1.0, 1.0, 1.0],
456        "sysDashDotDot" => &[3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
457        "lgDash" => &[8.0, 3.0],
458        "lgDashDot" => &[8.0, 3.0, 1.0, 3.0],
459        "lgDashDotDot" => &[8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
460        _ => return None,
461    })
462}
463
464fn rectangular_picture(properties: &str) -> bool {
465    direct_child_tag(properties, "a:custGeom").is_none()
466        && direct_child_tag(properties, "a:prstGeom")
467            .and_then(|geometry| get_attribute_ref(geometry, "prst"))
468            .is_none_or(|preset| preset == "rect")
469}
470
471fn border(properties: &str, colors: &impl PictureContext) -> Option<(String, f64)> {
472    let Some(line) = direct_child_tag(properties, "a:ln") else {
473        return Some((String::new(), 0.0));
474    };
475    if direct_child_tag(line, "a:noFill").is_some() {
476        return Some((String::new(), 0.0));
477    }
478    if direct_child_tag(line, "a:gradFill").is_some()
479        || direct_child_tag(line, "a:pattFill").is_some()
480        || get_attribute_ref(line, "cmpd").is_some_and(|value| value != "sng")
481        || get_attribute_ref(line, "algn").is_some_and(|value| value != "ctr")
482    {
483        return None;
484    }
485    let width = number(line, "w", 12700.0)?.max(0.0) / 9525.0;
486    if width == 0.0 {
487        return Some((String::new(), 0.0));
488    }
489    let color = resolve_drawing_color_from_xml(Some(line), colors)?;
490    let cap = match get_attribute_ref(line, "cap").unwrap_or("flat") {
491        "rnd" => "round",
492        "sq" => "square",
493        _ => "butt",
494    };
495    let join = if direct_child_tag(line, "a:miter").is_some() {
496        "miter"
497    } else if direct_child_tag(line, "a:bevel").is_some() {
498        "bevel"
499    } else {
500        "round"
501    };
502    let miter = match direct_child_tag(line, "a:miter") {
503        Some(xml) => percentage(xml, "lim", 8.0)?,
504        None => 8.0,
505    }
506    .max(1.0);
507    let mut attributes = format!(
508        r##"fill="none" stroke="{}" stroke-opacity="{}" stroke-width="{}" stroke-linecap="{cap}" stroke-linejoin="{join}" stroke-miterlimit="{miter}""##,
509        escape_xml_text(&color.color),
510        color.opacity.unwrap_or(1.0),
511        width
512            * if rectangular_picture(properties) {
513                1.0
514            } else {
515                2.0
516            },
517    );
518    if let Some(dash) = direct_child_tag(line, "a:prstDash") {
519        let segments = preset_dash_segments(get_attribute_ref(dash, "val").unwrap_or("solid"))?;
520        if !segments.is_empty() {
521            let values = segments
522                .iter()
523                .map(|value| (value * width).to_string())
524                .collect::<Vec<_>>()
525                .join(" ");
526            let _ = write!(attributes, r##" stroke-dasharray="{values}""##);
527        }
528    }
529    if let Some(dash) = direct_child_tag(line, "a:custDash") {
530        let mut values = Vec::new();
531        for stop in extract_direct_child_tag_ranges(dash) {
532            let xml = &dash[stop.start..stop.end];
533            values.extend([
534                percentage(xml, "d", 1.0)?.max(0.0) * width,
535                percentage(xml, "sp", 1.0)?.max(0.0) * width,
536            ]);
537        }
538        if !values.is_empty() && values.iter().any(|value| *value > 0.0) {
539            let values = values
540                .iter()
541                .map(f64::to_string)
542                .collect::<Vec<_>>()
543                .join(" ");
544            let _ = write!(attributes, r##" stroke-dasharray="{values}""##);
545        }
546    }
547    let geometry = direct_child_tag(properties, "a:prstGeom")
548        .and_then(|geometry| get_attribute_ref(geometry, "prst"))
549        .unwrap_or("rect");
550    let bounded_corner = direct_child_tag(properties, "a:custGeom").is_none()
551        && matches!(geometry, "rect" | "ellipse" | "roundRect");
552    Some((
553        attributes,
554        width
555            * if join == "miter" && !bounded_corner {
556                miter
557            } else {
558                1.0
559            },
560    ))
561}
562
563#[derive(Clone, Copy)]
564struct Bounds {
565    left: f64,
566    top: f64,
567    right: f64,
568    bottom: f64,
569}
570
571impl Bounds {
572    fn rect(width: f64, height: f64) -> Self {
573        Self {
574            left: 0.0,
575            top: 0.0,
576            right: width,
577            bottom: height,
578        }
579    }
580
581    fn expanded(self, radius: f64) -> Self {
582        Self {
583            left: self.left - radius,
584            top: self.top - radius,
585            right: self.right + radius,
586            bottom: self.bottom + radius,
587        }
588    }
589
590    fn union(self, other: Self) -> Self {
591        Self {
592            left: self.left.min(other.left),
593            top: self.top.min(other.top),
594            right: self.right.max(other.right),
595            bottom: self.bottom.max(other.bottom),
596        }
597    }
598
599    fn transformed(self, matrix: Matrix) -> Self {
600        let points = [
601            (self.left, self.top),
602            (self.right, self.top),
603            (self.left, self.bottom),
604            (self.right, self.bottom),
605        ]
606        .map(|(x, y)| matrix.point(x, y));
607        Self {
608            left: points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min),
609            top: points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min),
610            right: points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max),
611            bottom: points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max),
612        }
613    }
614
615    fn attributes(self) -> String {
616        format!(
617            r##"x="{}" y="{}" width="{}" height="{}""##,
618            self.left,
619            self.top,
620            self.right - self.left,
621            self.bottom - self.top
622        )
623    }
624
625    fn valid(self) -> bool {
626        [self.left, self.top, self.right, self.bottom]
627            .into_iter()
628            .all(|value| value.is_finite() && value.abs() <= 100_000_000.0)
629            && self.right > self.left
630            && self.bottom > self.top
631    }
632}
633
634#[derive(Clone, Copy)]
635struct Matrix([f64; 6]);
636
637impl Matrix {
638    fn point(self, x: f64, y: f64) -> (f64, f64) {
639        let [a, b, c, d, e, f] = self.0;
640        (a * x + c * y + e, b * x + d * y + f)
641    }
642
643    fn composed(self, other: Self) -> Self {
644        let [a, b, c, d, e, f] = self.0;
645        let [g, h, i, j, k, l] = other.0;
646        Self([
647            a * g + c * h,
648            b * g + d * h,
649            a * i + c * j,
650            b * i + d * j,
651            a * k + c * l + e,
652            b * k + d * l + f,
653        ])
654    }
655
656    fn markup(self) -> String {
657        let [a, b, c, d, e, f] = self.0;
658        format!("matrix({a} {b} {c} {d} {e} {f})")
659    }
660}
661
662fn alignment(value: &str) -> (f64, f64) {
663    (
664        match value {
665            "tl" | "l" | "bl" => 0.0,
666            "tr" | "r" | "br" => 1.0,
667            _ => 0.5,
668        },
669        match value {
670            "tl" | "t" | "tr" => 0.0,
671            "bl" | "b" | "br" => 1.0,
672            _ => 0.5,
673        },
674    )
675}
676
677fn effect_matrix(xml: &str, bounds: Bounds) -> Option<Matrix> {
678    let sx = percentage(xml, "sx", 1.0)?;
679    let sy = percentage(xml, "sy", 1.0)?;
680    let kx = number(xml, "kx", 0.0)? / 60_000.0;
681    let ky = number(xml, "ky", 0.0)? / 60_000.0;
682    if sx.abs() > 100.0 || sy.abs() > 100.0 || kx.abs() >= 90.0 || ky.abs() >= 90.0 {
683        return None;
684    }
685    let direction = (number(xml, "dir", 0.0)? / 60_000.0).to_radians();
686    let distance = number(xml, "dist", 0.0)?.max(0.0) / 9525.0;
687    let (ax, ay) = alignment(get_attribute_ref(xml, "algn").unwrap_or("b"));
688    let ax = bounds.left + (bounds.right - bounds.left) * ax;
689    let ay = bounds.top + (bounds.bottom - bounds.top) * ay;
690    let a = sx;
691    let b = sx * ky.to_radians().tan();
692    let c = sy * kx.to_radians().tan();
693    let d = sy;
694    Some(Matrix([
695        a,
696        b,
697        c,
698        d,
699        ax - a * ax - c * ay + distance * direction.cos(),
700        ay - b * ax - d * ay + distance * direction.sin(),
701    ]))
702}
703
704fn color_filter(
705    defs: &mut String,
706    id: &str,
707    bounds: Bounds,
708    color_xml: &str,
709    colors: &impl PictureContext,
710    deviation: f64,
711    dilation: f64,
712) -> Option<()> {
713    let color = resolve_drawing_color_from_xml(Some(color_xml), colors)?;
714    let morphology = if dilation > 0.0 {
715        format!(
716            r##"<feMorphology in="SourceAlpha" operator="dilate" radius="{dilation}" result="silhouette"/>"##
717        )
718    } else {
719        String::new()
720    };
721    let input = if dilation > 0.0 {
722        "silhouette"
723    } else {
724        "SourceAlpha"
725    };
726    let _ = write!(
727        defs,
728        r##"<filter id="{id}" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB">{morphology}<feGaussianBlur in="{input}" stdDeviation="{deviation}" result="blur"/><feFlood flood-color="{}" flood-opacity="{}"/><feComposite in2="blur" operator="in"/></filter>"##,
729        bounds.attributes(),
730        escape_xml_text(&color.color),
731        color.opacity.unwrap_or(1.0)
732    );
733    Some(())
734}
735
736fn apply_shape_effects(
737    properties: &str,
738    body: &str,
739    path: &str,
740    width: f64,
741    height: f64,
742    margin: f64,
743    matrix: Matrix,
744    colors: &impl PictureContext,
745    defs: &mut String,
746    glow_from_source: bool,
747) -> Option<(String, Bounds)> {
748    let local = Bounds::rect(width, height).expanded(margin);
749    let mut source_bounds = local;
750    let mut source = body.to_string();
751    if let Some(graph) = direct_child_tag(properties, "a:effectDag") {
752        let filter = effects::render(graph, colors)?;
753        let _ = write!(
754            defs,
755            r##"<filter id="picture-effect-tree" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB">{filter}</filter>"##,
756            source_bounds.attributes()
757        );
758        source = format!(r##"<g filter="url(#picture-effect-tree)">{source}</g>"##);
759    }
760    let list = direct_child_tag(properties, "a:effectLst").unwrap_or("");
761    let children = extract_direct_child_tag_ranges(list);
762    for effect in &children {
763        let xml = &list[effect.start..effect.end];
764        match effect.tag_name.as_str() {
765            "a:blur" => {
766                let deviation = blur_radius(xml, "rad")? / 2.0;
767                let grow = !get_attribute_ref(xml, "grow")
768                    .is_some_and(|value| matches!(value, "0" | "false" | "off"));
769                if grow {
770                    source_bounds = source_bounds.expanded(3.0 * deviation);
771                }
772                let _ = write!(
773                    defs,
774                    r##"<filter id="picture-shape-blur" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="{deviation}"/></filter>"##,
775                    source_bounds.attributes()
776                );
777                source = format!(r##"<g filter="url(#picture-shape-blur)">{source}</g>"##);
778            }
779            "a:softEdge" => {
780                let radius = blur_radius(xml, "rad")?;
781                let erosion = radius;
782                let deviation = radius / 3.0;
783                let _ = write!(
784                    defs,
785                    r##"<filter id="picture-soft-edge" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB"><feComponentTransfer in="SourceAlpha" result="binary"><feFuncA type="linear" slope="255"/></feComponentTransfer><feMorphology in="binary" operator="erode" radius="{erosion}" result="inset"/><feGaussianBlur in="inset" stdDeviation="{deviation}" result="soft"/><feComposite in="SourceGraphic" in2="soft" operator="in"/></filter>"##,
786                    source_bounds.attributes()
787                );
788                source = format!(r##"<g filter="url(#picture-soft-edge)">{source}</g>"##);
789            }
790            "a:innerShdw" | "a:outerShdw" | "a:glow" | "a:reflection" => {}
791            _ => return None,
792        }
793    }
794    let _ = write!(defs, r##"<g id="picture-body">{source}</g>"##);
795    let mut bounds = source_bounds.transformed(matrix);
796    let mut underlay = String::new();
797    let mut overlay = String::new();
798    for (index, effect) in children.iter().enumerate() {
799        let xml = &list[effect.start..effect.end];
800        let id = format!("picture-effect-{index}");
801        match effect.tag_name.as_str() {
802            "a:glow" => {
803                let radius = blur_radius(xml, "rad")?;
804                let deviation = radius / 6.0;
805                let dilation = radius / 2.0;
806                let effect_bounds = bounds.expanded(dilation + 3.0 * deviation);
807                color_filter(defs, &id, effect_bounds, xml, colors, deviation, dilation)?;
808                if glow_from_source {
809                    let _ = write!(
810                        underlay,
811                        r##"<g filter="url(#{id})"><use xlink:href="#picture-body" transform="{}"/></g>"##,
812                        matrix.markup()
813                    );
814                } else {
815                    let _ = write!(
816                        underlay,
817                        r##"<g filter="url(#{id})"><path d="{path}" fill="white" transform="{}"/></g>"##,
818                        matrix.markup()
819                    );
820                }
821                bounds = bounds.union(effect_bounds);
822            }
823            "a:outerShdw" | "a:reflection" => {
824                let deviation = blur_radius(xml, "blurRad")?
825                    / if effect.tag_name == "a:outerShdw" {
826                        3.0
827                    } else {
828                        2.0
829                    };
830                let rotate_with_shape = !get_attribute_ref(xml, "rotWithShape")
831                    .is_some_and(|value| matches!(value, "0" | "false" | "off"));
832                let transformed = if rotate_with_shape {
833                    matrix.composed(effect_matrix(xml, Bounds::rect(width, height))?)
834                } else {
835                    effect_matrix(xml, Bounds::rect(width, height).transformed(matrix))?
836                        .composed(matrix)
837                };
838                let raw_bounds = source_bounds.transformed(transformed);
839                let effect_bounds = raw_bounds.expanded(3.0 * deviation);
840                if effect.tag_name == "a:outerShdw" {
841                    color_filter(defs, &id, effect_bounds, xml, colors, deviation, 0.0)?;
842                    let _ = write!(
843                        underlay,
844                        r##"<g filter="url(#{id})"><use xlink:href="#picture-body" transform="{}"/></g>"##,
845                        transformed.markup()
846                    );
847                } else {
848                    let start_alpha = percentage(xml, "stA", 1.0)?.clamp(0.0, 1.0);
849                    let end_alpha = percentage(xml, "endA", 0.0)?.clamp(0.0, 1.0);
850                    let start = percentage(xml, "stPos", 0.0)?.clamp(0.0, 1.0);
851                    let end = percentage(xml, "endPos", 1.0)?.clamp(start, 1.0);
852                    let fade = (number(xml, "fadeDir", 5_400_000.0)? / 60_000.0).to_radians();
853                    let dx = fade.cos();
854                    let dy = fade.sin();
855                    let length = (raw_bounds.right - raw_bounds.left) * dx.abs()
856                        + (raw_bounds.bottom - raw_bounds.top) * dy.abs();
857                    let center_x = (raw_bounds.left + raw_bounds.right) / 2.0;
858                    let center_y = (raw_bounds.top + raw_bounds.bottom) / 2.0;
859                    let _ = write!(
860                        defs,
861                        r##"<linearGradient id="{id}-fade" gradientUnits="userSpaceOnUse" x1="{}" y1="{}" x2="{}" y2="{}"><stop offset="{start}" stop-color="white" stop-opacity="{start_alpha}"/><stop offset="{end}" stop-color="white" stop-opacity="{end_alpha}"/></linearGradient><mask id="{id}-mask" maskUnits="userSpaceOnUse" {}><rect {} fill="url(#{id}-fade)"/></mask><filter id="{id}" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="{deviation}"/></filter>"##,
862                        center_x - length * dx / 2.0,
863                        center_y - length * dy / 2.0,
864                        center_x + length * dx / 2.0,
865                        center_y + length * dy / 2.0,
866                        effect_bounds.attributes(),
867                        effect_bounds.attributes(),
868                        effect_bounds.attributes()
869                    );
870                    let _ = write!(
871                        underlay,
872                        r##"<g data-office-art-overflow="true" mask="url(#{id}-mask)" filter="url(#{id})"><use xlink:href="#picture-body" transform="{}"/></g>"##,
873                        transformed.markup()
874                    );
875                }
876                bounds = bounds.union(effect_bounds);
877            }
878            "a:innerShdw" => {
879                let deviation = blur_radius(xml, "blurRad")? / 2.0;
880                let direction = (number(xml, "dir", 0.0)? / 60_000.0).to_radians();
881                let distance = number(xml, "dist", 0.0)?.max(0.0) / 9525.0;
882                let dx = -distance * direction.cos();
883                let dy = -distance * direction.sin();
884                let color = resolve_drawing_color_from_xml(Some(xml), colors)?;
885                let _ = write!(
886                    defs,
887                    r##"<filter id="{id}" filterUnits="userSpaceOnUse" {} color-interpolation-filters="sRGB"><feOffset in="SourceAlpha" dx="{dx}" dy="{dy}" result="offset"/><feGaussianBlur in="offset" stdDeviation="{deviation}" result="blur"/><feComposite in="SourceAlpha" in2="blur" operator="out" result="edge"/><feFlood flood-color="{}" flood-opacity="{}"/><feComposite in2="edge" operator="in"/></filter>"##,
888                    local.attributes(),
889                    escape_xml_text(&color.color),
890                    color.opacity.unwrap_or(1.0)
891                );
892                let _ = write!(
893                    overlay,
894                    r##"<g transform="{}"><use xlink:href="#picture-body" filter="url(#{id})"/></g>"##,
895                    matrix.markup()
896                );
897            }
898            _ => {}
899        }
900    }
901    if !bounds.valid() {
902        return None;
903    }
904    Some((
905        format!(
906            r##"<g data-office-art-effect="true" pointer-events="none">{underlay}</g><g transform="{}">{source}</g><g data-office-art-effect="true" pointer-events="none">{overlay}</g>"##,
907            matrix.markup()
908        ),
909        bounds,
910    ))
911}
912
913fn raster_metrics(source: &str) -> Option<(f64, f64, f64, f64)> {
914    let (header, payload) = source.split_once(',')?;
915    if !header.starts_with("data:image/") || !header.ends_with(";base64") {
916        return None;
917    }
918    let length = payload.len().min(262_144) / 4 * 4;
919    let bytes = base64::engine::general_purpose::STANDARD
920        .decode(&payload[..length])
921        .ok()?;
922    let be_u32 = |offset: usize| -> Option<u32> {
923        Some(u32::from_be_bytes(
924            bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
925        ))
926    };
927    let le_u32 = |offset: usize| -> Option<u32> {
928        Some(u32::from_le_bytes(
929            bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
930        ))
931    };
932    let le_u16 = |offset: usize| -> Option<u16> {
933        Some(u16::from_le_bytes(
934            bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?,
935        ))
936    };
937    let mut dpi = (72.0, 72.0);
938    let (width, height) = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
939        let dimensions = (be_u32(16)? as f64, be_u32(20)? as f64);
940        let mut offset = 8usize;
941        while offset.checked_add(12)? <= bytes.len() {
942            let length = be_u32(offset)? as usize;
943            let kind = bytes.get(offset + 4..offset + 8)?;
944            if kind == b"pHYs" && length == 9 && bytes.get(offset + 16) == Some(&1) {
945                let x = be_u32(offset + 8)?;
946                let y = be_u32(offset + 12)?;
947                if x > 0 {
948                    dpi.0 = x as f64 * 0.0254;
949                }
950                if y > 0 {
951                    dpi.1 = y as f64 * 0.0254;
952                }
953            }
954            if kind == b"IDAT" {
955                break;
956            }
957            offset = offset.checked_add(length)?.checked_add(12)?;
958        }
959        dimensions
960    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
961        (le_u16(6)? as f64, le_u16(8)? as f64)
962    } else if bytes.starts_with(b"BM") {
963        if le_u32(14)? == 12 {
964            (le_u16(18)? as f64, le_u16(20)? as f64)
965        } else {
966            let x = le_u32(38)? as i32;
967            let y = le_u32(42)? as i32;
968            if x > 0 && y > 0 {
969                dpi = (x as f64 * 0.0254, y as f64 * 0.0254);
970            }
971            (
972                (le_u32(18)? as i32).checked_abs()? as f64,
973                (le_u32(22)? as i32).checked_abs()? as f64,
974            )
975        }
976    } else if bytes.starts_with(&[0xff, 0xd8]) {
977        let mut offset = 2usize;
978        let mut dimensions = None;
979        while offset + 4 <= bytes.len() {
980            if bytes[offset] != 0xff {
981                return None;
982            }
983            while bytes.get(offset + 1) == Some(&0xff) {
984                offset += 1;
985            }
986            let marker = *bytes.get(offset + 1)?;
987            if marker == 0xda || marker == 0xd9 {
988                break;
989            }
990            if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
991                offset += 2;
992                continue;
993            }
994            let length =
995                u16::from_be_bytes(bytes.get(offset + 2..offset + 4)?.try_into().ok()?) as usize;
996            if length < 2 {
997                return None;
998            }
999            let data = bytes.get(offset + 4..offset.checked_add(length)?.checked_add(2)?)?;
1000            if marker == 0xe0 && data.starts_with(b"JFIF\0") && data.len() >= 12 {
1001                let x = u16::from_be_bytes(data[8..10].try_into().ok()?) as f64;
1002                let y = u16::from_be_bytes(data[10..12].try_into().ok()?) as f64;
1003                if x > 0.0 && y > 0.0 {
1004                    match data[7] {
1005                        1 => dpi = (x, y),
1006                        2 => dpi = (x * 2.54, y * 2.54),
1007                        _ => {}
1008                    }
1009                }
1010            }
1011            if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf)
1012                && data.len() >= 5
1013            {
1014                dimensions = Some((
1015                    u16::from_be_bytes(data[3..5].try_into().ok()?) as f64,
1016                    u16::from_be_bytes(data[1..3].try_into().ok()?) as f64,
1017                ));
1018            }
1019            offset = offset.checked_add(length)?.checked_add(2)?;
1020        }
1021        dimensions?
1022    } else {
1023        return None;
1024    };
1025    if width <= 0.0 || height <= 0.0 || dpi.0 <= 0.0 || dpi.1 <= 0.0 {
1026        return None;
1027    }
1028    Some((width, height, dpi.0, dpi.1))
1029}
1030
1031fn tile_picture(
1032    fill: &str,
1033    source: &str,
1034    intrinsic_source: &str,
1035    width: f64,
1036    height: f64,
1037    crop: [f64; 4],
1038    filter: &str,
1039    defs: &mut String,
1040) -> Option<String> {
1041    let tile = direct_child_tag(fill, "a:tile")?;
1042    let (pixel_width, pixel_height, dpi_x, dpi_y) = raster_metrics(intrinsic_source)?;
1043    let sx = percentage(tile, "sx", 1.0)?;
1044    let sy = percentage(tile, "sy", 1.0)?;
1045    let image_width = pixel_width * 96.0 / dpi_x * sx;
1046    let image_height = pixel_height * 96.0 / dpi_y * sy;
1047    let [left, top, right, bottom] = crop;
1048    let tile_width = image_width * (1.0 - left - right);
1049    let tile_height = image_height * (1.0 - top - bottom);
1050    if [tile_width, tile_height]
1051        .into_iter()
1052        .any(|value| !value.is_finite() || !(0.001..=1_000_000.0).contains(&value))
1053    {
1054        return None;
1055    }
1056    let (align_x, align_y) = alignment(get_attribute_ref(tile, "algn").unwrap_or("tl"));
1057    let x = (width - tile_width) * align_x + number(tile, "tx", 0.0)? / 9525.0;
1058    let y = (height - tile_height) * align_y + number(tile, "ty", 0.0)? / 9525.0;
1059    let flip = get_attribute_ref(tile, "flip").unwrap_or("none");
1060    let flip_x = matches!(flip, "x" | "xy");
1061    let flip_y = matches!(flip, "y" | "xy");
1062    let pattern_width = tile_width * if flip_x { 2.0 } else { 1.0 };
1063    let pattern_height = tile_height * if flip_y { 2.0 } else { 1.0 };
1064    let image_x = -left * image_width;
1065    let image_y = -top * image_height;
1066    let _ = write!(
1067        defs,
1068        r##"<clipPath id="picture-tile-clip"><rect width="{tile_width}" height="{tile_height}"/></clipPath><g id="picture-tile" clip-path="url(#picture-tile-clip)"><image xlink:href="{}" x="{image_x}" y="{image_y}" width="{image_width}" height="{image_height}" preserveAspectRatio="none"{filter}/></g><pattern id="picture-tile-pattern" x="{x}" y="{y}" width="{pattern_width}" height="{pattern_height}" patternUnits="userSpaceOnUse"><use xlink:href="#picture-tile"/>"##,
1069        escape_xml_text(source)
1070    );
1071    if flip_x {
1072        let _ = write!(
1073            defs,
1074            r##"<use xlink:href="#picture-tile" transform="translate({pattern_width} 0) scale(-1 1)"/>"##
1075        );
1076    }
1077    if flip_y {
1078        let _ = write!(
1079            defs,
1080            r##"<use xlink:href="#picture-tile" transform="translate(0 {pattern_height}) scale(1 -1)"/>"##
1081        );
1082    }
1083    if flip_x && flip_y {
1084        let _ = write!(
1085            defs,
1086            r##"<use xlink:href="#picture-tile" transform="translate({pattern_width} {pattern_height}) scale(-1 -1)"/>"##
1087        );
1088    }
1089    defs.push_str("</pattern>");
1090    Some(r##"fill="url(#picture-tile-pattern)""##.to_string())
1091}
1092
1093pub fn render_picture(
1094    xml: &str,
1095    source: &str,
1096    width: f64,
1097    height: f64,
1098    context: &impl PictureContext,
1099) -> Option<RenderedPicture> {
1100    if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
1101        return None;
1102    }
1103    let picture = extract_balanced_tag_slices(xml, "pic:pic")
1104        .into_iter()
1105        .next()?;
1106    let properties = direct_child_tag(picture, "pic:spPr").unwrap_or("");
1107    let fill = direct_child_tag(picture, "pic:blipFill")?;
1108    if direct_child_tag(properties, "a:scene3d").is_some()
1109        || direct_child_tag(properties, "a:sp3d").is_some()
1110    {
1111        return None;
1112    }
1113    let transform = direct_child_tag(properties, "a:xfrm").unwrap_or("");
1114    let rotation = (number(transform, "rot", 0.0)? / 60000.0).rem_euclid(360.0);
1115    let flip_h = enabled(transform, "flipH");
1116    let flip_v = enabled(transform, "flipV");
1117    if (rotation != 0.0 || flip_h || flip_v)
1118        && get_attribute_ref(fill, "rotWithShape")
1119            .is_some_and(|value| matches!(value, "0" | "false" | "off"))
1120    {
1121        return None;
1122    }
1123    let crop = rectangle(direct_child_tag(fill, "a:srcRect"))?;
1124    let destination = rectangle(
1125        direct_child_tag(fill, "a:stretch")
1126            .and_then(|stretch| direct_child_tag(stretch, "a:fillRect")),
1127    )?;
1128    let blip = direct_child_tag(fill, "a:blip")?;
1129    let intrinsic_source = source;
1130    let raster_source = if raster::required(blip, source) {
1131        Some(raster::render(blip, source, context)?)
1132    } else {
1133        None
1134    };
1135    let source = raster_source.as_deref().unwrap_or(source);
1136    let filter = if raster_source.is_some() {
1137        String::new()
1138    } else {
1139        blip_filter(blip, context)?
1140    };
1141    let (border, margin) = border(properties, context)?;
1142    let geometry = direct_child_tag(properties, "a:prstGeom")
1143        .and_then(|geometry| get_attribute_ref(geometry, "prst"));
1144    let shaped = direct_child_tag(properties, "a:custGeom").is_some()
1145        || geometry.is_some_and(|geometry| geometry != "rect");
1146    if !shaped
1147        && rotation == 0.0
1148        && !flip_h
1149        && !flip_v
1150        && border.is_empty()
1151        && filter.is_empty()
1152        && crop == [0.0; 4]
1153        && destination == [0.0; 4]
1154        && raster_source.is_none()
1155        && direct_child_tag(fill, "a:tile").is_none()
1156        && direct_child_tag(properties, "a:effectDag").is_none()
1157        && direct_child_tag(properties, "a:effectLst")
1158            .is_none_or(|effects| extract_direct_child_tag_ranges(effects).is_empty())
1159    {
1160        return None;
1161    }
1162    let path = geometry_path(properties, width, height)?;
1163    let [left, top, right, bottom] = crop;
1164    let [fill_left, fill_top, fill_right, fill_bottom] = destination;
1165    let source_width = 1.0 - left - right;
1166    let source_height = 1.0 - top - bottom;
1167    let fill_width = width * (1.0 - fill_left - fill_right);
1168    let fill_height = height * (1.0 - fill_top - fill_bottom);
1169    if source_width <= 0.0 || source_height <= 0.0 || fill_width <= 0.0 || fill_height <= 0.0 {
1170        return None;
1171    }
1172    let image_width = fill_width / source_width;
1173    let image_height = fill_height / source_height;
1174    let image_x = width * fill_left - left * image_width;
1175    let image_y = height * fill_top - top * image_height;
1176    if [image_width, image_height, image_x, image_y]
1177        .iter()
1178        .any(|value| !value.is_finite() || value.abs() > 100_000_000.0)
1179    {
1180        return None;
1181    }
1182    let mut defs = format!(
1183        r##"<clipPath id="picture-clip"><path d="{path}"/></clipPath><clipPath id="picture-fill"><rect x="{}" y="{}" width="{fill_width}" height="{fill_height}"/></clipPath>"##,
1184        width * fill_left,
1185        height * fill_top
1186    );
1187    let filter_attribute = if filter.is_empty() {
1188        String::new()
1189    } else {
1190        let _ = write!(
1191            defs,
1192            r##"<filter id="picture-filter" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">{filter}</filter>"##
1193        );
1194        r##" filter="url(#picture-filter)""##.to_string()
1195    };
1196    let border_element = if border.is_empty() {
1197        String::new()
1198    } else if rectangular_picture(properties) {
1199        let line_width =
1200            number(direct_child_tag(properties, "a:ln")?, "w", 12700.0)?.max(0.0) / 9525.0;
1201        let outset = line_width / 2.0;
1202        format!(
1203            r##"<rect x="{}" y="{}" width="{}" height="{}" {border}/>"##,
1204            -outset,
1205            -outset,
1206            width + line_width,
1207            height + line_width
1208        )
1209    } else {
1210        let bounds = Bounds::rect(width, height).expanded(margin);
1211        let _ = write!(
1212            defs,
1213            r##"<mask id="picture-border-mask" maskUnits="userSpaceOnUse" {}><rect {} fill="white"/><path d="{path}" fill="black"/></mask>"##,
1214            bounds.attributes(),
1215            bounds.attributes()
1216        );
1217        format!(r##"<path d="{path}" {border} mask="url(#picture-border-mask)"/>"##)
1218    };
1219    let fill_element = if direct_child_tag(fill, "a:tile").is_some() {
1220        let tiled = tile_picture(
1221            fill,
1222            source,
1223            intrinsic_source,
1224            width,
1225            height,
1226            crop,
1227            &filter_attribute,
1228            &mut defs,
1229        )?;
1230        format!(r##"<path d="{path}" {tiled}/>"##)
1231    } else {
1232        format!(
1233            r##"<g clip-path="url(#picture-clip)"><g clip-path="url(#picture-fill)"><image xlink:href="{}" x="{image_x}" y="{image_y}" width="{image_width}" height="{image_height}" preserveAspectRatio="none"{filter_attribute}/></g></g>"##,
1234            escape_xml_text(source)
1235        )
1236    };
1237    let (sin, cos) = rotation.to_radians().sin_cos();
1238    let sx = if flip_h { -1.0 } else { 1.0 };
1239    let sy = if flip_v { -1.0 } else { 1.0 };
1240    let [a, b, c, d] = [cos * sx, sin * sx, -sin * sy, cos * sy];
1241    let matrix = Matrix([
1242        a,
1243        b,
1244        c,
1245        d,
1246        width / 2.0 - a * width / 2.0 - c * height / 2.0,
1247        height / 2.0 - b * width / 2.0 - d * height / 2.0,
1248    ]);
1249    let (body, paint) = apply_shape_effects(
1250        properties,
1251        &format!("{fill_element}{border_element}"),
1252        &path,
1253        width,
1254        height,
1255        margin,
1256        matrix,
1257        context,
1258        &mut defs,
1259        false,
1260    )?;
1261    let paint_width = paint.right - paint.left;
1262    let paint_height = paint.bottom - paint.top;
1263    let paint_x = paint.left;
1264    let paint_y = paint.top;
1265    let bounds = ((paint_x.abs()
1266        + paint_y.abs()
1267        + (paint_width - width).abs()
1268        + (paint_height - height).abs())
1269        > 0.000001)
1270        .then(|| PaintBounds {
1271            x_px: paint_x,
1272            y_px: paint_y,
1273            width_px: paint_width,
1274            height_px: paint_height,
1275            layout_width_px: width,
1276            layout_height_px: height,
1277        });
1278    let svg = format!(
1279        r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{paint_width}" height="{paint_height}" viewBox="{paint_x} {paint_y} {paint_width} {paint_height}" preserveAspectRatio="none"><defs>{defs}</defs>{body}</svg>"##,
1280    );
1281    Some(RenderedPicture {
1282        svg,
1283        paint_bounds: bounds,
1284    })
1285}