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