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