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