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