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