1use crate::model::{
2 ImageCrop, ImageFloating, ImageHorizontalAlign, ImagePaintBounds, ImageRunNode, ImageRunNodeType,
3 ImageVerticalAlign, ImageWrapText, ImageWrapType,
4};
5use crate::parse::colors::normalize_hex_color;
6use crate::parse::context::ParseContext;
7use crate::parse::re;
8use crate::parse::relationships::content_type_for_part;
9use crate::parse::shapes::{
10 drawing_group_paint_bounds, parse_text_box_layout, parse_text_box_paragraphs, render_bounded_text_box_svg,
11 render_standalone_word_shape_svg, render_text_box_svg,
12};
13use crate::parse::util::{
14 active_xml as prefer_alternate_content_choice, clamp, emu_to_pixels,
15 escape_xml_text, is_windows_metafile_content_type, rasterize_windows_metafile_to_png_data_uri,
16 regex_capture, regex_capture_tag, svg_data_uri, to_image_horizontal_align,
17 to_image_vertical_align, windows_metafile_to_svg_data_uri,
18};
19use crate::xml::{
20 extract_balanced_tag_blocks, extract_balanced_tag_blocks_in_order,
21 get_attribute, get_attribute_ref,
22};
23
24const CHART_CONTENT_TYPE: &str =
25 "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
26
27fn css_length_value_and_unit_to_pixels(value: &str, unit: &str) -> Option<f64> {
28 let numeric: f64 = value.parse().ok()?;
29 if !numeric.is_finite() {
30 return None;
31 }
32 match unit.to_ascii_lowercase().as_str() {
33 "px" => Some(numeric.round()),
34 "pt" => Some(((numeric * 96.0) / 72.0).round()),
35 "in" => Some((numeric * 96.0).round()),
36 "cm" => Some(((numeric * 96.0) / 2.54).round()),
37 "mm" => Some(((numeric * 96.0) / 25.4).round()),
38 _ => None,
39 }
40}
41
42pub fn css_length_to_pixels(value: Option<&str>) -> Option<f64> {
43 let value = value?.trim();
44 if value.is_empty() {
45 return None;
46 }
47 let caps =
48 re::get_unchecked(r"(?i)(-?[0-9]+(?:\.[0-9]+)?)\s*(px|pt|in|cm|mm)\b").captures(value)?;
49 css_length_value_and_unit_to_pixels(caps.get(1)?.as_str(), caps.get(2)?.as_str())
50}
51
52fn parse_css_style_declarations(style_value: &str) -> std::collections::HashMap<String, String> {
53 let mut declarations = std::collections::HashMap::new();
54 for entry in style_value.split(';') {
55 let entry = entry.trim();
56 if entry.is_empty() {
57 continue;
58 }
59 let Some(separator_index) = entry.find(':') else {
60 continue;
61 };
62 if separator_index == 0 {
63 continue;
64 }
65 let key = entry[..separator_index].trim().to_ascii_lowercase();
66 let value = entry[separator_index + 1..].trim();
67 if key.is_empty() || value.is_empty() {
68 continue;
69 }
70 declarations.insert(key, value.to_string());
71 }
72 declarations
73}
74
75fn parse_css_length_pixels(style_token: Option<&str>) -> Option<f64> {
76 css_length_to_pixels(style_token)
77}
78
79fn parse_wrap_distance_px(value: Option<&str>) -> Option<f64> {
80 if let Some(distance) = value.and_then(|raw| raw.trim().parse::<u32>().ok()) {
82 if distance > i32::MAX as u32 {
83 return Some(0.0);
84 }
85 }
86 emu_to_pixels(value).map(|resolved| resolved.max(0.0))
87}
88
89fn parse_wp_wrap_type(wrap_type_raw: Option<&str>) -> Option<ImageWrapType> {
90 match wrap_type_raw?.to_ascii_lowercase().as_str() {
91 "wrapnone" => Some(ImageWrapType::WrapNone),
92 "wrapsquare" => Some(ImageWrapType::Square),
93 "wraptight" => Some(ImageWrapType::Tight),
94 "wrapthrough" => Some(ImageWrapType::Through),
95 "wraptopandbottom" => Some(ImageWrapType::TopAndBottom),
96 _ => None,
97 }
98}
99
100fn parse_wp_wrap_text(wrap_text_raw: Option<&str>) -> Option<ImageWrapText> {
101 match wrap_text_raw?.trim() {
102 "bothSides" => Some(ImageWrapText::BothSides),
103 "left" => Some(ImageWrapText::Left),
104 "right" => Some(ImageWrapText::Right),
105 "largest" => Some(ImageWrapText::Largest),
106 _ => None,
107 }
108}
109
110pub fn parse_floating_anchor_from_run_xml(run_xml: &str) -> Option<ImageFloating> {
111 let anchor_tag = regex_capture_tag(run_xml, r"(?i)<wp:anchor\b[^>]*>")?;
112 let position_h_block = regex_capture(
113 run_xml,
114 r"(?is)(<wp:positionH\b[^>]*>[\s\S]*?</wp:positionH>)",
115 );
116 let position_v_block = regex_capture(
117 run_xml,
118 r"(?is)(<wp:positionV\b[^>]*>[\s\S]*?</wp:positionV>)",
119 );
120 let position_h_tag = position_h_block
121 .as_deref()
122 .and_then(|block| regex_capture_tag(block, r"(?i)<wp:positionH\b[^>]*>"));
123 let position_v_tag = position_v_block
124 .as_deref()
125 .and_then(|block| regex_capture_tag(block, r"(?i)<wp:positionV\b[^>]*>"));
126 let wrap_tag = regex_capture_tag(
127 run_xml,
128 r"(?i)<wp:(?:wrapNone|wrapSquare|wrapTight|wrapThrough|wrapTopAndBottom)\b[^>]*/?>",
129 );
130
131 let x_offset_raw = position_h_block
132 .as_deref()
133 .and_then(|block| regex_capture(block, r"(?i)<wp:posOffset>(-?\d+)</wp:posOffset>"));
134 let y_offset_raw = position_v_block
135 .as_deref()
136 .and_then(|block| regex_capture(block, r"(?i)<wp:posOffset>(-?\d+)</wp:posOffset>"));
137 let horizontal_align_raw = position_h_block
138 .as_deref()
139 .and_then(|block| regex_capture(block, r"(?i)<wp:align>([^<]+)</wp:align>"));
140 let vertical_align_raw = position_v_block
141 .as_deref()
142 .and_then(|block| regex_capture(block, r"(?i)<wp:align>([^<]+)</wp:align>"));
143 let dist_l_raw = get_attribute(&anchor_tag, "distL");
144 let dist_r_raw = get_attribute(&anchor_tag, "distR");
145 let dist_t_raw = get_attribute(&anchor_tag, "distT");
146 let dist_b_raw = get_attribute(&anchor_tag, "distB");
147 let z_index_raw = get_attribute(&anchor_tag, "relativeHeight");
148
149 let x_px = emu_to_pixels(x_offset_raw.as_deref());
150 let y_px = emu_to_pixels(y_offset_raw.as_deref());
151 let z_index = z_index_raw
152 .as_deref()
153 .and_then(|raw| raw.parse::<f64>().ok())
154 .filter(|value| value.is_finite())
155 .map(|value| value.round() as i64);
156 let horizontal_relative_to = position_h_tag
157 .as_deref()
158 .and_then(|tag| get_attribute(tag, "relativeFrom"));
159 let vertical_relative_to = position_v_tag
160 .as_deref()
161 .and_then(|tag| get_attribute(tag, "relativeFrom"));
162 let horizontal_align_lower = horizontal_align_raw
163 .as_deref()
164 .map(str::trim)
165 .map(str::to_ascii_lowercase);
166 let vertical_align_lower = vertical_align_raw
167 .as_deref()
168 .map(str::trim)
169 .map(str::to_ascii_lowercase);
170 let horizontal_align = to_image_horizontal_align(horizontal_align_lower.as_deref());
171 let vertical_align = to_image_vertical_align(vertical_align_lower.as_deref());
172 let dist_l_px = parse_wrap_distance_px(dist_l_raw.as_deref());
173 let dist_r_px = parse_wrap_distance_px(dist_r_raw.as_deref());
174 let dist_t_px = parse_wrap_distance_px(dist_t_raw.as_deref());
175 let dist_b_px = parse_wrap_distance_px(dist_b_raw.as_deref());
176 let wrap_type_raw = wrap_tag.as_deref().and_then(|tag| {
177 regex_capture(
178 tag,
179 r"(?i)<wp:(wrapNone|wrapSquare|wrapTight|wrapThrough|wrapTopAndBottom)\b",
180 )
181 });
182 let wrap_type = parse_wp_wrap_type(wrap_type_raw.as_deref());
183 let wrap_text = wrap_tag
184 .as_deref()
185 .and_then(|tag| get_attribute(tag, "wrapText"))
186 .as_deref()
187 .and_then(|raw| parse_wp_wrap_text(Some(raw)));
188 let behind_document = get_attribute(&anchor_tag, "behindDoc").as_deref() == Some("1");
189
190 if x_px.is_none()
191 && y_px.is_none()
192 && horizontal_align.is_none()
193 && vertical_align.is_none()
194 && horizontal_relative_to.is_none()
195 && vertical_relative_to.is_none()
196 && dist_l_px.is_none()
197 && dist_r_px.is_none()
198 && dist_t_px.is_none()
199 && dist_b_px.is_none()
200 && wrap_type.is_none()
201 && wrap_text.is_none()
202 && z_index.is_none()
203 && !behind_document
204 {
205 return None;
206 }
207
208 Some(ImageFloating {
209 x_px,
210 y_px,
211 horizontal_align,
212 vertical_align,
213 horizontal_relative_to,
214 vertical_relative_to,
215 dist_l_px,
216 dist_r_px,
217 dist_t_px,
218 dist_b_px,
219 wrap_type,
220 wrap_text,
221 behind_document: if behind_document { Some(true) } else { None },
222 z_index,
223 })
224}
225
226pub fn parse_drawing_image_css_filter(_run_xml: &str) -> Option<String> {
228 None
229}
230
231fn drawing_duotone_svg(
232 run_xml: &str,
233 source: &str,
234 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
235) -> Option<String> {
236 let effect = extract_balanced_tag_blocks(run_xml, "a:duotone")
237 .into_iter()
238 .next()?;
239 let tags = ["a:srgbClr", "a:schemeClr", "a:sysClr", "a:prstClr"];
240 let colors = extract_balanced_tag_blocks_in_order(&effect, &tags);
241 if colors.len() != 2 {
242 return None;
243 }
244 let mut endpoints = [[0.0; 3]; 2];
245 for (index, range) in colors.iter().enumerate() {
246 let resolved = super::colors::resolve_drawing_color_from_xml(
247 Some(&effect[range.start..range.end]),
248 &context.styles().theme_colors,
249 )?;
250 for channel in 0..3 {
251 endpoints[index][channel] =
252 u8::from_str_radix(&resolved.color[1 + channel * 2..3 + channel * 2], 16).ok()?
253 as f64
254 / 255.0;
255 }
256 }
257 let [dark, light] = endpoints;
258 Some(svg_data_uri(&format!(
259 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1" height="1" viewBox="0 0 1 1" preserveAspectRatio="none"><defs><filter id="duotone" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB"><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"/><feComponentTransfer><feFuncR type="table" tableValues="{} {}"/><feFuncG type="table" tableValues="{} {}"/><feFuncB type="table" tableValues="{} {}"/></feComponentTransfer></filter></defs><image xlink:href="{}" width="1" height="1" preserveAspectRatio="none" filter="url(#duotone)"/></svg>"#,
260 dark[0],
261 light[0],
262 dark[1],
263 light[1],
264 dark[2],
265 light[2],
266 escape_xml_text(source)
267 )))
268}
269
270pub fn parse_drawing_image_opacity(run_xml: &str) -> Option<f64> {
271 let alpha_raw = regex_capture(run_xml, r#"(?i)<a:alphaModFix\b[^>]*amt="(\d+)""#)?;
272 let alpha: f64 = alpha_raw.parse().ok()?;
273 if !alpha.is_finite() {
274 return None;
275 }
276 Some(clamp(alpha / 100_000.0, 0.0, 1.0))
277}
278
279pub fn parse_drawing_image_crop(run_xml: &str) -> Option<ImageCrop> {
280 let src_rect_tag = regex_capture_tag(run_xml, r"(?i)<a:srcRect\b[^>]*/>")
281 .or_else(|| regex_capture_tag(run_xml, r"(?is)<a:srcRect\b[^>]*>[\s\S]*?</a:srcRect>"))?;
282
283 let parse_crop_fraction = |attribute_name: &str| -> Option<f64> {
284 let raw_value = get_attribute(&src_rect_tag, attribute_name)?;
285 let value: f64 = raw_value.trim().trim_end_matches('%').parse().ok()?;
286 if !value.is_finite() {
287 return None;
288 }
289 Some(value / if raw_value.trim().ends_with('%') { 100.0 } else { 100_000.0 })
290 };
291
292 let crop = ImageCrop {
293 left_fraction: parse_crop_fraction("l"),
294 top_fraction: parse_crop_fraction("t"),
295 right_fraction: parse_crop_fraction("r"),
296 bottom_fraction: parse_crop_fraction("b"),
297 };
298
299 let has_crop = [
300 crop.left_fraction,
301 crop.top_fraction,
302 crop.right_fraction,
303 crop.bottom_fraction,
304 ]
305 .into_iter()
306 .flatten()
307 .any(|value| value.is_finite() && value != 0.0);
308
309 if has_crop {
310 Some(crop)
311 } else {
312 None
313 }
314}
315
316fn vml_shape_style(run_xml: &str) -> Option<String> {
317 let shape_tag = regex_capture_tag(run_xml, r"(?i)<v:shape\b[^>]*>").unwrap_or_default();
318 get_attribute(&shape_tag, "style")
319}
320
321pub fn parse_vml_size(run_xml: &str) -> (Option<f64>, Option<f64>) {
322 let style = vml_shape_style(run_xml).unwrap_or_default();
323 parse_vml_size_from_style(&style)
324}
325
326fn parse_vml_size_from_style(style: &str) -> (Option<f64>, Option<f64>) {
327 if style.is_empty() {
328 return (None, None);
329 }
330
331 let width_match =
332 re::get_unchecked(r"(?i)width:\s*([0-9.]+)\s*(px|pt|in|cm|mm)").captures(style);
333 let height_match =
334 re::get_unchecked(r"(?i)height:\s*([0-9.]+)\s*(px|pt|in|cm|mm)").captures(style);
335
336 let width_px = width_match.as_ref().and_then(|caps| {
337 css_length_value_and_unit_to_pixels(caps.get(1)?.as_str(), caps.get(2)?.as_str())
338 });
339 let height_px = height_match.as_ref().and_then(|caps| {
340 css_length_value_and_unit_to_pixels(caps.get(1)?.as_str(), caps.get(2)?.as_str())
341 });
342
343 (width_px, height_px)
344}
345
346fn normalize_vml_relative_to(raw: Option<&str>, axis: &str) -> Option<String> {
347 let normalized = raw?.trim().to_ascii_lowercase();
348 if normalized.is_empty() {
349 return None;
350 }
351 if matches!(
352 normalized.as_str(),
353 "page" | "margin" | "column" | "paragraph" | "line"
354 ) {
355 return Some(normalized);
356 }
357 if normalized == "text" {
358 return Some("paragraph".to_string());
359 }
360 if normalized == "char" || normalized == "character" {
361 let _ = axis;
362 return Some("line".to_string());
363 }
364 None
365}
366
367fn normalize_vml_horizontal_align(raw: Option<&str>) -> Option<ImageHorizontalAlign> {
368 let normalized = raw?.trim().to_ascii_lowercase();
369 to_image_horizontal_align(Some(normalized.as_str()))
370}
371
372fn normalize_vml_vertical_align(raw: Option<&str>) -> Option<ImageVerticalAlign> {
373 let normalized = raw?.trim().to_ascii_lowercase();
374 to_image_vertical_align(Some(normalized.as_str()))
375}
376
377fn normalize_vml_wrap_type(raw: Option<&str>) -> Option<ImageWrapType> {
378 let normalized = raw?.trim().to_ascii_lowercase();
379 match normalized.as_str() {
380 "square" => Some(ImageWrapType::Square),
381 "tight" => Some(ImageWrapType::Tight),
382 "through" => Some(ImageWrapType::Through),
383 "topandbottom" | "top-and-bottom" | "topbottom" => Some(ImageWrapType::TopAndBottom),
384 "none" | "inline" => Some(ImageWrapType::WrapNone),
385 _ => None,
386 }
387}
388
389fn parse_vml_numeric(raw: Option<&str>) -> Option<i64> {
390 let parsed: f64 = raw?.trim().parse().ok()?;
391 if !parsed.is_finite() {
392 return None;
393 }
394 Some(parsed.round() as i64)
395}
396
397pub fn parse_vml_floating_anchor_from_run_xml(run_xml: &str) -> Option<ImageFloating> {
398 let style_value = vml_shape_style(run_xml)?;
399 parse_vml_floating_anchor_from_style(run_xml, &style_value)
400}
401
402fn parse_vml_floating_anchor_from_style(run_xml: &str, style_value: &str) -> Option<ImageFloating> {
403 let declarations = parse_css_style_declarations(style_value);
404 if declarations.is_empty() {
405 return None;
406 }
407
408 let x_px = parse_css_length_pixels(declarations.get("left").map(String::as_str))
409 .or_else(|| parse_css_length_pixels(declarations.get("margin-left").map(String::as_str)));
410 let y_px = parse_css_length_pixels(declarations.get("top").map(String::as_str))
411 .or_else(|| parse_css_length_pixels(declarations.get("margin-top").map(String::as_str)));
412 let horizontal_relative_to = normalize_vml_relative_to(
413 declarations
414 .get("mso-position-horizontal-relative")
415 .map(String::as_str),
416 "horizontal",
417 );
418 let vertical_relative_to = normalize_vml_relative_to(
419 declarations
420 .get("mso-position-vertical-relative")
421 .map(String::as_str),
422 "vertical",
423 );
424 let horizontal_position_mode = declarations
425 .get("mso-position-horizontal")
426 .map(|value| value.trim().to_ascii_lowercase());
427 let vertical_position_mode = declarations
428 .get("mso-position-vertical")
429 .map(|value| value.trim().to_ascii_lowercase());
430 let horizontal_align = horizontal_position_mode
431 .as_deref()
432 .filter(|mode| *mode != "absolute")
433 .and_then(|mode| normalize_vml_horizontal_align(Some(mode)));
434 let vertical_align = vertical_position_mode
435 .as_deref()
436 .filter(|mode| *mode != "absolute")
437 .and_then(|mode| normalize_vml_vertical_align(Some(mode)));
438 let dist_l_px = parse_css_length_pixels(
439 declarations
440 .get("mso-wrap-distance-left")
441 .map(String::as_str),
442 );
443 let dist_r_px = parse_css_length_pixels(
444 declarations
445 .get("mso-wrap-distance-right")
446 .map(String::as_str),
447 );
448 let dist_t_px = parse_css_length_pixels(
449 declarations
450 .get("mso-wrap-distance-top")
451 .map(String::as_str),
452 );
453 let dist_b_px = parse_css_length_pixels(
454 declarations
455 .get("mso-wrap-distance-bottom")
456 .map(String::as_str),
457 );
458 let wrap_tag = regex_capture_tag(run_xml, r"(?i)<w10:wrap\b[^>]*/?>");
459 let wrap_type = wrap_tag
460 .as_deref()
461 .and_then(|tag| get_attribute(tag, "type"))
462 .as_deref()
463 .and_then(|raw| normalize_vml_wrap_type(Some(raw)))
464 .or_else(|| {
465 normalize_vml_wrap_type(declarations.get("mso-wrap-style").map(String::as_str))
466 });
467 let wrap_text = wrap_tag
468 .as_deref()
469 .and_then(|tag| get_attribute(tag, "side"))
470 .as_deref()
471 .and_then(|raw| match raw.trim().to_ascii_lowercase().as_str() {
472 "both" | "bothsides" => Some(ImageWrapText::BothSides),
473 "left" => Some(ImageWrapText::Left),
474 "right" => Some(ImageWrapText::Right),
475 "largest" => Some(ImageWrapText::Largest),
476 _ => None,
477 });
478 let z_index = parse_vml_numeric(declarations.get("z-index").map(String::as_str));
479 let behind_document = z_index.is_some_and(|index| index < 0);
480
481 if x_px.is_none()
482 && y_px.is_none()
483 && horizontal_relative_to.is_none()
484 && vertical_relative_to.is_none()
485 && horizontal_align.is_none()
486 && vertical_align.is_none()
487 && dist_l_px.is_none()
488 && dist_r_px.is_none()
489 && dist_t_px.is_none()
490 && dist_b_px.is_none()
491 && wrap_type.is_none()
492 && wrap_text.is_none()
493 && z_index.is_none()
494 {
495 return None;
496 }
497
498 Some(ImageFloating {
499 x_px,
500 y_px,
501 horizontal_align,
502 vertical_align,
503 horizontal_relative_to,
504 vertical_relative_to,
505 dist_l_px: dist_l_px.map(|value| value.max(0.0)),
506 dist_r_px: dist_r_px.map(|value| value.max(0.0)),
507 dist_t_px: dist_t_px.map(|value| value.max(0.0)),
508 dist_b_px: dist_b_px.map(|value| value.max(0.0)),
509 wrap_type,
510 wrap_text,
511 behind_document: if behind_document { Some(true) } else { None },
512 z_index,
513 })
514}
515
516fn chart_relationship(xml: &str) -> Option<String> {
517 let tag = regex_capture_tag(xml, r"(?i)<(?:c|cx):chart\b[^>]*>")?;
518 get_attribute(&tag, "r:id")
519}
520
521fn resolve_preferred_drawing_relationship_id(
522 run_xml: &str,
523 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
524) -> Option<String> {
525 let mut candidates = Vec::new();
526 for name in ["asvg:svgBlip", "am3d:blip", "a:blip", "v:imagedata", "v:fill"] {
527 for tag in extract_balanced_tag_blocks(run_xml, name) {
528 for attribute in ["r:embed", "r:link", "r:id", "o:relid", "r:href"] {
529 if let Some(id) = get_attribute(&tag, attribute).filter(|id| !id.is_empty()) {
530 candidates.push(id);
531 }
532 }
533 }
534 }
535 candidates.iter().find(|id| {
536 context.relationships.get(*id).is_some_and(|target| {
537 context.binary_assets.get(target).is_some_and(|bytes| !bytes.is_empty())
538 })
539 }).cloned().or_else(|| {
540 candidates.iter().find(|id| {
541 context.relationships.get(*id).is_some_and(|target| external_image_source(target).is_some())
542 }).cloned()
543 }).or_else(|| candidates.into_iter().next())
544}
545
546fn external_image_source(target: &str) -> Option<String> {
547 let normalized = target.trim().to_ascii_lowercase();
548 (normalized.starts_with("https://") || normalized.starts_with("http://"))
549 .then(|| target.trim().to_string())
550}
551
552fn resolve_run_image_layout(run_xml: &str) -> (Option<f64>, Option<f64>, Option<ImageFloating>) {
553 let extent_cx = regex_capture(run_xml, r#"(?i)<wp:extent\b[^>]*cx="(\d+)""#)
554 .or_else(|| regex_capture(run_xml, r#"(?i)<a:ext\b[^>]*cx="(\d+)""#));
555 let extent_cy = regex_capture(run_xml, r#"(?i)<wp:extent\b[^>]*cy="(\d+)""#)
556 .or_else(|| regex_capture(run_xml, r#"(?i)<a:ext\b[^>]*cy="(\d+)""#));
557 let vml_style = vml_shape_style(run_xml);
558 let vml_size = parse_vml_size_from_style(vml_style.as_deref().unwrap_or_default());
559 let drawing_width_px = extent_cx
560 .as_deref()
561 .and_then(|raw| emu_to_pixels(Some(raw)));
562 let drawing_height_px = extent_cy
563 .as_deref()
564 .and_then(|raw| emu_to_pixels(Some(raw)));
565 let width_px = vml_size.0.or(drawing_width_px);
568 let height_px = vml_size.1.or(drawing_height_px);
569 let floating = parse_floating_anchor_from_run_xml(run_xml).or_else(|| {
570 vml_style
571 .as_deref()
572 .and_then(|style| parse_vml_floating_anchor_from_style(run_xml, style))
573 });
574 (width_px, height_px, floating)
575}
576
577fn resolve_doc_pr_alt(run_xml: &str) -> Option<String> {
578 let doc_pr_tag = regex_capture_tag(run_xml, r"(?i)<(?:wp:docPr|pic:cNvPr|v:imagedata)\b[^>]*>")?;
579 get_attribute(&doc_pr_tag, "descr")
580 .or_else(|| get_attribute(&doc_pr_tag, "title"))
581 .or_else(|| get_attribute(&doc_pr_tag, "o:title"))
582 .or_else(|| get_attribute(&doc_pr_tag, "name"))
583}
584
585fn vml_finite(value: &str) -> Option<f64> {
586 let value: f64 = value.trim().parse().ok()?;
587 value.is_finite().then_some(value)
588}
589
590pub(crate) fn vml_pixels(value: &str) -> Option<f64> {
591 let value = value.trim();
592 let Some(split) = value.find(|c: char| c.is_ascii_alphabetic()) else {
593 return vml_finite(value);
594 };
595 let number = vml_finite(&value[..split])?;
596 let factor = match value[split..].trim().to_ascii_lowercase().as_str() {
597 "px" => 1.0,
598 "pt" => 96.0 / 72.0,
599 "in" => 96.0,
600 "cm" => 96.0 / 2.54,
601 "mm" => 96.0 / 25.4,
602 _ => return None,
603 };
604 let pixels = number * factor;
605 pixels.is_finite().then_some(pixels)
606}
607
608fn vml_pair(value: &str) -> Option<(f64, f64)> {
609 let mut parts = value
610 .split(|c: char| c == ',' || c.is_ascii_whitespace())
611 .filter(|v| !v.is_empty());
612 let result = (vml_finite(parts.next()?)?, vml_finite(parts.next()?)?);
613 parts.next().is_none().then_some(result)
614}
615
616fn vml_svg_path(path: &str) -> Option<String> {
617 vml_explicit_svg_path(path).or_else(|| vml_compact_svg_path(path))
618}
619
620fn vml_compact_svg_path(path: &str) -> Option<String> {
621 use std::fmt::Write;
622
623 let mut result = String::with_capacity(path.len());
624 let mut remaining = path.trim();
625 let mut current = (0.0, 0.0);
626 let mut start = current;
627 while !remaining.is_empty() {
628 let command = *remaining.as_bytes().first()?;
629 if !matches!(command, b'm' | b'l' | b'r' | b'c' | b'x' | b'e')
630 || (result.is_empty() && command != b'm')
631 {
632 return None;
633 }
634 remaining = &remaining[1..];
635 let end = remaining
636 .find(|c: char| c.is_ascii_alphabetic())
637 .unwrap_or(remaining.len());
638 let parameters = remaining[..end].trim();
639 let mut numbers = Vec::new();
640 if !parameters.is_empty() {
641 for field in parameters.split(',') {
642 if field.trim().is_empty() {
643 numbers.push(0.0);
644 } else {
645 for number in field.split_ascii_whitespace() {
646 numbers.push(vml_finite(number)?);
647 }
648 }
649 }
650 }
651 let valid = match command {
652 b'm' | b'l' | b'r' => !numbers.is_empty() && numbers.len() % 2 == 0,
653 b'c' => !numbers.is_empty() && numbers.len() % 6 == 0,
654 _ => numbers.is_empty(),
655 };
656 if !valid {
657 return None;
658 }
659 remaining = remaining[end..].trim();
660 match command {
661 b'e' => {
662 if !remaining.is_empty() {
663 return None;
664 }
665 }
666 b'x' => {
667 result.push('Z');
668 current = start;
669 }
670 b'c' => {
671 result.push('C');
672 for (index, value) in numbers.iter().enumerate() {
673 if index != 0 {
674 result.push(' ');
675 }
676 write!(result, "{value}").ok()?;
677 }
678 current = (numbers[numbers.len() - 2], numbers[numbers.len() - 1]);
679 }
680 _ => {
681 for (index, pair) in numbers.chunks_exact(2).enumerate() {
682 let point = if command == b'r' {
683 (current.0 + pair[0], current.1 + pair[1])
684 } else {
685 (pair[0], pair[1])
686 };
687 if !point.0.is_finite() || !point.1.is_finite() {
688 return None;
689 }
690 let output_command = if command == b'm' && index == 0 {
691 start = point;
692 'M'
693 } else {
694 'L'
695 };
696 write!(result, "{output_command}{} {}", point.0, point.1).ok()?;
697 current = point;
698 }
699 }
700 }
701 }
702 (!result.is_empty()).then_some(result)
703}
704
705fn vml_explicit_svg_path(path: &str) -> Option<String> {
706 let mut result = String::with_capacity(path.len());
707 let mut remaining = path.trim();
708 while !remaining.is_empty() {
709 let command = *remaining.as_bytes().first()?;
710 if !matches!(command, b'm' | b'l' | b'c' | b'x' | b'e') {
711 return None;
712 }
713 if result.is_empty() && command != b'm' {
714 return None;
715 }
716 remaining = &remaining[1..];
717 let end = remaining
718 .find(|c: char| c.is_ascii_alphabetic())
719 .unwrap_or(remaining.len());
720 let parameters = remaining[..end].trim();
721 if parameters.starts_with(',')
722 || parameters.ends_with(',')
723 || parameters.split(',').any(|value| value.trim().is_empty()) && !parameters.is_empty()
724 {
725 return None;
726 }
727 let numbers = parameters
728 .split(|c: char| c == ',' || c.is_ascii_whitespace())
729 .filter(|value| !value.is_empty())
730 .map(vml_finite)
731 .collect::<Option<Vec<_>>>()?;
732 let valid = match command {
733 b'm' | b'l' => !numbers.is_empty() && numbers.len() % 2 == 0,
734 b'c' => !numbers.is_empty() && numbers.len() % 6 == 0,
735 _ => numbers.is_empty(),
736 };
737 if !valid {
738 return None;
739 }
740 remaining = remaining[end..].trim();
741 if command == b'e' {
742 if !remaining.is_empty() {
743 return None;
744 }
745 } else {
746 result.push(if command == b'x' {
747 'Z'
748 } else {
749 (command as char).to_ascii_uppercase()
750 });
751 result.push_str(parameters);
752 }
753 }
754 (!result.is_empty()).then_some(result)
755}
756
757fn vml_path_stays_on_axis(path: &str, axis: usize, origin: f64) -> bool {
758 path.split(|c: char| c.is_ascii_alphabetic() || c == ',' || c.is_ascii_whitespace())
759 .filter(|value| !value.is_empty())
760 .enumerate()
761 .all(|(index, value)| index % 2 != axis || vml_finite(value) == Some(origin))
762}
763
764fn vml_path_bounds(
765 path: &str,
766 x: f64,
767 y: f64,
768 scale_x: f64,
769 scale_y: f64,
770 origin_x: f64,
771 origin_y: f64,
772) -> Option<(f64, f64, f64, f64)> {
773 let mut values = path
774 .split(|c: char| c.is_ascii_alphabetic() || c == ',' || c.is_ascii_whitespace())
775 .filter(|value| !value.is_empty());
776 let mut bounds = (
777 f64::INFINITY,
778 f64::INFINITY,
779 f64::NEG_INFINITY,
780 f64::NEG_INFINITY,
781 );
782 while let Some(value) = values.next() {
783 let px = x + (vml_finite(value)? - origin_x) * scale_x;
784 let py = y + (vml_finite(values.next()?)? - origin_y) * scale_y;
785 if !px.is_finite() || !py.is_finite() {
786 return None;
787 }
788 bounds.0 = bounds.0.min(px);
789 bounds.1 = bounds.1.min(py);
790 bounds.2 = bounds.2.max(px);
791 bounds.3 = bounds.3.max(py);
792 }
793 bounds.0.is_finite().then_some(bounds)
794}
795
796fn vml_attributes_are_known(xml: &str, names: &[&[u8]]) -> bool {
797 let mut reader = quick_xml::Reader::from_str(xml);
798 let Ok(quick_xml::events::Event::Start(element) | quick_xml::events::Event::Empty(element)) =
799 reader.read_event()
800 else {
801 return false;
802 };
803 element.attributes().all(|attribute| {
804 attribute.is_ok_and(|attribute| {
805 names.contains(&attribute.key.as_ref().as_bytes())
806 || attribute.key.as_ref().starts_with("xmlns:")
807 })
808 })
809}
810
811fn vml_bitmap_rectangle_type(xml: &str) -> bool {
812 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
813 return false;
814 };
815 let opening = &xml[..end];
816 if !vml_attributes_are_known(
817 xml,
818 &[
819 b"id",
820 b"coordsize",
821 b"o:spt",
822 b"o:preferrelative",
823 b"path",
824 b"filled",
825 b"stroked",
826 ],
827 ) || get_attribute(opening, "id").as_deref() != Some("_x0000_t75")
828 || get_attribute(opening, "coordsize").and_then(|value| vml_pair(&value))
829 != Some((21600.0, 21600.0))
830 || get_attribute(opening, "o:spt").as_deref() != Some("75")
831 || get_attribute(opening, "path").as_deref() != Some("m@4@5l@4@11@9@11@9@5xe")
832 || !["filled", "stroked"].iter().all(|name| {
833 get_attribute(opening, name)
834 .is_some_and(|value| matches!(value.as_str(), "f" | "false" | "0"))
835 })
836 || get_attribute(opening, "o:preferrelative")
837 .is_some_and(|value| !matches!(value.as_str(), "t" | "true" | "1"))
838 {
839 return false;
840 }
841 let children = crate::xml::extract_direct_child_tag_ranges(xml);
842 let mut formulas_seen = false;
843 let mut seen = std::collections::HashSet::new();
844 for child in children {
845 if !seen.insert(child.tag_name.clone()) {
846 return false;
847 }
848 let inner = &xml[child.start..child.end];
849 let valid = match child.tag_name.as_str() {
850 "v:stroke" => {
851 vml_attributes_are_known(inner, &[b"joinstyle"])
852 && get_attribute(inner, "joinstyle").as_deref() == Some("miter")
853 && crate::xml::extract_direct_child_tag_ranges(inner).is_empty()
854 }
855 "v:path" => {
856 vml_attributes_are_known(
857 inner,
858 &[b"o:extrusionok", b"gradientshapeok", b"o:connecttype"],
859 ) && crate::xml::extract_direct_child_tag_ranges(inner).is_empty()
860 }
861 "o:lock" => {
862 vml_attributes_are_known(inner, &[b"v:ext", b"aspectratio"])
863 && crate::xml::extract_direct_child_tag_ranges(inner).is_empty()
864 }
865 "v:formulas" => {
866 formulas_seen = true;
867 let expected = [
868 "if lineDrawn pixelLineWidth 0",
869 "sum @0 1 0",
870 "sum 0 0 @1",
871 "prod @2 1 2",
872 "prod @3 21600 pixelWidth",
873 "prod @3 21600 pixelHeight",
874 "sum @0 0 1",
875 "prod @6 1 2",
876 "prod @7 21600 pixelWidth",
877 "sum @8 21600 0",
878 "prod @7 21600 pixelHeight",
879 "sum @10 21600 0",
880 ];
881 let formulas = crate::xml::extract_direct_child_tag_ranges(inner);
882 vml_attributes_are_known(inner, &[])
883 && formulas.len() == expected.len()
884 && formulas.iter().zip(expected).all(|(formula, expected)| {
885 let item = &inner[formula.start..formula.end];
886 formula.tag_name == "v:f"
887 && vml_attributes_are_known(item, &[b"eqn"])
888 && get_attribute(item, "eqn").is_some_and(|value| {
889 value
890 .split_ascii_whitespace()
891 .eq(expected.split_ascii_whitespace())
892 })
893 && crate::xml::extract_direct_child_tag_ranges(item).is_empty()
894 })
895 }
896 _ => false,
897 };
898 if !valid {
899 return false;
900 }
901 }
902 formulas_seen
903}
904
905fn vml_textbox_rectangle_type(xml: &str) -> bool {
906 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
907 return false;
908 };
909 let tag = &xml[..end];
910 if !vml_attributes_are_known(tag, &[b"id", b"coordsize", b"o:spt", b"path"])
911 || get_attribute(tag, "id").as_deref() != Some("_x0000_t202")
912 || get_attribute(tag, "o:spt").as_deref() != Some("202")
913 || get_attribute(tag, "coordsize").and_then(|value| vml_pair(&value))
914 != Some((21600.0, 21600.0))
915 || get_attribute(tag, "path").as_deref() != Some("m,l,21600r21600,l21600,xe")
916 {
917 return false;
918 }
919 let mut stroke = false;
920 let mut path = false;
921 for child in crate::xml::extract_direct_child_tag_ranges(xml) {
922 let inner = &xml[child.start..child.end];
923 if !crate::xml::extract_direct_child_tag_ranges(inner).is_empty() {
924 return false;
925 }
926 match child.tag_name.as_str() {
927 "v:stroke" if !stroke => {
928 stroke = true;
929 if !vml_attributes_are_known(inner, &[b"joinstyle"])
930 || get_attribute(inner, "joinstyle").as_deref() != Some("miter")
931 {
932 return false;
933 }
934 }
935 "v:path" if !path => {
936 path = true;
937 if !vml_attributes_are_known(inner, &[b"gradientshapeok", b"o:connecttype"])
938 || get_attribute(inner, "gradientshapeok").as_deref() != Some("t")
939 || get_attribute(inner, "o:connecttype").as_deref() != Some("rect")
940 {
941 return false;
942 }
943 }
944 _ => return false,
945 }
946 }
947 stroke && path
948}
949
950fn vml_editing_lock(xml: &str) -> bool {
951 vml_attributes_are_known(xml, &[b"v:ext", b"aspectratio"])
952 && get_attribute(xml, "v:ext").as_deref() == Some("edit")
953 && get_attribute(xml, "aspectratio")
954 .is_none_or(|value| matches!(value.as_str(), "t" | "f" | "true" | "false" | "0" | "1"))
955 && crate::xml::extract_direct_child_tag_ranges(xml).is_empty()
956}
957
958fn vml_no_wrap(xml: &str) -> bool {
959 vml_attributes_are_known(xml, &[b"type"])
960 && get_attribute(xml, "type").as_deref() == Some("none")
961 && crate::xml::extract_direct_child_tag_ranges(xml).is_empty()
962}
963
964fn vml_identity_group(xml: &str) -> Option<()> {
965 let tag = &xml[..crate::xml::tag_token_end(xml, 0)?];
966 if !vml_attributes_are_known(
967 tag,
968 &[
969 b"id",
970 b"o:spid",
971 b"alt",
972 b"style",
973 b"coordorigin",
974 b"coordsize",
975 ],
976 ) || get_attribute(tag, "alt").is_some_and(|value| !value.is_empty())
977 {
978 return None;
979 }
980 let declarations = parse_css_style_declarations(&get_attribute(tag, "style")?);
981 if declarations.get("position").map(String::as_str) != Some("absolute")
982 || declarations.iter().any(|(key, value)| {
983 !matches!(
984 key.as_str(),
985 "position" | "left" | "top" | "width" | "height"
986 ) && !(key == "visibility" && value == "visible")
987 && !(key == "z-index" && vml_finite(value) == Some(0.0))
988 })
989 {
990 return None;
991 }
992 let origin = vml_pair(&get_attribute(tag, "coordorigin").unwrap_or_else(|| "0,0".into()))?;
993 let size = vml_pair(&get_attribute(tag, "coordsize")?)?;
994 let left = declarations
995 .get("left")
996 .map(|value| vml_finite(value))
997 .unwrap_or(Some(0.0))?;
998 let top = declarations
999 .get("top")
1000 .map(|value| vml_finite(value))
1001 .unwrap_or(Some(0.0))?;
1002 let width = vml_finite(declarations.get("width")?)?;
1003 let height = vml_finite(declarations.get("height")?)?;
1004 (origin == (left, top) && size == (width, height) && width > 0.0 && height > 0.0).then_some(())
1005}
1006
1007fn vml_group_leaves(xml: &str) -> Option<Vec<crate::xml::TaggedRange>> {
1008 fn append(
1009 xml: &str,
1010 offset: usize,
1011 depth: usize,
1012 output: &mut Vec<crate::xml::TaggedRange>,
1013 ) -> Option<()> {
1014 if depth > 32 {
1015 return None;
1016 }
1017 for mut child in crate::xml::extract_direct_child_tag_ranges(xml) {
1018 let inner = &xml[child.start..child.end];
1019 if child.tag_name == "v:group" {
1020 vml_identity_group(inner)?;
1021 append(inner, offset + child.start, depth + 1, output)?;
1022 } else {
1023 if depth > 0 {
1024 let tag = &inner[..crate::xml::tag_token_end(inner, 0)?];
1025 let style = parse_css_style_declarations(
1026 &get_attribute(tag, "style").unwrap_or_default(),
1027 );
1028 if style
1029 .get("z-index")
1030 .is_some_and(|value| vml_finite(value) != Some(0.0))
1031 {
1032 return None;
1033 }
1034 }
1035 child.start += offset;
1036 child.end += offset;
1037 output.push(child);
1038 }
1039 }
1040 Some(())
1041 }
1042 let mut output = Vec::new();
1043 append(xml, 0, 0, &mut output)?;
1044 Some(output)
1045}
1046
1047fn vml_canvas_hit_test_shape(xml: &str, origin: (f64, f64), extent: (f64, f64)) -> bool {
1048 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
1049 return false;
1050 };
1051 let tag = &xml[..end];
1052 if !vml_attributes_are_known(tag, &[b"id", b"o:spid", b"type", b"style"])
1053 || get_attribute(tag, "type").as_deref() != Some("#_x0000_t75")
1054 {
1055 return false;
1056 }
1057 let declarations =
1058 parse_css_style_declarations(&get_attribute(tag, "style").unwrap_or_default());
1059 if declarations.get("position").map(String::as_str) != Some("absolute")
1060 || declarations.iter().any(|(key, value)| {
1061 !matches!(
1062 key.as_str(),
1063 "position" | "left" | "top" | "width" | "height"
1064 ) && !(key == "visibility" && value == "visible")
1065 })
1066 {
1067 return false;
1068 }
1069 for (key, expected) in [
1070 ("left", origin.0),
1071 ("top", origin.1),
1072 ("width", extent.0),
1073 ("height", extent.1),
1074 ] {
1075 let value = declarations
1076 .get(key)
1077 .map(|value| vml_finite(value))
1078 .unwrap_or(Some(0.0));
1079 if value != Some(expected) {
1080 return false;
1081 }
1082 }
1083 let mut fill = false;
1084 let mut path = false;
1085 for child in crate::xml::extract_direct_child_tag_ranges(xml) {
1086 let inner = &xml[child.start..child.end];
1087 if !crate::xml::extract_direct_child_tag_ranges(inner).is_empty() {
1088 return false;
1089 }
1090 match child.tag_name.as_str() {
1091 "v:fill" if !fill => {
1092 fill = true;
1093 if !vml_attributes_are_known(inner, &[b"o:detectmouseclick"])
1094 || get_attribute(inner, "o:detectmouseclick").as_deref() != Some("t")
1095 {
1096 return false;
1097 }
1098 }
1099 "v:path" if !path => {
1100 path = true;
1101 if !vml_attributes_are_known(inner, &[b"o:connecttype"])
1102 || get_attribute(inner, "o:connecttype").as_deref() != Some("none")
1103 {
1104 return false;
1105 }
1106 }
1107 _ => return false,
1108 }
1109 }
1110 fill && path
1111}
1112
1113fn vml_declares_unpainted_bitmap(xml: &str) -> bool {
1114 let mut found = false;
1115 for definition in extract_balanced_tag_blocks(xml, "v:shapetype") {
1116 let Some(end) = crate::xml::tag_token_end(&definition, 0) else {
1117 return false;
1118 };
1119 if get_attribute(&definition[..end], "id").as_deref() == Some("_x0000_t75") {
1120 if !vml_bitmap_rectangle_type(&definition) {
1121 return false;
1122 }
1123 found = true;
1124 }
1125 }
1126 found
1127}
1128
1129fn vml_inherits_unpainted_bitmap(
1130 group: &str,
1131 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1132) -> bool {
1133 let mut found = false;
1134 for part in context
1135 .parts
1136 .values()
1137 .filter(|part| part.content.contains(group))
1138 {
1139 if !vml_declares_unpainted_bitmap(&part.content) {
1140 return false;
1141 }
1142 found = true;
1143 }
1144 found
1145}
1146
1147fn vml_rect_has_default_stroke(xml: &str) -> bool {
1148 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
1149 return false;
1150 };
1151 let tag = &xml[..end];
1152 get_attribute(tag, "strokeweight").is_none()
1153 && !get_attribute(tag, "stroked")
1154 .is_some_and(|value| matches!(value.as_str(), "f" | "false" | "0"))
1155}
1156
1157fn vml_browser_bitmap_src(src: &str, part_name: Option<&str>, content_type: Option<&str>) -> bool {
1158 if part_name
1159 .and_then(|value| value.rsplit_once('.'))
1160 .is_some_and(|(_, suffix)| {
1161 suffix.eq_ignore_ascii_case("emf") || suffix.eq_ignore_ascii_case("wmf")
1162 })
1163 || content_type.is_some_and(|value| {
1164 [
1165 "image/x-emf",
1166 "image/emf",
1167 "image/x-wmf",
1168 "image/wmf",
1169 "application/x-msmetafile",
1170 ]
1171 .iter()
1172 .any(|kind| value.eq_ignore_ascii_case(kind))
1173 })
1174 {
1175 return false;
1176 }
1177 [
1178 "data:image/png;",
1179 "data:image/jpeg;",
1180 "data:image/gif;",
1181 "data:image/bmp;",
1182 "data:image/webp;",
1183 "data:image/svg+xml;",
1184 ]
1185 .iter()
1186 .any(|prefix| src.starts_with(prefix))
1187}
1188
1189fn vml_named_paint_color(value: &str) -> Option<&'static str> {
1190 let value = value.trim();
1191 let color = match value.len() {
1192 3..=7 => value,
1193 _ => return None,
1194 };
1195 Some(match color {
1196 name if name.eq_ignore_ascii_case("black") => "#000000",
1197 name if name.eq_ignore_ascii_case("silver") => "#c0c0c0",
1198 name if name.eq_ignore_ascii_case("gray") => "#808080",
1199 name if name.eq_ignore_ascii_case("white") => "#ffffff",
1200 name if name.eq_ignore_ascii_case("maroon") => "#800000",
1201 name if name.eq_ignore_ascii_case("red") => "#ff0000",
1202 name if name.eq_ignore_ascii_case("purple") => "#800080",
1203 name if name.eq_ignore_ascii_case("fuchsia") => "#ff00ff",
1204 name if name.eq_ignore_ascii_case("green") => "#008000",
1205 name if name.eq_ignore_ascii_case("lime") => "#00ff00",
1206 name if name.eq_ignore_ascii_case("olive") => "#808000",
1207 name if name.eq_ignore_ascii_case("yellow") => "#ffff00",
1208 name if name.eq_ignore_ascii_case("navy") => "#000080",
1209 name if name.eq_ignore_ascii_case("blue") => "#0000ff",
1210 name if name.eq_ignore_ascii_case("teal") => "#008080",
1211 name if name.eq_ignore_ascii_case("aqua") => "#00ffff",
1212 _ => return None,
1213 })
1214}
1215
1216fn vml_paint_color(value: &str) -> Option<String> {
1217 normalize_hex_color(Some(value))
1218 .or_else(|| vml_named_paint_color(value).map(str::to_owned))
1219}
1220
1221fn vml_has_active_named_paint(tag_name: &str, xml: &str) -> bool {
1222 if !matches!(tag_name, "v:shape" | "v:rect" | "v:line") {
1223 return false;
1224 }
1225 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
1226 return false;
1227 };
1228 let tag = &xml[..end];
1229 let is_named = |name| {
1230 get_attribute_ref(tag, name)
1231 .is_some_and(|value| vml_named_paint_color(value).is_some())
1232 };
1233 if !get_attribute_ref(tag, "filled")
1234 .is_some_and(|value| matches!(value, "f" | "false" | "0"))
1235 && is_named("fillcolor")
1236 {
1237 return true;
1238 }
1239 if !is_named("strokecolor") {
1240 return false;
1241 }
1242 let stroked = get_attribute_ref(tag, "stroked");
1243 if stroked
1244 .is_some_and(|value| matches!(value, "f" | "false" | "0"))
1245 {
1246 return false;
1247 }
1248 let is_bitmap = xml.contains("<v:imagedata") && !xml.contains("<w:txbxContent");
1249 !(is_bitmap && stroked.is_none())
1250}
1251
1252
1253
1254fn vml_group_has_explicit_defaults(tag_name: &str, xml: &str) -> bool {
1255 if !matches!(tag_name, "v:shape" | "v:rect" | "v:line") {
1256 return false;
1257 }
1258 let Some(end) = crate::xml::tag_token_end(xml, 0) else {
1259 return false;
1260 };
1261 let has_default = |tag: &str| {
1262 get_attribute_ref(tag, "style").is_some_and(|style| {
1263 parse_css_style_declarations(style).iter().any(|(key, value)| {
1264 (key == "mso-wrap-style" && value == "square")
1265 || (key == "v-text-anchor" && value == "top")
1266 || key == "mso-next-textbox"
1267 })
1268 })
1269 };
1270 if has_default(&xml[..end]) {
1271 return true;
1272 }
1273 if !xml.contains("<v:textbox") {
1274 return false;
1275 }
1276 crate::xml::extract_direct_child_tag_ranges(xml).iter().any(|child| {
1277 child.tag_name == "v:textbox" && has_default(&xml[child.start..child.end])
1278 })
1279}
1280
1281
1282fn vml_inline_reference_hints(
1283 style: &std::collections::HashMap<String, String>,
1284 has_root_wrap: bool,
1285 floating: Option<&ImageFloating>,
1286) -> bool {
1287 if has_root_wrap
1288 || style.keys().any(|key| {
1289 !matches!(
1290 key.as_str(),
1291 "width"
1292 | "height"
1293 | "position"
1294 | "mso-position-horizontal-relative"
1295 | "mso-position-vertical-relative"
1296 )
1297 })
1298 || style.get("position").is_some_and(|value| {
1299 !value.eq_ignore_ascii_case("static") && !value.eq_ignore_ascii_case("relative")
1300 })
1301 {
1302 return false;
1303 }
1304 let Some(ImageFloating {
1305 x_px: None,
1306 y_px: None,
1307 horizontal_align: None,
1308 vertical_align: None,
1309 horizontal_relative_to,
1310 vertical_relative_to,
1311 dist_l_px: None,
1312 dist_r_px: None,
1313 dist_t_px: None,
1314 dist_b_px: None,
1315 wrap_type: None,
1316 wrap_text: None,
1317 behind_document: None,
1318 z_index: None,
1319 }) = floating
1320 else {
1321 return false;
1322 };
1323 horizontal_relative_to.is_some() || vertical_relative_to.is_some()
1324}
1325
1326fn vml_group_image(
1327 run_xml: &str,
1328 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1329) -> Option<ImageRunNode> {
1330 vml_group_image_with_owner(run_xml, context, None)
1331}
1332
1333pub(crate) fn parse_vml_group_image_from_part(
1334 run_xml: &str,
1335 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1336 owner_part_name: &str,
1337) -> Option<ImageRunNode> {
1338 let part = context.parts.get(owner_part_name)?;
1339 if part.name != owner_part_name {
1340 return None;
1341 }
1342 vml_group_image_with_owner(run_xml, context, Some(owner_part_name))
1343}
1344
1345fn vml_group_image_with_owner(
1346 run_xml: &str,
1347 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1348 owner_part_name: Option<&str>,
1349) -> Option<ImageRunNode> {
1350 if !run_xml.contains("<v:group") || !run_xml.contains("<v:imagedata") {
1351 return None;
1352 }
1353 let groups = extract_balanced_tag_blocks(run_xml, "v:group");
1354 if groups.len() != 1 {
1355 return None;
1356 }
1357 let group = &groups[0];
1358 let has_text_box = group.contains("<w:txbxContent");
1359 let bitmap_count = group.matches("<v:imagedata").count();
1360 if bitmap_count == 0
1361 || run_xml.matches("<v:imagedata").count() != bitmap_count
1362 {
1363 return None;
1364 }
1365 {
1366 let _scope = crate::xml_index::scope(std::iter::once(("", run_xml)), Default::default());
1367 let roots = crate::xml_index::children(run_xml)?;
1368 if roots
1369 .iter()
1370 .any(|child| !matches!(child.tag_name.as_str(), "v:group" | "v:shapetype"))
1371 {
1372 return None;
1373 }
1374 }
1375 let opening = &group[..crate::xml::tag_token_end(group, 0)?];
1376 if [
1377 "filled",
1378 "fillcolor",
1379 "stroked",
1380 "strokecolor",
1381 "strokeweight",
1382 "opacity",
1383 ]
1384 .iter()
1385 .any(|name| get_attribute(opening, name).is_some())
1386 {
1387 return None;
1388 }
1389 let style_text = get_attribute(opening, "style")?;
1390 let style = parse_css_style_declarations(&style_text);
1391 if ["rotation", "flip", "visibility", "display"]
1392 .iter()
1393 .any(|key| style.contains_key(*key))
1394 {
1395 return None;
1396 }
1397 let width = vml_pixels(style.get("width")?)?;
1398 let height = vml_pixels(style.get("height")?)?;
1399 if width <= 0.0 || height <= 0.0 {
1400 return None;
1401 }
1402 let (origin_x, origin_y) =
1403 vml_pair(&get_attribute(opening, "coordorigin").unwrap_or_else(|| "0,0".into()))?;
1404 let (extent_x, extent_y) =
1405 vml_pair(&get_attribute(opening, "coordsize").unwrap_or_else(|| "1000,1000".into()))?;
1406 if extent_x <= 0.0 || extent_y <= 0.0 {
1407 return None;
1408 }
1409 let _index_scope =
1410 crate::xml_index::scope(std::iter::once(("", group.as_str())), Default::default());
1411 let original_children = crate::xml_index::children(group)?;
1412 if !has_text_box && bitmap_count == 1 {
1413 if style.iter().any(|(key, value)| {
1414 !matches!(key.as_str(), "width" | "height")
1415 && !(key == "position" && matches!(value.as_str(), "static" | "relative"))
1416 }) || original_children.iter().any(|child| {
1417 !matches!(child.tag_name.as_str(), "v:shape" | "v:shapetype")
1418 }) {
1419 return None;
1420 }
1421 let mut shapes = original_children.iter().filter(|child| child.tag_name == "v:shape");
1422 let shape = shapes.next()?;
1423 if shapes.next().is_some() {
1424 return None;
1425 }
1426 let xml = &group[shape.start..shape.end];
1427 let tag = &xml[..crate::xml::tag_token_end(xml, 0)?];
1428 let declarations = parse_css_style_declarations(&get_attribute(tag, "style")?);
1429 let x = declarations.get("left").map(|value| vml_finite(value)).unwrap_or(Some(0.0))?;
1430 let y = declarations.get("top").map(|value| vml_finite(value)).unwrap_or(Some(0.0))?;
1431 let w = vml_finite(declarations.get("width")?)?;
1432 let h = vml_finite(declarations.get("height")?)?;
1433 if w <= 0.0 || h <= 0.0
1434 || (x >= origin_x && y >= origin_y && x + w <= origin_x + extent_x && y + h <= origin_y + extent_y)
1435 {
1436 return None;
1437 }
1438 let relationship = resolve_preferred_drawing_relationship_id(xml, context)
1439 .or_else(|| regex_capture(xml, r#"(?i)<v:imagedata\b[^>]*r:id="([^"]+)""#))
1440 .or_else(|| chart_relationship(xml))?;
1441 let part_name = context.relationships.get(&relationship)?;
1442 context.binary_assets.get(part_name)?;
1443 }
1444 let mut root_wraps = original_children
1445 .iter()
1446 .filter(|child| child.tag_name == "w10:wrap");
1447 let root_wrap = root_wraps
1448 .next()
1449 .map(|child| &group[child.start..child.end]);
1450 let multiple_root_wraps = root_wraps.next().is_some();
1451 let nested_groups = original_children
1452 .iter()
1453 .any(|child| child.tag_name == "v:group");
1454 let children = if nested_groups {
1455 vml_group_leaves(group)?
1456 } else {
1457 original_children
1458 };
1459 let has_placeholder = get_attribute(opening, "editas").as_deref() == Some("canvas")
1460 && children.iter().any(|child| {
1461 child.tag_name == "v:shape"
1462 && vml_canvas_hit_test_shape(
1463 &group[child.start..child.end],
1464 (origin_x, origin_y),
1465 (extent_x, extent_y),
1466 )
1467 });
1468 let skip_placeholder = has_placeholder && vml_inherits_unpainted_bitmap(group, context);
1469 let mut bitmap_definition = false;
1470 let mut textbox_definition = false;
1471 for child in children
1472 .iter()
1473 .filter(|child| child.tag_name == "v:shapetype")
1474 {
1475 let xml = &group[child.start..child.end];
1476 if !bitmap_definition && vml_bitmap_rectangle_type(xml) {
1477 bitmap_definition = true;
1478 } else if !textbox_definition && vml_textbox_rectangle_type(xml) {
1479 textbox_definition = true;
1480 } else {
1481 return None;
1482 }
1483 }
1484 if bitmap_definition || textbox_definition {
1485 for child in &children {
1486 if child.tag_name == "v:shapetype" {
1487 continue;
1488 }
1489 let xml = &group[child.start..child.end];
1490 let tag = &xml[..crate::xml::tag_token_end(xml, 0)?];
1491 if !matches!(child.tag_name.as_str(), "v:shape" | "v:rect" | "v:line") {
1492 if get_attribute(tag, "type").is_some()
1493 && (child.tag_name != "w10:wrap" || !vml_no_wrap(xml) || multiple_root_wraps)
1494 {
1495 return None;
1496 }
1497 continue;
1498 }
1499 if let Some(kind) = get_attribute(tag, "type") {
1500 let supported = match kind.as_str() {
1501 "#_x0000_t75" => {
1502 xml.contains("<v:imagedata")
1503 || (skip_placeholder
1504 && vml_canvas_hit_test_shape(
1505 xml,
1506 (origin_x, origin_y),
1507 (extent_x, extent_y),
1508 ))
1509 }
1510 "#_x0000_t202" => {
1511 textbox_definition
1512 && xml.contains("<w:txbxContent")
1513 && ["filled", "stroked"].iter().all(|name| {
1514 get_attribute(tag, name).is_some_and(|value| {
1515 matches!(value.as_str(), "f" | "false" | "0")
1516 })
1517 })
1518 }
1519 _ => false,
1520 };
1521 if !supported
1522 || crate::xml::extract_direct_child_tag_ranges(xml)
1523 .iter()
1524 .any(|item| item.tag_name == "v:formulas")
1525 || ["path", "coordorigin", "coordsize", "adj"]
1526 .iter()
1527 .any(|name| get_attribute(tag, name).is_some())
1528 {
1529 return None;
1530 }
1531 }
1532 }
1533 }
1534 let metadata_type_reference = (bitmap_definition || textbox_definition)
1535 && children.iter().any(|child| {
1536 child.tag_name == "w10:wrap" && vml_no_wrap(&group[child.start..child.end])
1537 });
1538 let named_paint_present = || {
1539 children.iter().any(|child| {
1540 vml_has_active_named_paint(&child.tag_name, &group[child.start..child.end])
1541 })
1542 };
1543 if multiple_root_wraps && named_paint_present() {
1544 return None;
1545 }
1546 let expanded_support = nested_groups
1547 || metadata_type_reference
1548 || textbox_definition
1549 || skip_placeholder
1550 || children.iter().any(|child| {
1551 let xml = &group[child.start..child.end];
1552 child.tag_name == "o:lock"
1553 || vml_has_active_named_paint(&child.tag_name, xml)
1554 || vml_group_has_explicit_defaults(&child.tag_name, xml)
1555 || (child.tag_name == "v:rect" && vml_rect_has_default_stroke(xml))
1556 || (child.tag_name == "v:shape"
1557 && (xml.contains("<o:lock") || xml.contains("<w10:wrap")))
1558 });
1559 if expanded_support {
1560 let mut containing_part = false;
1561 for part in context
1562 .parts
1563 .values()
1564 .filter(|part| match owner_part_name {
1565 Some(owner) => part.name == owner,
1566 None => part.content.contains(group.as_str()),
1567 })
1568 {
1569 containing_part = true;
1570 for definition in extract_balanced_tag_blocks(&part.content, "v:shapetype") {
1571 let end = crate::xml::tag_token_end(&definition, 0)?;
1572 let kind = get_attribute(&definition[..end], "id");
1573 match kind.as_deref() {
1574 Some("_x0000_t75") if !vml_bitmap_rectangle_type(&definition) => return None,
1575 Some("_x0000_t202")
1576 if has_text_box && !vml_textbox_rectangle_type(&definition) =>
1577 {
1578 return None
1579 }
1580 _ => {}
1581 }
1582 }
1583 }
1584 if !containing_part {
1585 return None;
1586 }
1587 }
1588 if root_wrap.is_some_and(|wrap| !vml_no_wrap(wrap)) && named_paint_present() {
1589 let anchor = format!(r#"<v:shape style="{}">"#, escape_xml_text(&style_text));
1590 let floating = parse_vml_floating_anchor_from_run_xml(&anchor);
1591 if vml_inline_reference_hints(&style, false, floating.as_ref()) {
1592 return None;
1593 }
1594 }
1595 let mut elements = Vec::new();
1596 let mut browser_bitmap_payloads = true;
1597 let mut canvas = (origin_x, origin_y, origin_x + extent_x, origin_y + extent_y);
1598 for child in children {
1599 let xml = &group[child.start..child.end];
1600 if child.tag_name == "o:lock" && vml_editing_lock(xml) {
1601 continue;
1602 }
1603 if skip_placeholder
1604 && child.tag_name == "v:shape"
1605 && vml_canvas_hit_test_shape(xml, (origin_x, origin_y), (extent_x, extent_y))
1606 {
1607 continue;
1608 }
1609 if matches!(
1610 child.tag_name.as_str(),
1611 "w10:wrap" | "w10:anchorlock" | "v:shapetype"
1612 ) {
1613 continue;
1614 }
1615 if !matches!(child.tag_name.as_str(), "v:shape" | "v:rect" | "v:line") {
1616 return None;
1617 }
1618 let tag = &xml[..crate::xml::tag_token_end(xml, 0)?];
1619 let declarations =
1620 parse_css_style_declarations(&get_attribute(tag, "style").unwrap_or_default());
1621 if declarations.iter().any(|(key, value)| {
1622 !(matches!(
1623 key.as_str(),
1624 "position" | "left" | "top" | "width" | "height" | "z-index"
1625 ) || (key == "visibility" && value == "visible")
1626 || (key == "mso-wrap-style" && matches!(value.as_str(), "none" | "square"))
1627 || (key == "v-text-anchor" && value == "top"))
1628 }) {
1629 return None;
1630 }
1631 if get_attribute(tag, "opacity").is_some() {
1632 return None;
1633 }
1634 let z = declarations
1635 .get("z-index")
1636 .map(|value| vml_finite(value))
1637 .unwrap_or(Some(0.0))?;
1638 if child.tag_name != "v:shape" && xml.contains("<v:imagedata") {
1639 return None;
1640 }
1641 let mut dotted = false;
1642 for item in crate::xml_index::children(xml)? {
1643 let inner = &xml[item.start..item.end];
1644 match item.tag_name.as_str() {
1645 "v:imagedata" => {
1646 for attribute in [
1647 "croptop",
1648 "cropleft",
1649 "cropright",
1650 "cropbottom",
1651 "gain",
1652 "blacklevel",
1653 "gamma",
1654 "chromakey",
1655 "grayscale",
1656 "bilevel",
1657 ] {
1658 if get_attribute(inner, attribute).is_some() {
1659 return None;
1660 }
1661 }
1662 }
1663 "v:fill" => {
1664 if get_attribute(inner, "type")
1665 .as_deref()
1666 .is_some_and(|value| value != "solid")
1667 || ["opacity", "color", "color2", "on"].iter().any(|name| {
1668 get_attribute(inner, name).is_some()
1669 && (*name != "opacity"
1670 || !get_attribute(tag, "filled").is_some_and(|value| {
1671 matches!(value.as_str(), "f" | "false" | "0")
1672 }))
1673 })
1674 {
1675 return None;
1676 }
1677 }
1678 "v:formulas" => {}
1679 "v:path" => {
1680 if ["v", "fillok", "strokeok", "shadowok", "limo"]
1681 .iter()
1682 .any(|name| get_attribute(inner, name).is_some())
1683 {
1684 return None;
1685 }
1686 }
1687 "v:stroke" => {
1688 if [
1689 "on",
1690 "weight",
1691 "color",
1692 "color2",
1693 "opacity",
1694 "style",
1695 "linestyle",
1696 "miterlimit",
1697 "endcap",
1698 "filltype",
1699 "src",
1700 "imagesize",
1701 "imagealignshape",
1702 "startarrow",
1703 "startarrowwidth",
1704 "startarrowlength",
1705 "endarrow",
1706 "endarrowwidth",
1707 "endarrowlength",
1708 ]
1709 .iter()
1710 .any(|name| get_attribute(inner, name).is_some())
1711 || get_attribute(inner, "joinstyle").is_some_and(|value| value != "round")
1712 || !crate::xml_index::children(inner)?.is_empty()
1713 {
1714 return None;
1715 }
1716 match get_attribute(inner, "dashstyle")
1717 .as_deref()
1718 .unwrap_or("solid")
1719 {
1720 "solid" => {}
1721 "dot" => dotted = true,
1722 _ => return None,
1723 }
1724 }
1725 "o:lock" if vml_editing_lock(inner) => {}
1726 "w10:wrap" if vml_no_wrap(inner) => {}
1727 "v:textbox" => {
1728 if get_attribute(tag, "type").as_deref() != Some("#_x0000_t202")
1729 || get_attribute(tag, "path").is_some()
1730 || get_attribute(inner, "style").is_some_and(|style| {
1731 parse_css_style_declarations(&style)
1732 .iter()
1733 .any(|(key, value)| {
1734 match key.as_str() {
1735 "mso-fit-shape-to-text" => !matches!(value.as_str(), "t" | "true"),
1736 "v-text-anchor" => value != "top",
1737 "mso-next-textbox" => !value.strip_prefix('#')
1738 .filter(|target| !target.is_empty())
1739 .is_some_and(|target| get_attribute_ref(tag, "id") == Some(target)),
1740 _ => true,
1741 }
1742 })
1743 })
1744 {
1745 return None;
1746 }
1747 }
1748 _ => return None,
1749 }
1750 }
1751 let fill = if get_attribute(tag, "filled")
1752 .as_deref()
1753 .is_some_and(|v| matches!(v, "false" | "f" | "0"))
1754 {
1755 "none".to_string()
1756 } else {
1757 match get_attribute(tag, "fillcolor") {
1758 Some(value) => vml_paint_color(&value)?,
1759 None => "#ffffff".to_string(),
1760 }
1761 };
1762 let is_bitmap = xml.contains("<v:imagedata") && !xml.contains("<w:txbxContent");
1763 let stroke = if (is_bitmap && get_attribute(tag, "stroked").is_none())
1764 || get_attribute(tag, "stroked")
1765 .as_deref()
1766 .is_some_and(|v| matches!(v, "false" | "f" | "0"))
1767 {
1768 "none".to_string()
1769 } else {
1770 match get_attribute(tag, "strokecolor") {
1771 Some(value) => vml_paint_color(&value)?,
1772 None => "#000000".to_string(),
1773 }
1774 };
1775 let stroke_weight = get_attribute(tag, "strokeweight");
1776 if stroke != "none" && stroke_weight.is_none() && child.tag_name != "v:rect" {
1777 return None;
1778 }
1779 let stroke_width = stroke_weight
1780 .map(|value| vml_pixels(&value))
1781 .unwrap_or(Some(1.0))?;
1782 if stroke_width < 0.0 {
1783 return None;
1784 }
1785 let dash = if dotted {
1786 format!(
1787 r#" stroke-dasharray="{} {}""#,
1788 stroke_width,
1789 stroke_width * 2.0
1790 )
1791 } else {
1792 String::new()
1793 };
1794 let paint = format!(
1795 r#"fill="{fill}" fill-rule="evenodd" stroke="{stroke}" stroke-width="{stroke_width}" stroke-linejoin="round" vector-effect="non-scaling-stroke"{dash}"#
1796 );
1797 let bounds;
1798 let element = if child.tag_name == "v:line" {
1799 let (x1, y1) = vml_pair(&get_attribute(tag, "from")?)?;
1800 let (x2, y2) = vml_pair(&get_attribute(tag, "to")?)?;
1801 bounds = (x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2));
1802 format!(r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" {paint}/>"#)
1803 } else {
1804 let x = declarations
1805 .get("left")
1806 .map(|value| vml_finite(value))
1807 .unwrap_or(Some(0.0))?;
1808 let y = declarations
1809 .get("top")
1810 .map(|value| vml_finite(value))
1811 .unwrap_or(Some(0.0))?;
1812 let w = vml_finite(declarations.get("width")?)?;
1813 let h = vml_finite(declarations.get("height")?)?;
1814 if w <= 0.0 || h <= 0.0 {
1815 return None;
1816 }
1817 if child.tag_name == "v:rect" {
1818 bounds = (x, y, x + w, y + h);
1819 format!(r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" {paint}/>"#)
1820 } else if xml.contains("<w:txbxContent") {
1821 if get_attribute(tag, "type").as_deref() != Some("#_x0000_t202")
1822 || xml.matches("<v:textbox").count() != 1
1823 {
1824 return None;
1825 }
1826 let paragraphs = parse_text_box_paragraphs(xml, context);
1827 let physical_width = w * width / extent_x;
1828 let physical_height = h * height / extent_y;
1829 if !physical_width.is_finite() || !physical_height.is_finite() {
1830 return None;
1831 }
1832 let mut layout = parse_text_box_layout(xml);
1833 if let Some(layout) = layout.as_mut() {
1834 if let Some(inset) = regex_capture_tag(xml, r"(?i)<v:textbox\b[^>]*>")
1835 .and_then(|tag| get_attribute(&tag, "inset"))
1836 {
1837 for (padding, value) in [
1838 &mut layout.padding_left_px,
1839 &mut layout.padding_top_px,
1840 &mut layout.padding_right_px,
1841 &mut layout.padding_bottom_px,
1842 ]
1843 .into_iter()
1844 .zip(inset.split(','))
1845 {
1846 if vml_finite(value) == Some(0.0) {
1847 *padding = Some(0.0);
1848 }
1849 }
1850 }
1851 }
1852 let text = render_bounded_text_box_svg(
1853 ¶graphs,
1854 physical_width,
1855 physical_height,
1856 layout.as_ref(),
1857 );
1858 let text_width = physical_width.round().clamp(1.0, 2400.0);
1859 let text_height = physical_height.round().clamp(1.0, 2400.0);
1860 bounds = (x, y, x + w, y + h);
1861 format!(
1862 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" {paint}/><svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="0 0 {text_width} {text_height}" preserveAspectRatio="none" overflow="hidden">{text}</svg>"#
1863 )
1864 } else if is_bitmap {
1865 if xml.matches("<v:imagedata").count() != 1
1866 || get_attribute(tag, "path").is_some()
1867 || get_attribute(tag, "type").as_deref() != Some("#_x0000_t75")
1868 || stroke != "none"
1869 || get_attribute(tag, "fillcolor").is_some()
1870 {
1871 return None;
1872 }
1873 let image = parse_run_image_block(xml, context)?;
1874 let image_src = image.src.as_deref()?;
1875 browser_bitmap_payloads &= vml_browser_bitmap_src(
1876 image_src,
1877 image.part_name.as_deref(),
1878 image.content_type.as_deref(),
1879 );
1880 if expanded_support && !browser_bitmap_payloads {
1881 return None;
1882 }
1883 let src = escape_xml_text(image_src);
1884 bounds = (x, y, x + w, y + h);
1885 format!(
1886 r#"<image href="{src}" x="{x}" y="{y}" width="{w}" height="{h}" preserveAspectRatio="none"/>"#
1887 )
1888 } else {
1889 let path = vml_svg_path(&get_attribute(tag, "path")?)?;
1890 let (cx, cy) =
1891 vml_pair(&get_attribute(tag, "coordorigin").unwrap_or_else(|| "0,0".into()))?;
1892 let (cw, ch) = vml_pair(&get_attribute(tag, "coordsize")?)?;
1893 if cw < 0.0 || ch < 0.0 || (cw == 0.0 && ch == 0.0) {
1894 return None;
1895 }
1896 if (cw == 0.0 && (fill != "none" || !vml_path_stays_on_axis(&path, 0, cx)))
1897 || (ch == 0.0 && (fill != "none" || !vml_path_stays_on_axis(&path, 1, cy)))
1898 {
1899 return None;
1900 }
1901 let scale_x = if cw == 0.0 { 1.0 } else { w / cw };
1902 let scale_y = if ch == 0.0 { 1.0 } else { h / ch };
1903 if !scale_x.is_finite() || !scale_y.is_finite() {
1904 return None;
1905 }
1906 bounds = vml_path_bounds(&path, x, y, scale_x, scale_y, cx, cy)?;
1907 format!(
1908 r#"<path d="{path}" transform="translate({x} {y}) scale({scale_x} {scale_y}) translate({} {})" {paint}/>"#,
1909 -cx, -cy
1910 )
1911 }
1912 };
1913 let stroke_radius = if stroke == "none" {
1914 0.0
1915 } else {
1916 stroke_width * 0.5
1917 };
1918 let padding_x = stroke_radius * extent_x / width;
1919 let padding_y = stroke_radius * extent_y / height;
1920 canvas.0 = canvas.0.min(bounds.0 - padding_x);
1921 canvas.1 = canvas.1.min(bounds.1 - padding_y);
1922 canvas.2 = canvas.2.max(bounds.2 + padding_x);
1923 canvas.3 = canvas.3.max(bounds.3 + padding_y);
1924 elements.push((z, element));
1925 }
1926 elements.sort_by(|left, right| left.0.total_cmp(&right.0));
1927 let body = elements
1928 .into_iter()
1929 .map(|(_, element)| element)
1930 .collect::<String>();
1931 let canvas_width = canvas.2 - canvas.0;
1932 let canvas_height = canvas.3 - canvas.1;
1933 let mut width_px = if canvas_width == extent_x {
1934 width
1935 } else {
1936 canvas_width * width / extent_x
1937 };
1938 let mut height_px = if canvas_height == extent_y {
1939 height
1940 } else {
1941 canvas_height * height / extent_y
1942 };
1943 if !width_px.is_finite() || !height_px.is_finite() {
1944 return None;
1945 }
1946 let anchor = format!(r#"<v:shape style="{}">"#, escape_xml_text(&style_text));
1947 let mut floating = parse_vml_floating_anchor_from_run_xml(&anchor);
1948 if let Some(anchor) = floating.as_mut() {
1949 if anchor.horizontal_align.is_none() {
1950 if let Some(value) = style.get("margin-left") {
1951 anchor.x_px = Some(vml_pixels(value)?);
1952 }
1953 }
1954 if anchor.vertical_align.is_none() {
1955 if let Some(value) = style.get("margin-top") {
1956 anchor.y_px = Some(vml_pixels(value)?);
1957 }
1958 }
1959 }
1960 if expanded_support && vml_inline_reference_hints(&style, false, floating.as_ref()) {
1961 if let Some(wrap) = root_wrap {
1962 if !multiple_root_wraps && vml_no_wrap(wrap) {
1963 let anchor = floating.as_mut()?;
1964 anchor.horizontal_relative_to = None;
1965 anchor.vertical_relative_to = None;
1966 anchor.wrap_type = Some(ImageWrapType::WrapNone);
1967 }
1968 } else {
1969 floating = None;
1970 }
1971 }
1972 if !multiple_root_wraps {
1973 if let Some(wrap) = root_wrap {
1974 if let Some(declared) = parse_vml_floating_anchor_from_style(wrap, &style_text) {
1975 if declared.wrap_type.is_some() || declared.wrap_text.is_some() {
1976 let anchor = floating.get_or_insert_with(|| declared.clone());
1977 anchor.wrap_type = declared.wrap_type;
1978 anchor.wrap_text = declared.wrap_text;
1979 }
1980 }
1981 }
1982 }
1983 let paint_bounds = if canvas != (origin_x, origin_y, origin_x + extent_x, origin_y + extent_y) {
1984 let x_px = (canvas.0 - origin_x) * width / extent_x;
1985 let y_px = (canvas.1 - origin_y) * height / extent_y;
1986 if !x_px.is_finite() || !y_px.is_finite() || width_px <= 0.0 || height_px <= 0.0 {
1987 return None;
1988 }
1989 if let Some(anchor) = floating.as_mut() {
1990 if anchor.horizontal_align.is_some() || anchor.vertical_align.is_some() {
1991 return None;
1992 }
1993 *anchor.x_px.as_mut()? += x_px;
1994 *anchor.y_px.as_mut()? += y_px;
1995 None
1996 } else {
1997 let x_px = if width == extent_x { canvas.0 - origin_x } else { x_px };
1998 let y_px = if height == extent_y { canvas.1 - origin_y } else { y_px };
1999 if width == extent_x {
2000 width_px = canvas_width;
2001 }
2002 if height == extent_y {
2003 height_px = canvas_height;
2004 }
2005 Some(Box::new(ImagePaintBounds {
2006 x_px,
2007 y_px,
2008 width_px,
2009 height_px,
2010 layout_width_px: width,
2011 layout_height_px: height,
2012 }))
2013 }
2014 } else {
2015 None
2016 };
2017 if !has_text_box && bitmap_count == 1 && paint_bounds.is_none() {
2018 return None;
2019 }
2020 if paint_bounds.is_some() && !browser_bitmap_payloads {
2021 return None;
2022 }
2023 let svg = format!(
2024 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width_px}" height="{height_px}" viewBox="{} {} {canvas_width} {canvas_height}" preserveAspectRatio="none">{body}</svg>"#,
2025 canvas.0, canvas.1
2026 );
2027 Some(ImageRunNode {
2028 model_3d: None,
2029 presentation: None,
2030 chart_data: None,
2031 chart_part_name: None,
2032 width_px: Some(if paint_bounds.is_some() { width } else { width_px }),
2033 height_px: Some(if paint_bounds.is_some() { height } else { height_px }),
2034 paint_bounds,
2035 r#type: ImageRunNodeType::Image,
2036 block_id: None,
2037 content_nodes: None,
2038 semantic_kind: None,
2039 math_ml: None,
2040 src: Some(svg_data_uri(&svg)),
2041 alt: has_text_box.then(|| resolve_doc_pr_alt(run_xml).unwrap_or_else(|| "Text box".into())),
2042 part_name: None,
2043 content_type: Some("image/svg+xml".to_string()),
2044 data: None,
2045 source_xml: Some(run_xml.to_string()),
2046 crop: None,
2047 css_filter: None,
2048 css_opacity: None,
2049 floating,
2050 synthetic_text_box: has_text_box.then_some(true),
2051 text_box_text: None,
2052 })
2053}
2054
2055pub fn parse_run_image_block(
2056 run_xml: &str,
2057 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2058) -> Option<ImageRunNode> {
2059 let mut image = parse_run_image_block_inner(run_xml, context)?;
2060 let transform_applied = image.presentation.as_ref().is_some_and(|value| value.transform_applied);
2061 image.presentation = super::image_properties::parse_image_presentation(
2062 image.source_xml.as_deref().unwrap_or(run_xml), context,
2063 ).or(image.presentation);
2064 if let Some(presentation) = image.presentation.as_mut() {
2065 presentation.transform_applied = transform_applied;
2066 }
2067 if rust_office_art::model3d::model3d_xml(image.source_xml.as_deref().unwrap_or(run_xml)).is_some() {
2068 image.semantic_kind = Some("model_3d".into());
2069 }
2070 Some(image)
2071}
2072
2073fn parse_run_image_block_inner(
2074 run_xml: &str,
2075 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2076) -> Option<ImageRunNode> {
2077 let normalized_run_xml = prefer_alternate_content_choice(run_xml);
2078 let mut active_run_xml = normalized_run_xml.as_ref();
2079 let _drawing_index = if active_run_xml.len() > 8192
2080 && crate::xml_index::tag(active_run_xml, "w:drawing").is_none()
2081 {
2082 Some(crate::xml_index::scope(
2083 std::iter::once(("", active_run_xml)),
2084 Default::default(),
2085 ))
2086 } else {
2087 None
2088 };
2089 if let Some(image) = vml_group_image(active_run_xml, context) {
2090 return Some(image);
2091 }
2092 let (mut width_px, mut height_px, mut floating) = resolve_run_image_layout(active_run_xml);
2093 let mut alt = resolve_doc_pr_alt(active_run_xml);
2094 let mut chart_relationship_id =
2095 chart_relationship(active_run_xml);
2096 let mut relationship_id = resolve_preferred_drawing_relationship_id(active_run_xml, context)
2097 .or_else(|| chart_relationship_id.clone());
2098
2099 if relationship_id.is_none() && normalized_run_xml != run_xml {
2100 active_run_xml = run_xml;
2101 chart_relationship_id =
2102 chart_relationship(active_run_xml);
2103 relationship_id = resolve_preferred_drawing_relationship_id(active_run_xml, context)
2104 .or_else(|| chart_relationship_id.clone());
2105 (width_px, height_px, floating) = resolve_run_image_layout(active_run_xml);
2106 alt = resolve_doc_pr_alt(active_run_xml);
2107 }
2108
2109 let part_name = relationship_id
2110 .as_deref()
2111 .and_then(|id| context.relationships.get(id).cloned())
2112 .or_else(|| {
2113 let tag = regex_capture_tag(active_run_xml, r"(?i)<v:(?:imagedata|fill)\b[^>]*>")?;
2114 let target = get_attribute(&tag, "src")?;
2115 external_image_source(&target).or_else(|| context.binary_assets.contains_key(&target).then_some(target))
2116 });
2117 if relationship_id.is_some() && part_name.is_none() {
2118 context.push_warning(format!(
2119 "Missing relationship target for {}",
2120 relationship_id.as_deref().unwrap_or_default()
2121 ));
2122 }
2123
2124 let content_type = part_name
2125 .as_deref()
2126 .and_then(|name| content_type_for_part(name, &context.content_types));
2127 let binary = part_name
2128 .as_deref()
2129 .and_then(|name| context.binary_assets.get(name).map(Vec::as_slice));
2130 let mut crop = parse_drawing_image_crop(active_run_xml);
2131 let mut css_filter = parse_drawing_image_css_filter(active_run_xml);
2132 let css_opacity = parse_drawing_image_opacity(active_run_xml);
2133
2134 let likely_chart_part = chart_relationship_id.is_some()
2135 || part_name
2136 .as_deref()
2137 .is_some_and(|name| name.contains("/charts/"))
2138 || matches!(content_type.as_deref(), Some(CHART_CONTENT_TYPE | "application/vnd.ms-office.chartex+xml"));
2139
2140 let chart_part_name = if likely_chart_part {
2141 chart_relationship_id
2142 .as_ref()
2143 .and_then(|id| context.relationships.get(id))
2144 .or(part_name.as_ref())
2145 .cloned()
2146 } else {
2147 None
2148 };
2149
2150 let diagram = super::smartart::render_diagram(
2151 active_run_xml, width_px.unwrap_or(640.0), height_px.unwrap_or(360.0), context,
2152 );
2153 let is_diagram = diagram.is_some();
2154 let standalone_shape_svg = diagram.or_else(||
2155 render_standalone_word_shape_svg(active_run_xml, width_px, height_px, context));
2156 let contains_grouped_or_standalone_shape =
2157 is_diagram || re::get_unchecked(r"(?i)<wpg:wgp\b|<wps:wsp\b").is_match(active_run_xml);
2158 let contains_text_box_content =
2159 re::get_unchecked(r"(?i)<w:txbxContent\b").is_match(active_run_xml);
2160
2161 if contains_text_box_content && standalone_shape_svg.is_none() {
2165 let text_box_paragraphs = parse_text_box_paragraphs(active_run_xml, context);
2166 if !text_box_paragraphs.is_empty() {
2167 let text_box_src = svg_data_uri(&render_text_box_svg(
2168 &text_box_paragraphs,
2169 width_px,
2170 height_px,
2171 parse_text_box_layout(active_run_xml).as_ref(),
2172 ));
2173 return Some(ImageRunNode {
2174 model_3d: None,
2175 presentation: None,
2176 chart_data: None,
2177 chart_part_name: None,
2178 paint_bounds: None,
2179 r#type: ImageRunNodeType::Image,
2180 block_id: None,
2181 content_nodes: None,
2182 semantic_kind: None,
2183 math_ml: None,
2184 src: Some(text_box_src),
2185 alt: Some(alt.unwrap_or_else(|| "Text box".to_string())),
2186 width_px,
2187 height_px,
2188 part_name: None,
2189 content_type: Some("image/svg+xml".to_string()),
2190 data: None,
2191 source_xml: Some(active_run_xml.to_string()),
2192 crop,
2193 css_filter,
2194 css_opacity,
2195 floating,
2196 synthetic_text_box: Some(true),
2197 text_box_text: None,
2198 });
2199 }
2200 }
2201
2202 if standalone_shape_svg.is_some()
2203 && (contains_grouped_or_standalone_shape || relationship_id.is_none())
2204 {
2205 let svg = standalone_shape_svg.as_deref().unwrap_or_default();
2206 if svg.contains("data-office-art=\"true\"") {
2207 width_px = width_px.map(|width| width.max(1.0));
2208 height_px = height_px.map(|height| height.max(1.0));
2209 }
2210 if !re::get_unchecked(r"(?i)<wpg:wgp\b").is_match(active_run_xml)
2211 && !svg.contains("data-office-art=\"true\"") {
2212 let transform = extract_balanced_tag_blocks(active_run_xml, "a:xfrm")
2213 .into_iter()
2214 .next()
2215 .unwrap_or_default();
2216 let angle = get_attribute(&transform, "rot")
2217 .and_then(|v| v.parse::<f64>().ok())
2218 .filter(|v| v.is_finite())
2219 .unwrap_or(0.0)
2220 / 60000.0;
2221 let svg_tag = regex_capture_tag(svg, r"<svg\b[^>]*>").unwrap_or_default();
2222 let source_width =
2223 width_px.or_else(|| get_attribute(&svg_tag, "width").and_then(|v| v.parse().ok()));
2224 let source_height = height_px
2225 .or_else(|| get_attribute(&svg_tag, "height").and_then(|v| v.parse().ok()));
2226 if let (Some(old_width), Some(old_height)) = (source_width, source_height) {
2227 let (sin, cos) = angle.to_radians().sin_cos();
2228 let width = (old_width * cos.abs() + old_height * sin.abs()).max(1.0);
2229 let height = (old_width * sin.abs() + old_height * cos.abs()).max(1.0);
2230 if let Some(anchor) = floating.as_mut() {
2231 if let Some(x) = anchor.x_px.as_mut() {
2232 *x += (old_width - width) / 2.0;
2233 }
2234 if let Some(y) = anchor.y_px.as_mut() {
2235 *y += (old_height - height) / 2.0;
2236 }
2237 }
2238 width_px = Some(width);
2239 height_px = Some(height);
2240 }
2241 }
2242 let paint_bounds = width_px.zip(height_px).and_then(|(width, height)| {
2243 drawing_group_paint_bounds(svg, width, height)
2244 });
2245 return Some(ImageRunNode {
2246 model_3d: None,
2247 presentation: contains_grouped_or_standalone_shape.then(|| Box::new(crate::model::ImagePresentation {
2248 transform_applied: true,
2249 ..Default::default()
2250 })),
2251 chart_data: None,
2252 chart_part_name: None,
2253 paint_bounds,
2254 r#type: ImageRunNodeType::Image,
2255 block_id: None,
2256 content_nodes: None,
2257 semantic_kind: is_diagram.then(|| "diagram".to_string()),
2258 math_ml: None,
2259 src: Some(svg_data_uri(svg)),
2260 alt: Some(alt.unwrap_or_else(|| {
2261 if contains_text_box_content {
2262 "Text box".to_string()
2263 } else {
2264 "Shape".to_string()
2265 }
2266 })),
2267 width_px,
2268 height_px,
2269 part_name: None,
2270 content_type: Some("image/svg+xml".to_string()),
2271 data: None,
2272 source_xml: Some(active_run_xml.to_string()),
2273 crop,
2274 css_filter,
2275 css_opacity,
2276 floating,
2277 synthetic_text_box: if contains_text_box_content {
2278 Some(true)
2279 } else {
2280 None
2281 },
2282 text_box_text: None,
2283 });
2284 }
2285
2286 if likely_chart_part {
2287 if let Some(name) = chart_part_name
2288 .as_deref()
2289 .filter(|name| !context.parts.contains_key(*name))
2290 {
2291 context.push_warning(format!("Missing chart part {name}"));
2292 }
2293 if let Some((chart_src, chart_data)) = chart_part_name
2294 .as_deref()
2295 .and_then(|name| crate::chart::render_chart_part(name, context, width_px, height_px))
2296 {
2297 return Some(ImageRunNode {
2298 model_3d: None,
2299 presentation: None,
2300 chart_data: Some(Box::new(chart_data)),
2301 chart_part_name,
2302 paint_bounds: None,
2303 r#type: ImageRunNodeType::Image,
2304 block_id: None,
2305 content_nodes: None,
2306 semantic_kind: None,
2307 math_ml: None,
2308 src: Some(chart_src),
2309 alt: Some(alt.unwrap_or_else(|| "Chart".to_string())),
2310 width_px,
2311 height_px,
2312 part_name: None,
2313 content_type: Some("image/svg+xml".to_string()),
2314 data: None,
2315 source_xml: Some(active_run_xml.to_string()),
2316 crop,
2317 css_filter,
2318 css_opacity,
2319 floating,
2320 synthetic_text_box: None,
2321 text_box_text: None,
2322 });
2323 }
2324 }
2325
2326 if relationship_id.is_none() && part_name.is_none()
2327 && rust_office_art::model3d::model3d_xml(active_run_xml).is_none() {
2328 let text_box_paragraphs = parse_text_box_paragraphs(active_run_xml, context);
2329 if text_box_paragraphs.is_empty() {
2330 return None;
2331 }
2332
2333 let text_box_src = svg_data_uri(&render_text_box_svg(
2334 &text_box_paragraphs,
2335 width_px,
2336 height_px,
2337 parse_text_box_layout(active_run_xml).as_ref(),
2338 ));
2339 return Some(ImageRunNode {
2340 model_3d: None,
2341 presentation: None,
2342 chart_data: None,
2343 chart_part_name: None,
2344 paint_bounds: None,
2345 r#type: ImageRunNodeType::Image,
2346 block_id: None,
2347 content_nodes: None,
2348 semantic_kind: None,
2349 math_ml: None,
2350 src: Some(text_box_src),
2351 alt: Some(alt.unwrap_or_else(|| "Text box".to_string())),
2352 width_px,
2353 height_px,
2354 part_name: None,
2355 content_type: Some("image/svg+xml".to_string()),
2356 data: None,
2357 source_xml: Some(active_run_xml.to_string()),
2358 crop,
2359 css_filter,
2360 css_opacity,
2361 floating,
2362 synthetic_text_box: Some(true),
2363 text_box_text: None,
2364 });
2365 }
2366
2367 let mut src: Option<String> = part_name.as_deref().and_then(external_image_source);
2368 let mut resolved_content_type = content_type.clone();
2369 let mut resolved_css_opacity = css_opacity;
2370 if let Some(binary) = binary {
2371 let mime_type = part_name
2372 .as_deref()
2373 .and_then(|name| content_type_for_part(name, &context.content_types))
2374 .or(content_type.clone())
2375 .unwrap_or_else(|| "application/octet-stream".to_string());
2376 if is_windows_metafile_content_type(Some(&mime_type), part_name.as_deref()) {
2377 src = windows_metafile_to_svg_data_uri(binary);
2378 if src.is_some() {
2379 resolved_content_type = Some("image/svg+xml".to_string());
2380 } else {
2381 src = rasterize_windows_metafile_to_png_data_uri(binary, part_name.as_deref());
2382 if src.is_some() {
2383 resolved_content_type = Some("image/png".to_string());
2384 }
2385 }
2386 }
2387
2388 if src.is_none() {
2389 src = Some(super::util::base64_data_uri(&mime_type, binary));
2390 resolved_content_type = Some(mime_type);
2391 }
2392 }
2393
2394 let mut paint_bounds = None;
2395 let mut transform_applied = false;
2396 if let Some(source) = src.as_deref() {
2397 let paint = width_px.zip(height_px).and_then(|(width, height)| {
2398 super::picture::render_picture(active_run_xml, source, width, height, context)
2399 .or_else(|| super::vml_picture::render_vml_picture(active_run_xml, source, width, height, context))
2400 });
2401 if let Some(paint) = paint {
2402 transform_applied = true;
2403 src = Some(paint.src);
2404 resolved_content_type = Some("image/svg+xml".to_owned());
2405 paint_bounds = paint.paint_bounds;
2406 crop = None;
2407 css_filter = None;
2408 resolved_css_opacity = None;
2409 } else if let Some(duotone) = drawing_duotone_svg(active_run_xml, source, context) {
2410 src = Some(duotone);
2411 resolved_content_type = Some("image/svg+xml".to_owned());
2412 } else if matches!(resolved_content_type.as_deref(), Some("image/tiff" | "image/tif")) {
2413 if let Some(normalized) = rust_office_art::normalize_raster(source) {
2414 src = Some(normalized);
2415 resolved_content_type = Some("image/png".to_owned());
2416 }
2417 }
2418 }
2419 if !transform_applied && super::picture::has_picture_effects(active_run_xml) {
2420 context.push_warning("OfficeArt picture appearance retained but not fully rendered");
2421 }
2422
2423 Some(ImageRunNode {
2424 model_3d: None,
2425 presentation: transform_applied.then(|| Box::new(crate::model::ImagePresentation {
2426 transform_applied: true,
2427 ..Default::default()
2428 })),
2429 chart_data: chart_part_name.as_ref().and_then(|name| {
2430 rust_office_charts::parse_chart_xml(&context.parts.get(name)?.content).map(Box::new)
2431 }),
2432 chart_part_name,
2433 paint_bounds,
2434 r#type: ImageRunNodeType::Image,
2435 block_id: None,
2436 content_nodes: None,
2437 semantic_kind: None,
2438 math_ml: None,
2439 src,
2440 alt,
2441 width_px,
2442 height_px,
2443 part_name,
2444 content_type: resolved_content_type,
2445 data: binary.map(|bytes| bytes.to_vec()),
2446 source_xml: Some(active_run_xml.to_string()),
2447 crop,
2448 css_filter,
2449 css_opacity: resolved_css_opacity,
2450 floating,
2451 synthetic_text_box: None,
2452 text_box_text: None,
2453 })
2454}
2455
2456pub fn parse_run_images(
2457 run_xml: &str,
2458 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2459) -> Vec<ImageRunNode> {
2460 parse_run_image_tokens(run_xml, context)
2461 .into_iter()
2462 .map(|(_, image)| image)
2463 .collect()
2464}
2465
2466fn alternate_chart_previews(run_xml: &str) -> std::collections::HashMap<usize, (&str, &str)> {
2467 let mut previews = std::collections::HashMap::new();
2468 let mut removed = 0;
2470 for range in crate::xml::extract_balanced_tag_ranges(run_xml, "mc:AlternateContent") {
2471 let alternate = &run_xml[range.start..range.end];
2472 let selected = prefer_alternate_content_choice(alternate);
2473 let offset = range.start - removed;
2474 removed += alternate.len().saturating_sub(selected.len());
2475 let Some(choice) = crate::xml::direct_child_tag(alternate, "mc:Choice") else {
2476 continue;
2477 };
2478 let Some(drawing) = crate::xml::direct_child_tag(choice, "w:drawing") else {
2479 continue;
2480 };
2481 if selected.trim() != drawing {
2482 continue;
2483 }
2484 let Some(fallback) = crate::xml::direct_child_tag(alternate, "mc:Fallback") else {
2485 continue;
2486 };
2487 let ranges = crate::xml::extract_direct_child_tag_ranges(fallback);
2488 if let [item] = ranges.as_slice() {
2489 if matches!(item.tag_name.as_str(), "w:drawing" | "w:pict") {
2490 let start = offset + selected.len() - selected.trim_start().len();
2491 previews.insert(start, (alternate, &fallback[item.start..item.end]));
2492 }
2493 }
2494 }
2495 previews
2496}
2497
2498pub fn parse_run_image_tokens(
2499 run_xml: &str,
2500 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2501) -> Vec<(usize, ImageRunNode)> {
2502 if !memchr::memchr_iter(b'<', run_xml.as_bytes()).any(|position| {
2503 let tag = &run_xml[position + 1..];
2504 ["a:", "wp:", "v:", "c:", "cx:", "wps:", "wpg:", "w:txbxContent"]
2505 .iter()
2506 .any(|prefix| super::scan::starts_with_ignore_ascii_case(tag, prefix))
2507 }) {
2508 return Vec::new();
2509 }
2510 let original_run = run_xml;
2511 let active_run_xml = prefer_alternate_content_choice(run_xml);
2512 let run_xml = active_run_xml.as_ref();
2513 let candidate_ranges = extract_balanced_tag_blocks_in_order(
2514 run_xml,
2515 &["mc:AlternateContent", "w:drawing", "w:pict", "w:object"],
2516 );
2517 let candidate_xml_blocks: Vec<(usize, &str)> = if candidate_ranges.is_empty() {
2518 vec![(0, run_xml)]
2519 } else {
2520 candidate_ranges
2521 .iter()
2522 .map(|range| (range.start, &run_xml[range.start..range.end]))
2523 .collect()
2524 };
2525
2526 let mut images = Vec::new();
2527 let mut previews = None;
2528 for (source_start, candidate_xml) in candidate_xml_blocks {
2529 let mut image = parse_run_image_block(candidate_xml, context);
2530 let requires_preview = image.as_ref().is_none_or(|image| {
2531 image.src.is_none() || matches!(image.content_type.as_deref(),
2532 Some("image/wmf" | "image/x-wmf" | "image/emf" | "image/x-emf" | "image/x-pict" | "image/vnd.ms-photo"))
2533 });
2534 if requires_preview && run_xml.len() < original_run.len() {
2535 if let Some(&(alternate, fallback)) = previews
2536 .get_or_insert_with(|| alternate_chart_previews(original_run))
2537 .get(&source_start)
2538 {
2539 if let Some(mut preview) = parse_run_image_block(fallback, context).filter(|image| image.src.is_some()) {
2540 if let Some(original) = image {
2541 preview.chart_data = original.chart_data;
2542 preview.chart_part_name = original.chart_part_name;
2543 }
2544 preview.source_xml = Some(alternate.to_owned());
2545 let transform_applied = preview.presentation.as_ref().is_some_and(|value| value.transform_applied);
2546 preview.presentation = super::image_properties::parse_image_presentation(alternate, context);
2547 if let Some(presentation) = preview.presentation.as_mut() {
2548 presentation.transform_applied = transform_applied;
2549 }
2550 super::ink::apply_cached_ink_metadata(alternate, &mut preview, context);
2551 image = Some(preview);
2552 }
2553 }
2554 }
2555 if let Some(image) = image.as_mut() {
2556 if let Some(&(alternate, _)) = previews.get_or_insert_with(|| alternate_chart_previews(original_run)).get(&source_start)
2557 .filter(|(alternate, _)| rust_office_art::model3d::model3d_xml(alternate).is_some()) {
2558 image.source_xml = Some(alternate.to_owned());
2559 image.semantic_kind = Some("model_3d".into());
2560 let transform_applied = image.presentation.as_ref().is_some_and(|value| value.transform_applied);
2561 image.presentation = super::image_properties::parse_image_presentation(alternate, context);
2562 if let Some(presentation) = image.presentation.as_mut() {
2563 presentation.transform_applied = transform_applied;
2564 }
2565 }
2566 }
2567 if let Some(image) = image {
2568 images.push((source_start, image));
2569 }
2570 }
2571
2572 images
2573}
2574
2575#[cfg(test)]
2576mod tests {
2577 use super::*;
2578
2579 fn rectangular_bitmap_type() -> &'static str {
2580 r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:stroke joinstyle="miter"/><v:formulas><v:f eqn="if lineDrawn pixelLineWidth 0"/><v:f eqn="sum @0 1 0"/><v:f eqn="sum 0 0 @1"/><v:f eqn="prod @2 1 2"/><v:f eqn="prod @3 21600 pixelWidth"/><v:f eqn="prod @3 21600 pixelHeight"/><v:f eqn="sum @0 0 1"/><v:f eqn="prod @6 1 2"/><v:f eqn="prod @7 21600 pixelWidth"/><v:f eqn="sum @8 21600 0"/><v:f eqn="prod @7 21600 pixelHeight"/><v:f eqn="sum @10 21600 0"/></v:formulas></v:shapetype>"#
2581 }
2582
2583 #[test]
2584 fn bitmap_type_declarations_reject_geometry_and_paint_overrides() {
2585 let xml = rectangular_bitmap_type();
2586 assert!(vml_bitmap_rectangle_type(xml));
2587 for changed in [
2588 xml.replace("filled=\"f\"", "filled=\"t\""),
2589 xml.replace("stroked=\"f\"", "stroked=\"t\""),
2590 xml.replace("21600 pixelWidth", "10800 pixelWidth"),
2591 xml.replace("<v:shapetype ", "<v:shapetype opacity=\"0.5\" "),
2592 xml.replace("</v:shapetype>", "<v:fill color=\"red\"/></v:shapetype>"),
2593 ] {
2594 assert!(!vml_bitmap_rectangle_type(&changed));
2595 }
2596 }
2597
2598 #[test]
2599 fn rectangular_textbox_declarations_reject_inherited_effects() {
2600 let xml = r#"<v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe"><v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>"#;
2601 assert!(vml_textbox_rectangle_type(xml));
2602 for changed in [
2603 xml.replace("<v:shapetype ", "<v:shapetype fillcolor=\"red\" "),
2604 xml.replace("21600r21600", "21600r10800"),
2605 xml.replace("joinstyle=\"miter\"", "joinstyle=\"round\""),
2606 xml.replace("</v:shapetype>", "<v:shadow on=\"t\"/></v:shapetype>"),
2607 ] {
2608 assert!(!vml_textbox_rectangle_type(&changed));
2609 }
2610 }
2611
2612 #[test]
2613 fn identity_groups_preserve_leaf_order_and_overflow_geometry() {
2614 let xml = r#"<v:group><v:rect id="first"/><v:group id="座標" alt="" style="position:absolute;left:10;top:20;width:100;height:200" coordorigin="10,20" coordsize="100,200"><v:shape id="middle" style="position:absolute;left:-5;top:12;width:140;height:230"/><v:group style="position:absolute;left:30;top:40;width:50;height:60" coordorigin="30,40" coordsize="50,60"><v:line id="last" from="-20,70" to="240,70"/></v:group></v:group></v:group>"#;
2615 let leaves = vml_group_leaves(xml).unwrap();
2616 assert_eq!(leaves.len(), 3);
2617 let ids: Vec<_> = leaves
2618 .iter()
2619 .map(|range| get_attribute(&xml[range.start..range.end], "id").unwrap())
2620 .collect();
2621 assert_eq!(ids, ["first", "middle", "last"]);
2622 let last = &xml[leaves[2].start..leaves[2].end];
2623 assert_eq!(get_attribute(last, "from").as_deref(), Some("-20,70"));
2624 assert_eq!(get_attribute(last, "to").as_deref(), Some("240,70"));
2625 for changed in [
2626 xml.replace("coordorigin=\"10,20\"", "coordorigin=\"0,0\""),
2627 xml.replace("coordsize=\"100,200\"", "coordsize=\"200,200\""),
2628 xml.replace("alt=\"\"", "alt=\"label\""),
2629 xml.replace("left:10", "rotation:90;left:10"),
2630 xml.replace("left:-5", "z-index:1;left:-5"),
2631 xml.replace("alt=\"\"", "filled=\"t\" alt=\"\""),
2632 ] {
2633 assert!(vml_group_leaves(&changed).is_none());
2634 }
2635 }
2636
2637 #[test]
2638 fn canvas_hit_testing_requires_exact_nonvisual_structure_and_type() {
2639 let xml = r##"<v:shape type="#_x0000_t75" style="position:absolute;width:100;height:80;visibility:visible"><v:fill o:detectmouseclick="t"/><v:path o:connecttype="none"/></v:shape>"##;
2640 assert!(vml_canvas_hit_test_shape(xml, (0.0, 0.0), (100.0, 80.0)));
2641 for changed in [
2642 xml.replace("<v:shape ", "<v:shape filled=\"t\" "),
2643 xml.replace("width:100", "width:101"),
2644 xml.replace("<v:fill ", "<v:fill color=\"red\" "),
2645 xml.replace("</v:shape>", "<v:imagedata r:id=\"image\"/></v:shape>"),
2646 ] {
2647 assert!(!vml_canvas_hit_test_shape(
2648 &changed,
2649 (0.0, 0.0),
2650 (100.0, 80.0)
2651 ));
2652 }
2653 let definition = rectangular_bitmap_type();
2654 assert!(vml_declares_unpainted_bitmap(definition));
2655 assert!(!vml_declares_unpainted_bitmap(xml));
2656 assert!(!vml_declares_unpainted_bitmap(&format!(
2657 "{definition}{}",
2658 definition.replace("stroked=\"f\"", "stroked=\"t\"")
2659 )));
2660 }
2661
2662 #[test]
2663 fn nonvisual_properties_and_bitmap_formats_are_bounded() {
2664 assert!(vml_editing_lock(
2665 r#"<o:lock v:ext="edit" aspectratio="f"/>"#
2666 ));
2667 assert!(!vml_editing_lock(
2668 r#"<o:lock v:ext="view" aspectratio="f"/>"#
2669 ));
2670 assert!(!vml_editing_lock(r#"<o:lock v:ext="edit" rotation="t"/>"#));
2671 assert!(vml_no_wrap(r#"<w10:wrap type="none"/>"#));
2672 assert!(!vml_no_wrap(r#"<w10:wrap type="none" anchorx="page"/>"#));
2673 assert!(!vml_no_wrap(r#"<w10:wrap type="square"/>"#));
2674 assert!(vml_browser_bitmap_src(
2675 "data:image/png;base64,AA==",
2676 None,
2677 None
2678 ));
2679 assert!(vml_browser_bitmap_src(
2680 "data:image/svg+xml;charset=utf-8,%3Csvg",
2681 None,
2682 None
2683 ));
2684 assert!(!vml_browser_bitmap_src(
2685 "data:image/x-emf;base64,AA==",
2686 None,
2687 None
2688 ));
2689 assert!(!vml_browser_bitmap_src(
2690 "data:image/svg+xml;charset=utf-8,%3Csvg",
2691 Some("media/vector.emf"),
2692 Some("image/svg+xml")
2693 ));
2694 assert!(!vml_browser_bitmap_src(
2695 "data:image/png;base64,AA==",
2696 None,
2697 Some("image/x-wmf")
2698 ));
2699 }
2700
2701 #[test]
2702 fn named_paint_preflight_tracks_consumed_colors() {
2703 for (name, xml) in [
2704 ("v:rect", r#"<v:rect fillcolor="black"/>"#),
2705 ("v:rect", r#"<v:rect strokecolor="NAVY" strokeweight="1px"/>"#),
2706 ("v:line", r#"<v:line fillcolor=" green "/>"#),
2707 ("v:rect", r#"<v:rect strokecolor="black" strokeweight="0px"/>"#),
2708 ] {
2709 assert!(vml_has_active_named_paint(name, xml));
2710 }
2711 for (name, xml) in [
2712 ("v:rect", r#"<v:rect filled="f" fillcolor="black"/>"#),
2713 ("v:rect", r#"<v:rect stroked="false" strokecolor="black"/>"#),
2714 ("v:rect", r##"<v:rect fillcolor="#000000" strokecolor="000000"/>"##),
2715 ("v:rect", r#"<v:rect fillcolor="windowText"/>"#),
2716 ("v:shape", r#"<v:shape strokecolor="black"><v:imagedata/></v:shape>"#),
2717 ("v:shape", r#"<v:shape><v:fill color="black"/></v:shape>"#),
2718 ("v:shapetype", r#"<v:shapetype fillcolor="black"/>"#),
2719 ] {
2720 assert!(!vml_has_active_named_paint(name, xml));
2721 }
2722 }
2723
2724 #[test]
2725 fn grouped_bitmap_named_paint_matches_rgb_and_enables_preflight() {
2726 use crate::{package::OoxmlPart, parse::empty_style_sheet};
2727 use base64::Engine;
2728 use std::{cell::RefCell, collections::HashMap};
2729 let render = |extension: &str, color: &str, definition: &str, style: &str, wrap: &str| {
2730 let source = format!(
2731 r##"<w:pict><v:group style="width:100pt;height:100pt;{style}" coordsize="100,100"><v:shape type="#_x0000_t75" style="position:absolute;left:10;top:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape><v:rect style="position:absolute;left:70;top:70;width:20;height:20" strokeweight="1px" fillcolor="{color}" strokecolor="{color}"/><v:shape type="#_x0000_t75" style="position:absolute;left:40;top:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape>{wrap}</v:group></w:pict>"##
2732 );
2733 let name = "word/document.xml".to_string();
2734 let parts = HashMap::from([(
2735 name.clone(),
2736 OoxmlPart {
2737 name,
2738 content: format!("{definition}{source}"),
2739 },
2740 )]);
2741 let target = format!("word/media/image.{extension}");
2742 let pixels = base64::engine::general_purpose::STANDARD
2743 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
2744 .expect("valid PNG payload");
2745 let assets = HashMap::from([(target.clone(), pixels)]);
2746 let context = ParseContext {
2747 relationships: HashMap::from([("rId1".to_string(), target)]),
2748 content_types: Default::default(),
2749 parts: &parts,
2750 binary_assets: &assets,
2751 style_sheet: empty_style_sheet(),
2752 warnings: RefCell::new(Vec::new()),
2753 };
2754 vml_group_image(&source, &context)
2755 };
2756 for (name, rgb) in [
2757 ("black", "#000000"),
2758 ("NAVY", "#000080"),
2759 (" green ", "#008000"),
2760 ("silver", "#c0c0c0"),
2761 ("gray", "#808080"),
2762 ("white", "#ffffff"),
2763 ("maroon", "#800000"),
2764 ("red", "#ff0000"),
2765 ("purple", "#800080"),
2766 ("fuchsia", "#ff00ff"),
2767 ("lime", "#00ff00"),
2768 ("olive", "#808000"),
2769 ("yellow", "#ffff00"),
2770 ("blue", "#0000ff"),
2771 ("teal", "#008080"),
2772 ("aqua", "#00ffff"),
2773 ] {
2774 let named = render("png", name, "", "", "").expect("resolved named paint");
2775 let explicit = render("png", rgb, "", "", "").expect("explicit RGB paint");
2776 assert_eq!(named.src, explicit.src);
2777 assert_eq!(named.width_px, explicit.width_px);
2778 assert_eq!(named.height_px, explicit.height_px);
2779 assert_eq!(named.src.as_deref().unwrap().matches("%3Cimage%20").count(), 2);
2780 }
2781 for unresolved in [
2782 "auto", "windowText", "currentColor", "transparent", "url(#paint)", "black [0]",
2783 ] {
2784 assert!(render("png", unresolved, "", "", "").is_none());
2785 }
2786 assert!(render("emf", "black", "", "", "").is_none());
2787 assert!(render("emf", "#000000", "", "", "").is_some());
2788 let conflicting = r#"<v:shapetype id="_x0000_t75" path="m0,0l1,1xe"/>"#;
2789 assert!(render("png", "black", conflicting, "", "").is_none());
2790 assert!(render("png", "#000000", conflicting, "", "").is_some());
2791 let reference = "mso-position-horizontal-relative:char;mso-position-vertical-relative:line";
2792 for metadata in [
2793 r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#,
2794 r#"<w10:wrap type="square"/>"#,
2795 r#"<w10:wrap type="none" anchorx="page"/>"#,
2796 ] {
2797 assert!(render("png", "black", "", reference, metadata).is_none());
2798 assert!(render("png", "#000000", "", reference, metadata).is_some());
2799 }
2800 let duplicate = r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#;
2801 assert!(render("png", "black", "", "position:absolute;left:1pt", duplicate).is_none());
2802 let none = r#"<w10:wrap type="none"/>"#;
2803 let image = render("png", "black", "", reference, none).unwrap();
2804 let floating = image.floating.unwrap();
2805 assert!(floating.horizontal_relative_to.is_none());
2806 assert!(floating.vertical_relative_to.is_none());
2807 assert!(matches!(floating.wrap_type, Some(ImageWrapType::WrapNone)));
2808 }
2809
2810 #[test]
2811 fn default_rectangle_stroke_enables_the_metafile_guard_before_leaf_rendering() {
2812 use crate::{package::OoxmlPart, parse::empty_style_sheet};
2813 use std::{cell::RefCell, collections::HashMap};
2814 let xml = r##"<w:pict><v:group style="width:100pt;height:100pt" coordsize="100,100"><v:shape type="#_x0000_t75" style="position:absolute;left:10;top:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape><v:shape type="#_x0000_t75" style="position:absolute;left:40;top:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape><v:rect style="position:absolute;left:70;top:70;width:20;height:20"/></v:group></w:pict>"##;
2815 let render = |extension: &str, source: &str| {
2816 let name = "word/document.xml".to_string();
2817 let parts = HashMap::from([(
2818 name.clone(),
2819 OoxmlPart {
2820 name,
2821 content: source.to_string(),
2822 },
2823 )]);
2824 let target = format!("word/media/image.{extension}");
2825 let assets = HashMap::from([(target.clone(), vec![1, 2, 3])]);
2826 let context = ParseContext {
2827 relationships: HashMap::from([("rId1".to_string(), target)]),
2828 content_types: Default::default(),
2829 parts: &parts,
2830 binary_assets: &assets,
2831 style_sheet: empty_style_sheet(),
2832 warnings: RefCell::new(Vec::new()),
2833 };
2834 vml_group_image(source, &context)
2835 };
2836 assert!(render("png", xml).is_some());
2837 assert!(render("emf", xml).is_none());
2838 assert!(render(
2839 "emf",
2840 &xml.replace("<v:rect style=", "<v:rect stroked=\"f\" style=")
2841 )
2842 .is_some());
2843 assert!(render(
2844 "emf",
2845 &xml.replace("<v:rect style=", "<v:rect strokeweight=\"1px\" style=")
2846 )
2847 .is_some());
2848 let conflicting = xml.replace(
2849 "<v:group ",
2850 "<v:shapetype id=\"_x0000_t75\" path=\"m0,0l1,1xe\"/><v:group ",
2851 );
2852 assert!(render("png", &conflicting).is_none());
2853 }
2854
2855 fn inline_group_package(
2856 style_suffix: &str,
2857 metadata: &str,
2858 expanded: bool,
2859 ) -> crate::OoxmlPackage {
2860 use base64::Engine;
2861 let lock = if expanded {
2862 r#"<o:lock v:ext="edit" aspectratio="f"/><w10:wrap type="none"/>"#
2863 } else {
2864 ""
2865 };
2866 let document = format!(
2867 r##"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w10="urn:schemas-microsoft-com:office:word"><w:body><w:p><w:r><w:pict>{}<v:group style="width:100pt;height:80pt;mso-position-horizontal-relative:char;mso-position-vertical-relative:line{style_suffix}" coordsize="100,80"><v:shape type="#_x0000_t75" style="position:absolute;left:10;top:10;width:20;height:20"><v:imagedata r:id="rId1"/>{lock}</v:shape><v:shape type="#_x0000_t75" style="position:absolute;left:40;top:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape>{metadata}</v:group></w:pict></w:r></w:p><w:sectPr/></w:body></w:document>"##,
2868 rectangular_bitmap_type()
2869 );
2870 let mut package = crate::create_minimal_docx_package(Some(&document));
2871 let rel_name = "word/_rels/document.xml.rels".to_string();
2872 package.parts.insert(rel_name.clone(), crate::OoxmlPart {
2873 name: rel_name,
2874 content: r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image.png"/></Relationships>"#.to_string(),
2875 });
2876 package.binary_assets.insert("word/media/image.png".to_string(),
2877 base64::engine::general_purpose::STANDARD
2878 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
2879 .unwrap());
2880 package
2881 }
2882
2883 fn overflow_group_package(style: &str, geometry: &str, line: &str) -> crate::OoxmlPackage {
2884 let mut package = inline_group_package("", "", false);
2885 let document = format!(
2886 r##"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:w10="urn:schemas-microsoft-com:office:word"><w:body><w:p><w:r><w:pict><v:group style="{style}" {geometry}><v:shape type="#_x0000_t75" style="position:absolute;left:20;top:25;width:30;height:30" stroked="f"><v:imagedata r:id="rId1"/></v:shape>{line}<v:shape type="#_x0000_t202" style="position:absolute;left:65;top:20;width:150;height:40" filled="f" stroked="f"><v:textbox inset="0,0,0,0"><w:txbxContent><w:p><w:r><w:t>Upper story</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape><v:shape type="#_x0000_t202" style="position:absolute;left:145;top:70;width:150;height:40" filled="f" stroked="f"><v:textbox inset="0,0,0,0"><w:txbxContent><w:p><w:r><w:t>Lower story</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></v:group></w:pict></w:r></w:p><w:sectPr/></w:body></w:document>"##,
2887 );
2888 package.parts.get_mut("word/document.xml").unwrap().content = document;
2889 package
2890 }
2891
2892
2893 fn render_style_group(package: &crate::OoxmlPackage) -> Option<ImageRunNode> {
2894 use std::{cell::RefCell, collections::HashMap};
2895 let source = extract_balanced_tag_blocks(&package.parts["word/document.xml"].content, "w:pict")
2896 .into_iter().next().unwrap();
2897 let context = ParseContext {
2898 relationships: HashMap::from([("rId1".to_string(), "word/media/image.png".to_string())]),
2899 content_types: Default::default(),
2900 parts: &package.parts,
2901 binary_assets: &package.binary_assets,
2902 style_sheet: crate::parse::empty_style_sheet(),
2903 warnings: RefCell::new(Vec::new()),
2904 };
2905 vml_group_image(&source, &context)
2906 }
2907
2908 fn style_group_package() -> crate::OoxmlPackage {
2909 overflow_group_package("width:270pt;height:105pt", r#"coordsize="360,140""#,
2910 r##"<v:shape type="#_x0000_t75" style="position:absolute;left:20;top:70;width:30;height:30" stroked="f"><v:imagedata r:id="rId1"/></v:shape>"##)
2911 }
2912
2913 #[test]
2914 fn explicit_default_group_styles_preserve_payload_and_placement() {
2915 let package = style_group_package();
2916 let expected = render_style_group(&package).unwrap();
2917 assert_eq!(svg_fragment_count(&expected, "data:image/png;base64,"), 2);
2918 assert_eq!(svg_fragment_count(&expected, "Upper story"), 1);
2919 assert_eq!(svg_fragment_count(&expected, "Lower story"), 1);
2920 for style in ["mso-wrap-style:square;", "v-text-anchor:top;", "mso-wrap-style:square;v-text-anchor:top;"] {
2921 let mut explicit = package.clone();
2922 let part = explicit.parts.get_mut("word/document.xml").unwrap();
2923 part.content = part.content.replace("position:absolute;", &format!("position:absolute;{style}"));
2924 let actual = render_style_group(&explicit).unwrap();
2925 assert_eq!(actual.src, expected.src);
2926 assert_eq!(actual.width_px, expected.width_px);
2927 assert_eq!(actual.height_px, expected.height_px);
2928 assert_eq!(actual.paint_bounds, expected.paint_bounds);
2929 assert_eq!(serde_json::to_value(actual.floating).unwrap(), serde_json::to_value(&expected.floating).unwrap());
2930 }
2931 }
2932
2933 #[test]
2934 fn self_linked_group_textboxes_terminate_without_cross_box_flow() {
2935 let mut package = style_group_package();
2936 let expected = render_style_group(&package).unwrap();
2937 let part = package.parts.get_mut("word/document.xml").unwrap();
2938 for id in ["box-a", "box-b"] {
2939 part.content = part.content.replacen(r##"<v:shape type="#_x0000_t202""##,
2940 &format!(r##"<v:shape id="{id}" type="#_x0000_t202""##), 1);
2941 part.content = part.content.replacen("<v:textbox inset=",
2942 &format!(r##"<v:textbox style="mso-next-textbox:#{id};v-text-anchor:top" inset="##), 1);
2943 }
2944 let actual = render_style_group(&package).unwrap();
2945 assert_eq!(actual.src, expected.src);
2946 assert_eq!(svg_fragment_count(&actual, "data:image/png;base64,"), 2);
2947 for (from, to) in [
2948 ("mso-next-textbox:#box-a", "mso-next-textbox:#box-b"),
2949 ("mso-next-textbox:#box-a", "mso-next-textbox:#missing"),
2950 ("mso-next-textbox:#box-a", "mso-next-textbox:#"),
2951 ("mso-next-textbox:#box-a", "mso-next-textbox:box-a"),
2952 ("v-text-anchor:top", "v-text-anchor:bottom"),
2953 ("v-text-anchor:top", "mso-fit-shape-to-text:false"),
2954 ] {
2955 let mut unsupported = package.clone();
2956 let part = unsupported.parts.get_mut("word/document.xml").unwrap();
2957 part.content = part.content.replace(from, to);
2958 assert!(render_style_group(&unsupported).is_none(), "{to}");
2959 }
2960 }
2961
2962
2963 #[test]
2964 fn explicit_default_bitmap_group_exports_inline() {
2965 use crate::model::{DocNode, ParagraphChildNode};
2966 let mut package = inline_group_package("", "", false);
2967 let part = package.parts.get_mut("word/document.xml").unwrap();
2968 part.content = part.content.replace("position:absolute;", "position:absolute;mso-wrap-style:square;");
2969 let mut model = crate::build_doc_model(&package);
2970 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
2971 panic!("paragraph")
2972 };
2973 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
2974 panic!("image")
2975 };
2976 assert!(image.floating.is_none());
2977 assert_eq!(svg_fragment_count(image, "data:image/png;base64,"), 2);
2978 image.width_px = Some(160.0);
2979 image.height_px = Some(80.0);
2980 paragraph.source_xml = None;
2981 let exported = crate::serialize_doc_model(&model, Some(&package));
2982 let xml = &exported.parts["word/document.xml"].content;
2983 assert!(xml.contains("<wp:inline "));
2984 assert!(!xml.contains("<wp:anchor "));
2985 }
2986
2987 #[test]
2988 fn grouped_bitmap_root_wrap_survives_edit_and_export() {
2989 use crate::model::{DocNode, ParagraphChildNode};
2990 for (kind, expected, export_tag) in [
2991 ("topAndBottom", ImageWrapType::TopAndBottom, "<wp:wrapTopAndBottom"),
2992 ("square", ImageWrapType::Square, "<wp:wrapSquare"),
2993 ("tight", ImageWrapType::Tight, "<wp:wrapTight"),
2994 ("through", ImageWrapType::Through, "<wp:wrapThrough"),
2995 ("none", ImageWrapType::WrapNone, "<wp:wrapNone"),
2996 ] {
2997 let metadata = format!(r#"<w10:wrap type="{kind}" side="right"/>"#);
2998 let package = inline_group_package(";position:absolute;left:12px;top:8px", &metadata, true);
2999 let mut model = crate::build_doc_model(&package);
3000 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
3001 panic!("paragraph")
3002 };
3003 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
3004 panic!("image")
3005 };
3006 assert_eq!(svg_fragment_count(image, "data:image/png;base64,"), 2);
3007 let floating = image.floating.as_ref().unwrap();
3008 assert_eq!(floating.wrap_type, Some(expected.clone()), "{kind}");
3009 assert_eq!(floating.wrap_text, Some(ImageWrapText::Right));
3010 assert_eq!((floating.x_px, floating.y_px), (Some(12.0), Some(8.0)));
3011 image.width_px = Some(160.0);
3012 image.height_px = Some(80.0);
3013 paragraph.source_xml = None;
3014 let exported = crate::serialize_doc_model(&model, Some(&package));
3015 let xml = &exported.parts["word/document.xml"].content;
3016 assert!(xml.contains("<wp:anchor "));
3017 assert!(xml.contains(export_tag), "{kind}");
3018 let reparsed = crate::build_doc_model(&exported);
3019 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
3020 panic!("paragraph")
3021 };
3022 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3023 panic!("image")
3024 };
3025 assert_eq!(image.floating.as_ref().unwrap().wrap_type, Some(expected), "{kind}");
3026 }
3027 }
3028
3029 #[test]
3030 fn group_wrap_metadata_creates_anchor_without_position_hints() {
3031 let mut package = style_group_package();
3032 let part = package.parts.get_mut("word/document.xml").unwrap();
3033 part.content = part.content.replace("</v:group>", r#"<w10:wrap type="topAndBottom"/></v:group>"#);
3034 let image = render_style_group(&package).unwrap();
3035 let floating = image.floating.as_ref().unwrap();
3036 assert_eq!(floating.wrap_type, Some(ImageWrapType::TopAndBottom));
3037 assert_eq!((floating.x_px, floating.y_px), (None, None));
3038 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 2);
3039 }
3040
3041 fn first_group_image(package: &crate::OoxmlPackage) -> ImageRunNode {
3042 let model = crate::build_doc_model(package);
3043 let crate::model::DocNode::Paragraph(paragraph) = &model.nodes[0] else {
3044 panic!("paragraph")
3045 };
3046 let crate::model::ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3047 panic!("image")
3048 };
3049 (**image).clone()
3050 }
3051
3052 fn svg_fragment_count(image: &ImageRunNode, fragment: &str) -> usize {
3053 let encoded = svg_data_uri(fragment);
3054 image.src.as_ref().unwrap().matches(encoded.split_once(',').unwrap().1).count()
3055 }
3056
3057 #[test]
3058 fn inline_overflow_preserves_logical_frame_payload_and_stories() {
3059 let package = overflow_group_package("width:270pt;height:105pt", r#"coordorigin="0,0" coordsize="360,140""#,
3060 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3061 let image = first_group_image(&package);
3062 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3063 assert!(image.floating.is_none());
3064 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3065 x_px: -0.5, y_px: 0.0, width_px: 361.0, height_px: 140.0,
3066 layout_width_px: 360.0, layout_height_px: 140.0,
3067 }));
3068 assert_eq!(svg_fragment_count(&image, r#"viewBox="-0.5 0 361 140""#), 1);
3069 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3070 let stories = image.content_nodes.as_ref().unwrap();
3071 assert_eq!(stories.len(), 2);
3072 for (story, expected) in stories.iter().zip(["Upper story", "Lower story"]) {
3073 let crate::model::DocNode::Paragraph(paragraph) = story else {
3074 panic!("story paragraph")
3075 };
3076 let crate::model::ParagraphChildNode::Text(text) = ¶graph.children[0] else {
3077 panic!("story text")
3078 };
3079 assert_eq!(text.text, expected);
3080 }
3081 let encoded = serde_json::to_value(&image).unwrap();
3082 assert_eq!(encoded["paintBounds"]["xPx"], -0.5);
3083 assert_eq!(serde_json::from_value::<ImageRunNode>(encoded).unwrap(), image);
3084 }
3085
3086 #[test]
3087 fn inline_overflow_maps_nonzero_coordinate_origin_and_nonunit_scale() {
3088 let package = overflow_group_package("width:540pt;height:420pt", r#"coordorigin="10,20" coordsize="360,140""#,
3089 r##"<v:line from="10,20" to="370,160" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3090 let image = first_group_image(&package);
3091 assert_eq!((image.width_px, image.height_px), (Some(720.0), Some(560.0)));
3092 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3093 x_px: -0.5, y_px: -0.5, width_px: 721.0, height_px: 561.0,
3094 layout_width_px: 720.0, layout_height_px: 560.0,
3095 }));
3096 assert_eq!(svg_fragment_count(&image, r#"viewBox="9.75 19.875 360.5 140.25""#), 1);
3097 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3098 assert!(image.floating.is_none());
3099 }
3100
3101 #[test]
3102 fn floating_overflow_keeps_existing_anchor_and_canvas_contract() {
3103 let package = overflow_group_package("position:absolute;left:12px;top:8px;width:270pt;height:105pt", r#"coordsize="360,140""#,
3104 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3105 let image = first_group_image(&package);
3106 assert_eq!((image.width_px, image.height_px), (Some(361.0), Some(140.0)));
3107 assert!(image.paint_bounds.is_none());
3108 let floating = image.floating.as_ref().unwrap();
3109 assert_eq!((floating.x_px, floating.y_px), (Some(11.5), Some(8.0)));
3110 assert!(serde_json::to_value(&image).unwrap().get("paintBounds").is_none());
3111 }
3112
3113 #[test]
3114 fn in_frame_inline_group_omits_paint_metadata() {
3115 let package = overflow_group_package("width:270pt;height:105pt", r#"coordsize="360,140""#,
3116 r##"<v:line from="1,120" to="359,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3117 let image = first_group_image(&package);
3118 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3119 assert!(image.paint_bounds.is_none());
3120 assert!(image.floating.is_none());
3121 assert!(serde_json::to_value(&image).unwrap().get("paintBounds").is_none());
3122 }
3123
3124 #[test]
3125 fn inline_reference_normalization_precedes_overflow_mapping_only_in_admitted_domain() {
3126 let mut package = overflow_group_package("width:270pt;height:105pt;mso-position-horizontal-relative:char;mso-position-vertical-relative:line", r#"coordsize="360,140""#,
3127 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3128 let part = package.parts.get_mut("word/document.xml").unwrap();
3129 part.content = part.content.replace("</v:group>", "<o:lock v:ext=\"edit\" aspectratio=\"f\"/></v:group>");
3130 let image = first_group_image(&package);
3131 assert!(image.paint_bounds.is_some());
3132 assert!(image.floating.is_none());
3133 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3134 }
3135
3136 #[test]
3137 fn single_bitmap_overflow_keeps_inline_frame_and_payload() {
3138 let mut package = inline_group_package("", "", false);
3139 let document = package.parts.get_mut("word/document.xml").unwrap();
3140 let start = document.content.find("<v:group ").unwrap();
3141 let end = document.content.find("</v:group>").unwrap() + "</v:group>".len();
3142 document.content.replace_range(start..end,
3143 r##"<v:group style="width:270pt;height:105pt" coordsize="360,140"><v:shape type="#_x0000_t75" style="position:absolute;left:-0.5;top:0;width:361;height:140" stroked="f"><v:imagedata r:id="rId1"/></v:shape></v:group>"##);
3144 let image = first_group_image(&package);
3145 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3146 assert!(image.floating.is_none());
3147 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3148 x_px: -0.5, y_px: 0.0, width_px: 361.0, height_px: 140.0,
3149 layout_width_px: 360.0, layout_height_px: 140.0,
3150 }));
3151 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3152 assert!(image.content_nodes.is_none());
3153 for (find, replace) in [
3154 ("left:-0.5;top:0;width:361", "left:0;top:0;width:360"),
3155 ("width:270pt;height:105pt", "position:absolute;left:12px;top:8px;width:270pt;height:105pt"),
3156 ] {
3157 let mut unchanged = package.clone();
3158 let document = unchanged.parts.get_mut("word/document.xml").unwrap();
3159 document.content = document.content.replace(find, replace);
3160 let fallback = first_group_image(&unchanged);
3161 assert!(fallback.paint_bounds.is_none());
3162 assert_eq!(fallback.content_type.as_deref(), Some("image/png"));
3163 }
3164 let mut missing_relationship = package.clone();
3165 missing_relationship.parts.get_mut("word/_rels/document.xml.rels").unwrap().content =
3166 r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#.to_string();
3167 let model = crate::build_doc_model(&missing_relationship);
3168 assert_eq!(model.metadata.warnings.iter().filter(|warning| warning.as_str() == "Missing relationship target for rId1").count(), 1);
3169 let mut unsupported = package.clone();
3170 unsupported.parts.get_mut("word/_rels/document.xml.rels").unwrap().content =
3171 unsupported.parts["word/_rels/document.xml.rels"].content.replace("image.png", "image.wmf");
3172 let bytes = unsupported.binary_assets.remove("word/media/image.png").unwrap();
3173 unsupported.binary_assets.insert("word/media/image.wmf".to_string(), bytes);
3174 let fallback = first_group_image(&unsupported);
3175 assert!(fallback.paint_bounds.is_none());
3176 assert_ne!(fallback.content_type.as_deref(), Some("image/svg+xml"));
3177 }
3178
3179 #[test]
3180 fn edited_group_exports_inline_only_without_anchor_data() {
3181 use crate::model::{DocNode, ParagraphChildNode};
3182 for (style, metadata, expanded, anchored) in [
3183 ("", "", true, false),
3184 ("", "<w10:anchorlock/>", true, false),
3185 (";position:static", "", true, false),
3186 (";position:relative", "", true, false),
3187 (";position:absolute", "", true, true),
3188 (";left:0px;top:0px", "", true, true),
3189 (";margin-left:0;margin-top:0", "", true, true),
3190 (";mso-position-horizontal:center", "", true, true),
3191 (";mso-wrap-style:none", "", true, true),
3192 (";z-index:0", "", true, true),
3193 (";mso-wrap-distance-left:0px", "", true, true),
3194 ("", r#"<w10:wrap type="none"/>"#, true, true),
3195 ("", "", false, true),
3196 ] {
3197 let package = inline_group_package(style, metadata, expanded);
3198 let mut model = crate::build_doc_model(&package);
3199 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
3200 panic!("paragraph")
3201 };
3202 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
3203 panic!("image")
3204 };
3205 assert_eq!(image.content_type.as_deref(), Some("image/svg+xml"));
3206 assert_eq!(image.floating.is_some(), anchored, "{style} {metadata}");
3207 let restored_wrap = style.is_empty() && metadata == r#"<w10:wrap type="none"/>"#;
3208 if restored_wrap {
3209 assert_eq!(
3210 serde_json::to_value(&image.floating).unwrap(),
3211 serde_json::json!({"wrapType": "none"})
3212 );
3213 }
3214 let original_source = image.src.clone();
3215 image.width_px = Some(160.0);
3216 image.height_px = Some(80.0);
3217 assert_eq!(image.src, original_source);
3218 paragraph.source_xml = None;
3219 let exported = crate::serialize_doc_model(&model, Some(&package));
3220 let xml = &exported.parts["word/document.xml"].content;
3221 assert_eq!(xml.contains("<wp:anchor "), anchored, "{style} {metadata}");
3222 assert_eq!(xml.contains("<wp:inline "), !anchored, "{style} {metadata}");
3223 assert!(xml.contains(r#"<wp:extent cx="1524000" cy="762000"/>"#));
3224 assert!(exported.binary_assets.values().any(|bytes| {
3225 std::str::from_utf8(bytes).is_ok_and(|svg| {
3226 svg.starts_with("<svg") && svg.matches("data:image/png;base64,").count() == 2
3227 })
3228 }));
3229 if restored_wrap {
3230 assert!(xml.contains(r#"<wp:positionH relativeFrom="margin">"#));
3231 assert!(!xml.contains(r#"<wp:positionH relativeFrom="line">"#));
3232 assert!(xml.contains("<wp:wrapNone/>"));
3233 let reparsed = crate::build_doc_model(&exported);
3234 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
3235 panic!("paragraph")
3236 };
3237 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3238 panic!("image")
3239 };
3240 let floating = image.floating.as_ref().unwrap();
3241 assert_eq!(floating.horizontal_relative_to.as_deref(), Some("margin"));
3242 assert_eq!(floating.wrap_type, Some(ImageWrapType::WrapNone));
3243 assert_eq!((image.width_px, image.height_px), (Some(160.0), Some(80.0)));
3244 }
3245 if !anchored {
3246 assert!(!xml.contains("<wp:positionH"));
3247 let reparsed = crate::build_doc_model(&exported);
3248 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
3249 panic!("paragraph")
3250 };
3251 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3252 panic!("image")
3253 };
3254 assert!(image.floating.is_none());
3255 assert_eq!((image.width_px, image.height_px), (Some(160.0), Some(80.0)));
3256 }
3257 }
3258 }
3259
3260 #[test]
3261 fn reference_hint_normalization_preserves_every_anchor_field() {
3262 let style = parse_css_style_declarations("width:100pt;height:80pt;mso-position-horizontal-relative:char;mso-position-vertical-relative:line");
3263 let hints = serde_json::json!({"horizontalRelativeTo":"line","verticalRelativeTo":"line"});
3264 let floating: ImageFloating = serde_json::from_value(hints.clone()).unwrap();
3265 assert!(vml_inline_reference_hints(&style, false, Some(&floating)));
3266 assert!(!vml_inline_reference_hints(&style, true, Some(&floating)));
3267 for (field, value) in [
3268 ("xPx", serde_json::json!(0)),
3269 ("yPx", serde_json::json!(0)),
3270 ("horizontalAlign", serde_json::json!("center")),
3271 ("verticalAlign", serde_json::json!("center")),
3272 ("distLPx", serde_json::json!(0)),
3273 ("distRPx", serde_json::json!(0)),
3274 ("distTPx", serde_json::json!(0)),
3275 ("distBPx", serde_json::json!(0)),
3276 ("wrapType", serde_json::json!("none")),
3277 ("wrapText", serde_json::json!("bothSides")),
3278 ("behindDocument", serde_json::json!(false)),
3279 ("zIndex", serde_json::json!(0)),
3280 ] {
3281 let mut modified = hints.clone();
3282 modified[field] = value;
3283 let anchor: ImageFloating = serde_json::from_value(modified).unwrap();
3284 assert!(
3285 !vml_inline_reference_hints(&style, false, Some(&anchor)),
3286 "{field}"
3287 );
3288 }
3289 }
3290
3291 #[test]
3292 fn group_wrap_types_are_not_shape_type_references() {
3293 use crate::{package::OoxmlPart, parse::empty_style_sheet};
3294 use base64::Engine;
3295 use std::{cell::RefCell, collections::HashMap};
3296 let xml = format!(
3297 r##"<w:pict><v:group editas="canvas" style="width:100pt;height:80pt" coordsize="100,80">{}<v:shape type="#_x0000_t75" style="position:absolute;width:100;height:80"><v:fill o:detectmouseclick="t"/><v:path o:connecttype="none"/></v:shape><v:shape type="#_x0000_t75" style="position:absolute;left:10;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape><v:shape type="#_x0000_t75" style="position:absolute;left:40;width:20;height:20"><v:imagedata r:id="rId1"/></v:shape><w10:wrap type="none"/><w10:anchorlock/></v:group></w:pict>"##,
3298 rectangular_bitmap_type()
3299 );
3300 let render = |extension: &str, source: &str, part_prefix: &str| {
3301 let name = "word/document.xml".to_string();
3302 let parts = HashMap::from([(
3303 name.clone(),
3304 OoxmlPart {
3305 name,
3306 content: format!("{part_prefix}{source}"),
3307 },
3308 )]);
3309 let target = format!("word/media/image.{extension}");
3310 let pixels = base64::engine::general_purpose::STANDARD
3311 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
3312 .unwrap();
3313 let payload = if extension == "emf" {
3314 vec![1, 2, 3]
3315 } else {
3316 pixels
3317 };
3318 let assets = HashMap::from([(target.clone(), payload)]);
3319 let context = ParseContext {
3320 relationships: HashMap::from([("rId1".to_string(), target)]),
3321 content_types: Default::default(),
3322 parts: &parts,
3323 binary_assets: &assets,
3324 style_sheet: empty_style_sheet(),
3325 warnings: RefCell::new(Vec::new()),
3326 };
3327 vml_group_image(source, &context)
3328 };
3329 let image = render("png", &xml, "").unwrap();
3330 assert_eq!(image.src.unwrap().matches("%3Cimage%20").count(), 2);
3331 assert!(render("emf", &xml, "").is_none());
3332 let unknown_shape = xml.replacen(
3333 "left:10;width:20;height:20\"><v:imagedata",
3334 "left:10;width:20;height:20\" path=\"m0,0l1,1xe\"><v:imagedata",
3335 1,
3336 );
3337 assert!(render("png", &unknown_shape, "").is_none());
3338 assert!(render("png", &xml.replace("#_x0000_t75", "#_x0000_t999"), "").is_none());
3339 let direct = xml
3340 .replace(
3341 r##"<v:shape type="#_x0000_t75" style="position:absolute;width:100;height:80"><v:fill o:detectmouseclick="t"/><v:path o:connecttype="none"/></v:shape>"##,
3342 "",
3343 )
3344 .replace("editas=\"canvas\" ", "")
3345 .replace("<v:shape type=", "<v:shape stroked=\"f\" strokeweight=\"1px\" type=");
3346 assert!(render("png", &direct, "").is_some());
3347 assert!(render("emf", &direct, "").is_none());
3348 let conflicting = rectangular_bitmap_type().replace("stroked=\"f\"", "stroked=\"t\"");
3349 assert!(render("png", &direct, &conflicting).is_none());
3350 for metadata in [
3351 r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#,
3352 r#"<w10:wrap type="square"/>"#,
3353 r#"<w10:wrap type="none" anchorx="page"/>"#,
3354 r#"<w10:anchorlock type="none"/>"#,
3355 ] {
3356 let unsupported = direct.replace(r#"<w10:wrap type="none"/>"#, metadata);
3357 assert!(render("png", &unsupported, "").is_none());
3358 }
3359 }
3360
3361 #[test]
3362 fn compact_vml_paths_resolve_relative_coordinates() {
3363 assert_eq!(
3364 vml_svg_path("m10,20r5,-2,-3,4xe").as_deref(),
3365 Some("M10 20L15 18L12 22Z")
3366 );
3367 assert_eq!(
3368 vml_svg_path("m10,l,,,20r5,l5,30r10,l15,20,15,xe").as_deref(),
3369 Some("M10 0L0 0L0 20L5 20L5 30L15 30L15 20L15 0Z")
3370 );
3371 assert_eq!(
3372 vml_svg_path("m10,20r5,5xr2,3e").as_deref(),
3373 Some("M10 20L15 25ZL12 23")
3374 );
3375 assert_eq!(
3376 vml_svg_path("m0,0c10,10,,,25,13e").as_deref(),
3377 Some("M0 0C10 10 0 0 25 13")
3378 );
3379 assert!(vml_svg_path("m0,0r1e").is_none());
3380 assert!(vml_svg_path("m0,0r@1,@2e").is_none());
3381 assert!(vml_svg_path("m0,0q1,2e").is_none());
3382 }
3383
3384 #[test]
3385 fn explicit_vml_paths_keep_their_serialization() {
3386 assert_eq!(
3387 vml_svg_path("m 1,2 l 3,4 5,6 x e").as_deref(),
3388 Some("M1,2L3,4 5,6Z")
3389 );
3390 assert_eq!(vml_pixels("0"), Some(0.0));
3391 assert_eq!(vml_pixels("12.5"), Some(12.5));
3392 assert_eq!(vml_pixels("3pt"), Some(4.0));
3393 }
3394
3395 #[test]
3396 fn vml_wrap_element_overrides_style_and_preserves_side() {
3397 let xml = r#"<w:pict><v:shape style="position:absolute;left:-60.15pt;top:-45pt;mso-wrap-style:square;z-index:-1"><w10:wrap type="none" side="right"/></v:shape></w:pict>"#;
3398 let floating = parse_vml_floating_anchor_from_run_xml(xml).expect("VML anchor");
3399 assert_eq!(floating.wrap_type, Some(ImageWrapType::WrapNone));
3400 assert_eq!(floating.wrap_text, Some(ImageWrapText::Right));
3401 assert_eq!(floating.z_index, Some(-1));
3402 assert_eq!(floating.behind_document, Some(true));
3403 assert!(floating.x_px.is_some_and(|x| (x + 80.0).abs() < 0.01));
3404 assert!(floating.y_px.is_some_and(|y| (y + 60.0).abs() < 0.01));
3405 }
3406
3407 #[test]
3408 fn outer_vml_size_wins_over_nested_drawing_extent() {
3409 let xml = r#"<w:pict><v:shape style="width:558pt;height:738pt"><v:textbox><w:txbxContent><w:p><w:r><w:drawing><wp:inline><wp:extent cx="952500" cy="476250"/></wp:inline></w:drawing></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict>"#;
3410 let (width, height, _) = resolve_run_image_layout(xml);
3411 assert!(width.is_some_and(|value| (value - 744.0).abs() < 0.01));
3412 assert!(height.is_some_and(|value| (value - 984.0).abs() < 0.01));
3413 }
3414}
3415
3416#[cfg(test)]
3417mod shared_vml_layout_style_tests {
3418 use super::*;
3419
3420 #[test]
3421 fn first_shape_with_missing_or_empty_style_blocks_later_shapes() {
3422 for first in [r#"<v:shape/>"#, r#"<v:shape style=""/>"#, r#"<v:shape style=" "/>"#] {
3423 let xml = format!(r#"{first}<v:shape style="width:40px;left:12px"/>"#);
3424 let (width, height, floating) = resolve_run_image_layout(&xml);
3425 assert_eq!((width, height), (None, None));
3426 assert!(floating.is_none());
3427 assert_eq!(parse_vml_size(&xml), (None, None));
3428 assert!(parse_vml_floating_anchor_from_run_xml(&xml).is_none());
3429 }
3430 }
3431
3432 #[test]
3433 fn dimension_and_position_declaration_precedence_remains_distinct() {
3434 let xml = r#"<v:shape style="width:8px;width:16px;height:12pt;left:1px;left:2px"/>"#;
3435 let (width, height, floating) = resolve_run_image_layout(xml);
3436 assert_eq!((width, height), (Some(8.0), Some(16.0)));
3437 assert_eq!(floating.unwrap().x_px, Some(2.0));
3438 assert_eq!(parse_vml_size(xml), (Some(8.0), Some(16.0)));
3439 assert_eq!(parse_vml_floating_anchor_from_run_xml(xml).unwrap().x_px, Some(2.0));
3440 }
3441
3442 #[test]
3443 fn case_folding_and_existing_token_boundaries_select_the_first_shape() {
3444 for name in ["v:shape", "V:SHAPE", "v:ſhape", "v:shapeλ", "v:shape-extra"] {
3445 let xml = format!(r#"<{name} style="width:7px;left:3px"/><v:shape style="width:9px;left:5px"/>"#);
3446 let (width, _, floating) = resolve_run_image_layout(&xml);
3447 assert_eq!(width, Some(7.0), "{name}");
3448 assert_eq!(floating.unwrap().x_px, Some(3.0), "{name}");
3449 }
3450 let xml = r#"<v:shape_extra style="width:7px;left:3px"/><v:shape style="width:9px;left:5px"/>"#;
3451 let (width, _, floating) = resolve_run_image_layout(xml);
3452 assert_eq!(width, Some(9.0));
3453 assert_eq!(floating.unwrap().x_px, Some(5.0));
3454 }
3455
3456 #[test]
3457 fn vml_dimensions_override_drawing_extent_without_changing_anchor_priority() {
3458 let xml = r#"<v:shape style="width:8px;height:9px;left:17px"/><wp:extent cx="952500" cy="1905000"/><wp:anchor><wp:positionH relativeFrom="page"><wp:posOffset>95250</wp:posOffset></wp:positionH></wp:anchor>"#;
3459 let (width, height, floating) = resolve_run_image_layout(xml);
3460 assert_eq!((width, height), (Some(8.0), Some(9.0)));
3461 let floating = floating.unwrap();
3462 assert_eq!(floating.x_px, Some(10.0));
3463 assert_eq!(floating.horizontal_relative_to.as_deref(), Some("page"));
3464 }
3465
3466 #[test]
3467 fn absent_vml_dimensions_keep_extent_and_empty_anchor_falls_back() {
3468 let xml = r#"<v:shape style="left:17px"/><wp:extent cx="952500" cy="1905000"/><wp:anchor/>"#;
3469 let (width, height, floating) = resolve_run_image_layout(xml);
3470 assert_eq!((width, height), (Some(100.0), Some(200.0)));
3471 assert_eq!(floating.unwrap().x_px, Some(17.0));
3472 }
3473
3474 #[test]
3475 fn wrap_selection_keeps_whole_run_scope() {
3476 let xml = r#"<w10:wrap type="tight" side="right"/><v:shape style="width:8px;left:3px"><w10:wrap type="square" side="left"/></v:shape>"#;
3477 let (width, _, floating) = resolve_run_image_layout(xml);
3478 assert_eq!(width, Some(8.0));
3479 let floating = floating.unwrap();
3480 assert!(matches!(floating.wrap_type, Some(ImageWrapType::Tight)));
3481 assert!(matches!(floating.wrap_text, Some(ImageWrapText::Right)));
3482 }
3483}