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", "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 presentation: None,
2029 chart_data: None,
2030 chart_part_name: None,
2031 width_px: Some(if paint_bounds.is_some() { width } else { width_px }),
2032 height_px: Some(if paint_bounds.is_some() { height } else { height_px }),
2033 paint_bounds,
2034 r#type: ImageRunNodeType::Image,
2035 block_id: None,
2036 content_nodes: None,
2037 semantic_kind: None,
2038 math_ml: None,
2039 src: Some(svg_data_uri(&svg)),
2040 alt: has_text_box.then(|| resolve_doc_pr_alt(run_xml).unwrap_or_else(|| "Text box".into())),
2041 part_name: None,
2042 content_type: Some("image/svg+xml".to_string()),
2043 data: None,
2044 source_xml: Some(run_xml.to_string()),
2045 crop: None,
2046 css_filter: None,
2047 css_opacity: None,
2048 floating,
2049 synthetic_text_box: has_text_box.then_some(true),
2050 text_box_text: None,
2051 })
2052}
2053
2054pub fn parse_run_image_block(
2055 run_xml: &str,
2056 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2057) -> Option<ImageRunNode> {
2058 let mut image = parse_run_image_block_inner(run_xml, context)?;
2059 let transform_applied = image.presentation.as_ref().is_some_and(|value| value.transform_applied);
2060 image.presentation = super::image_properties::parse_image_presentation(
2061 image.source_xml.as_deref().unwrap_or(run_xml), context,
2062 ).or(image.presentation);
2063 if let Some(presentation) = image.presentation.as_mut() {
2064 presentation.transform_applied = transform_applied;
2065 }
2066 Some(image)
2067}
2068
2069fn parse_run_image_block_inner(
2070 run_xml: &str,
2071 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2072) -> Option<ImageRunNode> {
2073 let normalized_run_xml = prefer_alternate_content_choice(run_xml);
2074 let mut active_run_xml = normalized_run_xml.as_ref();
2075 let _drawing_index = if active_run_xml.len() > 8192
2076 && crate::xml_index::tag(active_run_xml, "w:drawing").is_none()
2077 {
2078 Some(crate::xml_index::scope(
2079 std::iter::once(("", active_run_xml)),
2080 Default::default(),
2081 ))
2082 } else {
2083 None
2084 };
2085 if let Some(image) = vml_group_image(active_run_xml, context) {
2086 return Some(image);
2087 }
2088 let (mut width_px, mut height_px, mut floating) = resolve_run_image_layout(active_run_xml);
2089 let mut alt = resolve_doc_pr_alt(active_run_xml);
2090 let mut chart_relationship_id =
2091 chart_relationship(active_run_xml);
2092 let mut relationship_id = resolve_preferred_drawing_relationship_id(active_run_xml, context)
2093 .or_else(|| chart_relationship_id.clone());
2094
2095 if relationship_id.is_none() && normalized_run_xml != run_xml {
2096 active_run_xml = run_xml;
2097 chart_relationship_id =
2098 chart_relationship(active_run_xml);
2099 relationship_id = resolve_preferred_drawing_relationship_id(active_run_xml, context)
2100 .or_else(|| chart_relationship_id.clone());
2101 (width_px, height_px, floating) = resolve_run_image_layout(active_run_xml);
2102 alt = resolve_doc_pr_alt(active_run_xml);
2103 }
2104
2105 let part_name = relationship_id
2106 .as_deref()
2107 .and_then(|id| context.relationships.get(id).cloned())
2108 .or_else(|| {
2109 let tag = regex_capture_tag(active_run_xml, r"(?i)<v:(?:imagedata|fill)\b[^>]*>")?;
2110 let target = get_attribute(&tag, "src")?;
2111 external_image_source(&target).or_else(|| context.binary_assets.contains_key(&target).then_some(target))
2112 });
2113 if relationship_id.is_some() && part_name.is_none() {
2114 context.push_warning(format!(
2115 "Missing relationship target for {}",
2116 relationship_id.as_deref().unwrap_or_default()
2117 ));
2118 }
2119
2120 let content_type = part_name
2121 .as_deref()
2122 .and_then(|name| content_type_for_part(name, &context.content_types));
2123 let binary = part_name
2124 .as_deref()
2125 .and_then(|name| context.binary_assets.get(name).map(Vec::as_slice));
2126 let mut crop = parse_drawing_image_crop(active_run_xml);
2127 let mut css_filter = parse_drawing_image_css_filter(active_run_xml);
2128 let css_opacity = parse_drawing_image_opacity(active_run_xml);
2129
2130 let likely_chart_part = chart_relationship_id.is_some()
2131 || part_name
2132 .as_deref()
2133 .is_some_and(|name| name.contains("/charts/"))
2134 || matches!(content_type.as_deref(), Some(CHART_CONTENT_TYPE | "application/vnd.ms-office.chartex+xml"));
2135
2136 let chart_part_name = if likely_chart_part {
2137 chart_relationship_id
2138 .as_ref()
2139 .and_then(|id| context.relationships.get(id))
2140 .or(part_name.as_ref())
2141 .cloned()
2142 } else {
2143 None
2144 };
2145
2146 let diagram = super::smartart::render_diagram(
2147 active_run_xml, width_px.unwrap_or(640.0), height_px.unwrap_or(360.0), context,
2148 );
2149 let is_diagram = diagram.is_some();
2150 let standalone_shape_svg = diagram.or_else(||
2151 render_standalone_word_shape_svg(active_run_xml, width_px, height_px, context));
2152 let contains_grouped_or_standalone_shape =
2153 is_diagram || re::get_unchecked(r"(?i)<wpg:wgp\b|<wps:wsp\b").is_match(active_run_xml);
2154 let contains_text_box_content =
2155 re::get_unchecked(r"(?i)<w:txbxContent\b").is_match(active_run_xml);
2156
2157 if contains_text_box_content && standalone_shape_svg.is_none() {
2161 let text_box_paragraphs = parse_text_box_paragraphs(active_run_xml, context);
2162 if !text_box_paragraphs.is_empty() {
2163 let text_box_src = svg_data_uri(&render_text_box_svg(
2164 &text_box_paragraphs,
2165 width_px,
2166 height_px,
2167 parse_text_box_layout(active_run_xml).as_ref(),
2168 ));
2169 return Some(ImageRunNode {
2170 presentation: None,
2171 chart_data: None,
2172 chart_part_name: None,
2173 paint_bounds: None,
2174 r#type: ImageRunNodeType::Image,
2175 block_id: None,
2176 content_nodes: None,
2177 semantic_kind: None,
2178 math_ml: None,
2179 src: Some(text_box_src),
2180 alt: Some(alt.unwrap_or_else(|| "Text box".to_string())),
2181 width_px,
2182 height_px,
2183 part_name: None,
2184 content_type: Some("image/svg+xml".to_string()),
2185 data: None,
2186 source_xml: Some(active_run_xml.to_string()),
2187 crop,
2188 css_filter,
2189 css_opacity,
2190 floating,
2191 synthetic_text_box: Some(true),
2192 text_box_text: None,
2193 });
2194 }
2195 }
2196
2197 if standalone_shape_svg.is_some()
2198 && (contains_grouped_or_standalone_shape || relationship_id.is_none())
2199 {
2200 let svg = standalone_shape_svg.as_deref().unwrap_or_default();
2201 if svg.contains("data-office-art=\"true\"") {
2202 width_px = width_px.map(|width| width.max(1.0));
2203 height_px = height_px.map(|height| height.max(1.0));
2204 }
2205 if !re::get_unchecked(r"(?i)<wpg:wgp\b").is_match(active_run_xml)
2206 && !svg.contains("data-office-art=\"true\"") {
2207 let transform = extract_balanced_tag_blocks(active_run_xml, "a:xfrm")
2208 .into_iter()
2209 .next()
2210 .unwrap_or_default();
2211 let angle = get_attribute(&transform, "rot")
2212 .and_then(|v| v.parse::<f64>().ok())
2213 .filter(|v| v.is_finite())
2214 .unwrap_or(0.0)
2215 / 60000.0;
2216 let svg_tag = regex_capture_tag(svg, r"<svg\b[^>]*>").unwrap_or_default();
2217 let source_width =
2218 width_px.or_else(|| get_attribute(&svg_tag, "width").and_then(|v| v.parse().ok()));
2219 let source_height = height_px
2220 .or_else(|| get_attribute(&svg_tag, "height").and_then(|v| v.parse().ok()));
2221 if let (Some(old_width), Some(old_height)) = (source_width, source_height) {
2222 let (sin, cos) = angle.to_radians().sin_cos();
2223 let width = (old_width * cos.abs() + old_height * sin.abs()).max(1.0);
2224 let height = (old_width * sin.abs() + old_height * cos.abs()).max(1.0);
2225 if let Some(anchor) = floating.as_mut() {
2226 if let Some(x) = anchor.x_px.as_mut() {
2227 *x += (old_width - width) / 2.0;
2228 }
2229 if let Some(y) = anchor.y_px.as_mut() {
2230 *y += (old_height - height) / 2.0;
2231 }
2232 }
2233 width_px = Some(width);
2234 height_px = Some(height);
2235 }
2236 }
2237 let paint_bounds = width_px.zip(height_px).and_then(|(width, height)| {
2238 drawing_group_paint_bounds(svg, width, height)
2239 });
2240 return Some(ImageRunNode {
2241 presentation: contains_grouped_or_standalone_shape.then(|| Box::new(crate::model::ImagePresentation {
2242 transform_applied: true,
2243 ..Default::default()
2244 })),
2245 chart_data: None,
2246 chart_part_name: None,
2247 paint_bounds,
2248 r#type: ImageRunNodeType::Image,
2249 block_id: None,
2250 content_nodes: None,
2251 semantic_kind: is_diagram.then(|| "diagram".to_string()),
2252 math_ml: None,
2253 src: Some(svg_data_uri(svg)),
2254 alt: Some(alt.unwrap_or_else(|| {
2255 if contains_text_box_content {
2256 "Text box".to_string()
2257 } else {
2258 "Shape".to_string()
2259 }
2260 })),
2261 width_px,
2262 height_px,
2263 part_name: None,
2264 content_type: Some("image/svg+xml".to_string()),
2265 data: None,
2266 source_xml: Some(active_run_xml.to_string()),
2267 crop,
2268 css_filter,
2269 css_opacity,
2270 floating,
2271 synthetic_text_box: if contains_text_box_content {
2272 Some(true)
2273 } else {
2274 None
2275 },
2276 text_box_text: None,
2277 });
2278 }
2279
2280 if likely_chart_part {
2281 if let Some(name) = chart_part_name
2282 .as_deref()
2283 .filter(|name| !context.parts.contains_key(*name))
2284 {
2285 context.push_warning(format!("Missing chart part {name}"));
2286 }
2287 if let Some((chart_src, chart_data)) = chart_part_name
2288 .as_deref()
2289 .and_then(|name| crate::chart::render_chart_part(name, context, width_px, height_px))
2290 {
2291 return Some(ImageRunNode {
2292 presentation: None,
2293 chart_data: Some(Box::new(chart_data)),
2294 chart_part_name,
2295 paint_bounds: None,
2296 r#type: ImageRunNodeType::Image,
2297 block_id: None,
2298 content_nodes: None,
2299 semantic_kind: None,
2300 math_ml: None,
2301 src: Some(chart_src),
2302 alt: Some(alt.unwrap_or_else(|| "Chart".to_string())),
2303 width_px,
2304 height_px,
2305 part_name: None,
2306 content_type: Some("image/svg+xml".to_string()),
2307 data: None,
2308 source_xml: Some(active_run_xml.to_string()),
2309 crop,
2310 css_filter,
2311 css_opacity,
2312 floating,
2313 synthetic_text_box: None,
2314 text_box_text: None,
2315 });
2316 }
2317 }
2318
2319 if relationship_id.is_none() && part_name.is_none() {
2320 let text_box_paragraphs = parse_text_box_paragraphs(active_run_xml, context);
2321 if text_box_paragraphs.is_empty() {
2322 return None;
2323 }
2324
2325 let text_box_src = svg_data_uri(&render_text_box_svg(
2326 &text_box_paragraphs,
2327 width_px,
2328 height_px,
2329 parse_text_box_layout(active_run_xml).as_ref(),
2330 ));
2331 return Some(ImageRunNode {
2332 presentation: None,
2333 chart_data: None,
2334 chart_part_name: None,
2335 paint_bounds: None,
2336 r#type: ImageRunNodeType::Image,
2337 block_id: None,
2338 content_nodes: None,
2339 semantic_kind: None,
2340 math_ml: None,
2341 src: Some(text_box_src),
2342 alt: Some(alt.unwrap_or_else(|| "Text box".to_string())),
2343 width_px,
2344 height_px,
2345 part_name: None,
2346 content_type: Some("image/svg+xml".to_string()),
2347 data: None,
2348 source_xml: Some(active_run_xml.to_string()),
2349 crop,
2350 css_filter,
2351 css_opacity,
2352 floating,
2353 synthetic_text_box: Some(true),
2354 text_box_text: None,
2355 });
2356 }
2357
2358 let mut src: Option<String> = part_name.as_deref().and_then(external_image_source);
2359 let mut resolved_content_type = content_type.clone();
2360 let mut resolved_css_opacity = css_opacity;
2361 if let Some(binary) = binary {
2362 let mime_type = part_name
2363 .as_deref()
2364 .and_then(|name| content_type_for_part(name, &context.content_types))
2365 .or(content_type.clone())
2366 .unwrap_or_else(|| "application/octet-stream".to_string());
2367 if is_windows_metafile_content_type(Some(&mime_type), part_name.as_deref()) {
2368 src = windows_metafile_to_svg_data_uri(binary);
2369 if src.is_some() {
2370 resolved_content_type = Some("image/svg+xml".to_string());
2371 } else {
2372 src = rasterize_windows_metafile_to_png_data_uri(binary, part_name.as_deref());
2373 if src.is_some() {
2374 resolved_content_type = Some("image/png".to_string());
2375 }
2376 }
2377 }
2378
2379 if src.is_none() {
2380 src = Some(super::util::base64_data_uri(&mime_type, binary));
2381 resolved_content_type = Some(mime_type);
2382 }
2383 }
2384
2385 let mut paint_bounds = None;
2386 let mut transform_applied = false;
2387 if let Some(source) = src.as_deref() {
2388 let paint = width_px.zip(height_px).and_then(|(width, height)| {
2389 super::picture::render_picture(active_run_xml, source, width, height, context)
2390 .or_else(|| super::vml_picture::render_vml_picture(active_run_xml, source, width, height, context))
2391 });
2392 if let Some(paint) = paint {
2393 transform_applied = true;
2394 src = Some(paint.src);
2395 resolved_content_type = Some("image/svg+xml".to_owned());
2396 paint_bounds = paint.paint_bounds;
2397 crop = None;
2398 css_filter = None;
2399 resolved_css_opacity = None;
2400 } else if let Some(duotone) = drawing_duotone_svg(active_run_xml, source, context) {
2401 src = Some(duotone);
2402 resolved_content_type = Some("image/svg+xml".to_owned());
2403 } else if matches!(resolved_content_type.as_deref(), Some("image/tiff" | "image/tif")) {
2404 if let Some(normalized) = rust_office_art::normalize_raster(source) {
2405 src = Some(normalized);
2406 resolved_content_type = Some("image/png".to_owned());
2407 }
2408 }
2409 }
2410 if !transform_applied && super::picture::has_picture_effects(active_run_xml) {
2411 context.push_warning("OfficeArt picture appearance retained but not fully rendered");
2412 }
2413
2414 Some(ImageRunNode {
2415 presentation: transform_applied.then(|| Box::new(crate::model::ImagePresentation {
2416 transform_applied: true,
2417 ..Default::default()
2418 })),
2419 chart_data: chart_part_name.as_ref().and_then(|name| {
2420 rust_office_charts::parse_chart_xml(&context.parts.get(name)?.content).map(Box::new)
2421 }),
2422 chart_part_name,
2423 paint_bounds,
2424 r#type: ImageRunNodeType::Image,
2425 block_id: None,
2426 content_nodes: None,
2427 semantic_kind: None,
2428 math_ml: None,
2429 src,
2430 alt,
2431 width_px,
2432 height_px,
2433 part_name,
2434 content_type: resolved_content_type,
2435 data: binary.map(|bytes| bytes.to_vec()),
2436 source_xml: Some(active_run_xml.to_string()),
2437 crop,
2438 css_filter,
2439 css_opacity: resolved_css_opacity,
2440 floating,
2441 synthetic_text_box: None,
2442 text_box_text: None,
2443 })
2444}
2445
2446pub fn parse_run_images(
2447 run_xml: &str,
2448 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2449) -> Vec<ImageRunNode> {
2450 parse_run_image_tokens(run_xml, context)
2451 .into_iter()
2452 .map(|(_, image)| image)
2453 .collect()
2454}
2455
2456fn alternate_chart_previews(run_xml: &str) -> std::collections::HashMap<usize, (&str, &str)> {
2457 let mut previews = std::collections::HashMap::new();
2458 let mut removed = 0;
2460 for range in crate::xml::extract_balanced_tag_ranges(run_xml, "mc:AlternateContent") {
2461 let alternate = &run_xml[range.start..range.end];
2462 let selected = prefer_alternate_content_choice(alternate);
2463 let offset = range.start - removed;
2464 removed += alternate.len().saturating_sub(selected.len());
2465 let Some(choice) = crate::xml::direct_child_tag(alternate, "mc:Choice") else {
2466 continue;
2467 };
2468 let Some(drawing) = crate::xml::direct_child_tag(choice, "w:drawing") else {
2469 continue;
2470 };
2471 if selected.trim() != drawing {
2472 continue;
2473 }
2474 let Some(fallback) = crate::xml::direct_child_tag(alternate, "mc:Fallback") else {
2475 continue;
2476 };
2477 let ranges = crate::xml::extract_direct_child_tag_ranges(fallback);
2478 if let [item] = ranges.as_slice() {
2479 if matches!(item.tag_name.as_str(), "w:drawing" | "w:pict") {
2480 let start = offset + selected.len() - selected.trim_start().len();
2481 previews.insert(start, (alternate, &fallback[item.start..item.end]));
2482 }
2483 }
2484 }
2485 previews
2486}
2487
2488pub fn parse_run_image_tokens(
2489 run_xml: &str,
2490 context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2491) -> Vec<(usize, ImageRunNode)> {
2492 if !memchr::memchr_iter(b'<', run_xml.as_bytes()).any(|position| {
2493 let tag = &run_xml[position + 1..];
2494 ["a:", "wp:", "v:", "c:", "cx:", "wps:", "wpg:", "w:txbxContent"]
2495 .iter()
2496 .any(|prefix| super::scan::starts_with_ignore_ascii_case(tag, prefix))
2497 }) {
2498 return Vec::new();
2499 }
2500 let original_run = run_xml;
2501 let active_run_xml = prefer_alternate_content_choice(run_xml);
2502 let run_xml = active_run_xml.as_ref();
2503 let candidate_ranges = extract_balanced_tag_blocks_in_order(
2504 run_xml,
2505 &["mc:AlternateContent", "w:drawing", "w:pict", "w:object"],
2506 );
2507 let candidate_xml_blocks: Vec<(usize, &str)> = if candidate_ranges.is_empty() {
2508 vec![(0, run_xml)]
2509 } else {
2510 candidate_ranges
2511 .iter()
2512 .map(|range| (range.start, &run_xml[range.start..range.end]))
2513 .collect()
2514 };
2515
2516 let mut images = Vec::new();
2517 let mut previews = None;
2518 for (source_start, candidate_xml) in candidate_xml_blocks {
2519 let mut image = parse_run_image_block(candidate_xml, context);
2520 let requires_preview = image.as_ref().is_none_or(|image| {
2521 image.src.is_none() || matches!(image.content_type.as_deref(),
2522 Some("image/wmf" | "image/x-wmf" | "image/emf" | "image/x-emf" | "image/x-pict" | "image/vnd.ms-photo"))
2523 });
2524 if requires_preview && run_xml.len() < original_run.len() {
2525 if let Some(&(alternate, fallback)) = previews
2526 .get_or_insert_with(|| alternate_chart_previews(original_run))
2527 .get(&source_start)
2528 {
2529 if let Some(mut preview) = parse_run_image_block(fallback, context).filter(|image| image.src.is_some()) {
2530 if let Some(original) = image {
2531 preview.chart_data = original.chart_data;
2532 preview.chart_part_name = original.chart_part_name;
2533 }
2534 preview.source_xml = Some(alternate.to_owned());
2535 let transform_applied = preview.presentation.as_ref().is_some_and(|value| value.transform_applied);
2536 preview.presentation = super::image_properties::parse_image_presentation(alternate, context);
2537 if let Some(presentation) = preview.presentation.as_mut() {
2538 presentation.transform_applied = transform_applied;
2539 }
2540 image = Some(preview);
2541 }
2542 }
2543 }
2544 if let Some(image) = image {
2545 images.push((source_start, image));
2546 }
2547 }
2548
2549 images
2550}
2551
2552#[cfg(test)]
2553mod tests {
2554 use super::*;
2555
2556 fn rectangular_bitmap_type() -> &'static str {
2557 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>"#
2558 }
2559
2560 #[test]
2561 fn bitmap_type_declarations_reject_geometry_and_paint_overrides() {
2562 let xml = rectangular_bitmap_type();
2563 assert!(vml_bitmap_rectangle_type(xml));
2564 for changed in [
2565 xml.replace("filled=\"f\"", "filled=\"t\""),
2566 xml.replace("stroked=\"f\"", "stroked=\"t\""),
2567 xml.replace("21600 pixelWidth", "10800 pixelWidth"),
2568 xml.replace("<v:shapetype ", "<v:shapetype opacity=\"0.5\" "),
2569 xml.replace("</v:shapetype>", "<v:fill color=\"red\"/></v:shapetype>"),
2570 ] {
2571 assert!(!vml_bitmap_rectangle_type(&changed));
2572 }
2573 }
2574
2575 #[test]
2576 fn rectangular_textbox_declarations_reject_inherited_effects() {
2577 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>"#;
2578 assert!(vml_textbox_rectangle_type(xml));
2579 for changed in [
2580 xml.replace("<v:shapetype ", "<v:shapetype fillcolor=\"red\" "),
2581 xml.replace("21600r21600", "21600r10800"),
2582 xml.replace("joinstyle=\"miter\"", "joinstyle=\"round\""),
2583 xml.replace("</v:shapetype>", "<v:shadow on=\"t\"/></v:shapetype>"),
2584 ] {
2585 assert!(!vml_textbox_rectangle_type(&changed));
2586 }
2587 }
2588
2589 #[test]
2590 fn identity_groups_preserve_leaf_order_and_overflow_geometry() {
2591 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>"#;
2592 let leaves = vml_group_leaves(xml).unwrap();
2593 assert_eq!(leaves.len(), 3);
2594 let ids: Vec<_> = leaves
2595 .iter()
2596 .map(|range| get_attribute(&xml[range.start..range.end], "id").unwrap())
2597 .collect();
2598 assert_eq!(ids, ["first", "middle", "last"]);
2599 let last = &xml[leaves[2].start..leaves[2].end];
2600 assert_eq!(get_attribute(last, "from").as_deref(), Some("-20,70"));
2601 assert_eq!(get_attribute(last, "to").as_deref(), Some("240,70"));
2602 for changed in [
2603 xml.replace("coordorigin=\"10,20\"", "coordorigin=\"0,0\""),
2604 xml.replace("coordsize=\"100,200\"", "coordsize=\"200,200\""),
2605 xml.replace("alt=\"\"", "alt=\"label\""),
2606 xml.replace("left:10", "rotation:90;left:10"),
2607 xml.replace("left:-5", "z-index:1;left:-5"),
2608 xml.replace("alt=\"\"", "filled=\"t\" alt=\"\""),
2609 ] {
2610 assert!(vml_group_leaves(&changed).is_none());
2611 }
2612 }
2613
2614 #[test]
2615 fn canvas_hit_testing_requires_exact_nonvisual_structure_and_type() {
2616 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>"##;
2617 assert!(vml_canvas_hit_test_shape(xml, (0.0, 0.0), (100.0, 80.0)));
2618 for changed in [
2619 xml.replace("<v:shape ", "<v:shape filled=\"t\" "),
2620 xml.replace("width:100", "width:101"),
2621 xml.replace("<v:fill ", "<v:fill color=\"red\" "),
2622 xml.replace("</v:shape>", "<v:imagedata r:id=\"image\"/></v:shape>"),
2623 ] {
2624 assert!(!vml_canvas_hit_test_shape(
2625 &changed,
2626 (0.0, 0.0),
2627 (100.0, 80.0)
2628 ));
2629 }
2630 let definition = rectangular_bitmap_type();
2631 assert!(vml_declares_unpainted_bitmap(definition));
2632 assert!(!vml_declares_unpainted_bitmap(xml));
2633 assert!(!vml_declares_unpainted_bitmap(&format!(
2634 "{definition}{}",
2635 definition.replace("stroked=\"f\"", "stroked=\"t\"")
2636 )));
2637 }
2638
2639 #[test]
2640 fn nonvisual_properties_and_bitmap_formats_are_bounded() {
2641 assert!(vml_editing_lock(
2642 r#"<o:lock v:ext="edit" aspectratio="f"/>"#
2643 ));
2644 assert!(!vml_editing_lock(
2645 r#"<o:lock v:ext="view" aspectratio="f"/>"#
2646 ));
2647 assert!(!vml_editing_lock(r#"<o:lock v:ext="edit" rotation="t"/>"#));
2648 assert!(vml_no_wrap(r#"<w10:wrap type="none"/>"#));
2649 assert!(!vml_no_wrap(r#"<w10:wrap type="none" anchorx="page"/>"#));
2650 assert!(!vml_no_wrap(r#"<w10:wrap type="square"/>"#));
2651 assert!(vml_browser_bitmap_src(
2652 "data:image/png;base64,AA==",
2653 None,
2654 None
2655 ));
2656 assert!(vml_browser_bitmap_src(
2657 "data:image/svg+xml;charset=utf-8,%3Csvg",
2658 None,
2659 None
2660 ));
2661 assert!(!vml_browser_bitmap_src(
2662 "data:image/x-emf;base64,AA==",
2663 None,
2664 None
2665 ));
2666 assert!(!vml_browser_bitmap_src(
2667 "data:image/svg+xml;charset=utf-8,%3Csvg",
2668 Some("media/vector.emf"),
2669 Some("image/svg+xml")
2670 ));
2671 assert!(!vml_browser_bitmap_src(
2672 "data:image/png;base64,AA==",
2673 None,
2674 Some("image/x-wmf")
2675 ));
2676 }
2677
2678 #[test]
2679 fn named_paint_preflight_tracks_consumed_colors() {
2680 for (name, xml) in [
2681 ("v:rect", r#"<v:rect fillcolor="black"/>"#),
2682 ("v:rect", r#"<v:rect strokecolor="NAVY" strokeweight="1px"/>"#),
2683 ("v:line", r#"<v:line fillcolor=" green "/>"#),
2684 ("v:rect", r#"<v:rect strokecolor="black" strokeweight="0px"/>"#),
2685 ] {
2686 assert!(vml_has_active_named_paint(name, xml));
2687 }
2688 for (name, xml) in [
2689 ("v:rect", r#"<v:rect filled="f" fillcolor="black"/>"#),
2690 ("v:rect", r#"<v:rect stroked="false" strokecolor="black"/>"#),
2691 ("v:rect", r##"<v:rect fillcolor="#000000" strokecolor="000000"/>"##),
2692 ("v:rect", r#"<v:rect fillcolor="windowText"/>"#),
2693 ("v:shape", r#"<v:shape strokecolor="black"><v:imagedata/></v:shape>"#),
2694 ("v:shape", r#"<v:shape><v:fill color="black"/></v:shape>"#),
2695 ("v:shapetype", r#"<v:shapetype fillcolor="black"/>"#),
2696 ] {
2697 assert!(!vml_has_active_named_paint(name, xml));
2698 }
2699 }
2700
2701 #[test]
2702 fn grouped_bitmap_named_paint_matches_rgb_and_enables_preflight() {
2703 use crate::{package::OoxmlPart, parse::empty_style_sheet};
2704 use base64::Engine;
2705 use std::{cell::RefCell, collections::HashMap};
2706 let render = |extension: &str, color: &str, definition: &str, style: &str, wrap: &str| {
2707 let source = format!(
2708 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>"##
2709 );
2710 let name = "word/document.xml".to_string();
2711 let parts = HashMap::from([(
2712 name.clone(),
2713 OoxmlPart {
2714 name,
2715 content: format!("{definition}{source}"),
2716 },
2717 )]);
2718 let target = format!("word/media/image.{extension}");
2719 let pixels = base64::engine::general_purpose::STANDARD
2720 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
2721 .expect("valid PNG payload");
2722 let assets = HashMap::from([(target.clone(), pixels)]);
2723 let context = ParseContext {
2724 relationships: HashMap::from([("rId1".to_string(), target)]),
2725 content_types: Default::default(),
2726 parts: &parts,
2727 binary_assets: &assets,
2728 style_sheet: empty_style_sheet(),
2729 warnings: RefCell::new(Vec::new()),
2730 };
2731 vml_group_image(&source, &context)
2732 };
2733 for (name, rgb) in [
2734 ("black", "#000000"),
2735 ("NAVY", "#000080"),
2736 (" green ", "#008000"),
2737 ("silver", "#c0c0c0"),
2738 ("gray", "#808080"),
2739 ("white", "#ffffff"),
2740 ("maroon", "#800000"),
2741 ("red", "#ff0000"),
2742 ("purple", "#800080"),
2743 ("fuchsia", "#ff00ff"),
2744 ("lime", "#00ff00"),
2745 ("olive", "#808000"),
2746 ("yellow", "#ffff00"),
2747 ("blue", "#0000ff"),
2748 ("teal", "#008080"),
2749 ("aqua", "#00ffff"),
2750 ] {
2751 let named = render("png", name, "", "", "").expect("resolved named paint");
2752 let explicit = render("png", rgb, "", "", "").expect("explicit RGB paint");
2753 assert_eq!(named.src, explicit.src);
2754 assert_eq!(named.width_px, explicit.width_px);
2755 assert_eq!(named.height_px, explicit.height_px);
2756 assert_eq!(named.src.as_deref().unwrap().matches("%3Cimage%20").count(), 2);
2757 }
2758 for unresolved in [
2759 "auto", "windowText", "currentColor", "transparent", "url(#paint)", "black [0]",
2760 ] {
2761 assert!(render("png", unresolved, "", "", "").is_none());
2762 }
2763 assert!(render("emf", "black", "", "", "").is_none());
2764 assert!(render("emf", "#000000", "", "", "").is_some());
2765 let conflicting = r#"<v:shapetype id="_x0000_t75" path="m0,0l1,1xe"/>"#;
2766 assert!(render("png", "black", conflicting, "", "").is_none());
2767 assert!(render("png", "#000000", conflicting, "", "").is_some());
2768 let reference = "mso-position-horizontal-relative:char;mso-position-vertical-relative:line";
2769 for metadata in [
2770 r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#,
2771 r#"<w10:wrap type="square"/>"#,
2772 r#"<w10:wrap type="none" anchorx="page"/>"#,
2773 ] {
2774 assert!(render("png", "black", "", reference, metadata).is_none());
2775 assert!(render("png", "#000000", "", reference, metadata).is_some());
2776 }
2777 let duplicate = r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#;
2778 assert!(render("png", "black", "", "position:absolute;left:1pt", duplicate).is_none());
2779 let none = r#"<w10:wrap type="none"/>"#;
2780 let image = render("png", "black", "", reference, none).unwrap();
2781 let floating = image.floating.unwrap();
2782 assert!(floating.horizontal_relative_to.is_none());
2783 assert!(floating.vertical_relative_to.is_none());
2784 assert!(matches!(floating.wrap_type, Some(ImageWrapType::WrapNone)));
2785 }
2786
2787 #[test]
2788 fn default_rectangle_stroke_enables_the_metafile_guard_before_leaf_rendering() {
2789 use crate::{package::OoxmlPart, parse::empty_style_sheet};
2790 use std::{cell::RefCell, collections::HashMap};
2791 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>"##;
2792 let render = |extension: &str, source: &str| {
2793 let name = "word/document.xml".to_string();
2794 let parts = HashMap::from([(
2795 name.clone(),
2796 OoxmlPart {
2797 name,
2798 content: source.to_string(),
2799 },
2800 )]);
2801 let target = format!("word/media/image.{extension}");
2802 let assets = HashMap::from([(target.clone(), vec![1, 2, 3])]);
2803 let context = ParseContext {
2804 relationships: HashMap::from([("rId1".to_string(), target)]),
2805 content_types: Default::default(),
2806 parts: &parts,
2807 binary_assets: &assets,
2808 style_sheet: empty_style_sheet(),
2809 warnings: RefCell::new(Vec::new()),
2810 };
2811 vml_group_image(source, &context)
2812 };
2813 assert!(render("png", xml).is_some());
2814 assert!(render("emf", xml).is_none());
2815 assert!(render(
2816 "emf",
2817 &xml.replace("<v:rect style=", "<v:rect stroked=\"f\" style=")
2818 )
2819 .is_some());
2820 assert!(render(
2821 "emf",
2822 &xml.replace("<v:rect style=", "<v:rect strokeweight=\"1px\" style=")
2823 )
2824 .is_some());
2825 let conflicting = xml.replace(
2826 "<v:group ",
2827 "<v:shapetype id=\"_x0000_t75\" path=\"m0,0l1,1xe\"/><v:group ",
2828 );
2829 assert!(render("png", &conflicting).is_none());
2830 }
2831
2832 fn inline_group_package(
2833 style_suffix: &str,
2834 metadata: &str,
2835 expanded: bool,
2836 ) -> crate::OoxmlPackage {
2837 use base64::Engine;
2838 let lock = if expanded {
2839 r#"<o:lock v:ext="edit" aspectratio="f"/><w10:wrap type="none"/>"#
2840 } else {
2841 ""
2842 };
2843 let document = format!(
2844 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>"##,
2845 rectangular_bitmap_type()
2846 );
2847 let mut package = crate::create_minimal_docx_package(Some(&document));
2848 let rel_name = "word/_rels/document.xml.rels".to_string();
2849 package.parts.insert(rel_name.clone(), crate::OoxmlPart {
2850 name: rel_name,
2851 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(),
2852 });
2853 package.binary_assets.insert("word/media/image.png".to_string(),
2854 base64::engine::general_purpose::STANDARD
2855 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
2856 .unwrap());
2857 package
2858 }
2859
2860 fn overflow_group_package(style: &str, geometry: &str, line: &str) -> crate::OoxmlPackage {
2861 let mut package = inline_group_package("", "", false);
2862 let document = format!(
2863 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>"##,
2864 );
2865 package.parts.get_mut("word/document.xml").unwrap().content = document;
2866 package
2867 }
2868
2869
2870 fn render_style_group(package: &crate::OoxmlPackage) -> Option<ImageRunNode> {
2871 use std::{cell::RefCell, collections::HashMap};
2872 let source = extract_balanced_tag_blocks(&package.parts["word/document.xml"].content, "w:pict")
2873 .into_iter().next().unwrap();
2874 let context = ParseContext {
2875 relationships: HashMap::from([("rId1".to_string(), "word/media/image.png".to_string())]),
2876 content_types: Default::default(),
2877 parts: &package.parts,
2878 binary_assets: &package.binary_assets,
2879 style_sheet: crate::parse::empty_style_sheet(),
2880 warnings: RefCell::new(Vec::new()),
2881 };
2882 vml_group_image(&source, &context)
2883 }
2884
2885 fn style_group_package() -> crate::OoxmlPackage {
2886 overflow_group_package("width:270pt;height:105pt", r#"coordsize="360,140""#,
2887 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>"##)
2888 }
2889
2890 #[test]
2891 fn explicit_default_group_styles_preserve_payload_and_placement() {
2892 let package = style_group_package();
2893 let expected = render_style_group(&package).unwrap();
2894 assert_eq!(svg_fragment_count(&expected, "data:image/png;base64,"), 2);
2895 assert_eq!(svg_fragment_count(&expected, "Upper story"), 1);
2896 assert_eq!(svg_fragment_count(&expected, "Lower story"), 1);
2897 for style in ["mso-wrap-style:square;", "v-text-anchor:top;", "mso-wrap-style:square;v-text-anchor:top;"] {
2898 let mut explicit = package.clone();
2899 let part = explicit.parts.get_mut("word/document.xml").unwrap();
2900 part.content = part.content.replace("position:absolute;", &format!("position:absolute;{style}"));
2901 let actual = render_style_group(&explicit).unwrap();
2902 assert_eq!(actual.src, expected.src);
2903 assert_eq!(actual.width_px, expected.width_px);
2904 assert_eq!(actual.height_px, expected.height_px);
2905 assert_eq!(actual.paint_bounds, expected.paint_bounds);
2906 assert_eq!(serde_json::to_value(actual.floating).unwrap(), serde_json::to_value(&expected.floating).unwrap());
2907 }
2908 }
2909
2910 #[test]
2911 fn self_linked_group_textboxes_terminate_without_cross_box_flow() {
2912 let mut package = style_group_package();
2913 let expected = render_style_group(&package).unwrap();
2914 let part = package.parts.get_mut("word/document.xml").unwrap();
2915 for id in ["box-a", "box-b"] {
2916 part.content = part.content.replacen(r##"<v:shape type="#_x0000_t202""##,
2917 &format!(r##"<v:shape id="{id}" type="#_x0000_t202""##), 1);
2918 part.content = part.content.replacen("<v:textbox inset=",
2919 &format!(r##"<v:textbox style="mso-next-textbox:#{id};v-text-anchor:top" inset="##), 1);
2920 }
2921 let actual = render_style_group(&package).unwrap();
2922 assert_eq!(actual.src, expected.src);
2923 assert_eq!(svg_fragment_count(&actual, "data:image/png;base64,"), 2);
2924 for (from, to) in [
2925 ("mso-next-textbox:#box-a", "mso-next-textbox:#box-b"),
2926 ("mso-next-textbox:#box-a", "mso-next-textbox:#missing"),
2927 ("mso-next-textbox:#box-a", "mso-next-textbox:#"),
2928 ("mso-next-textbox:#box-a", "mso-next-textbox:box-a"),
2929 ("v-text-anchor:top", "v-text-anchor:bottom"),
2930 ("v-text-anchor:top", "mso-fit-shape-to-text:false"),
2931 ] {
2932 let mut unsupported = package.clone();
2933 let part = unsupported.parts.get_mut("word/document.xml").unwrap();
2934 part.content = part.content.replace(from, to);
2935 assert!(render_style_group(&unsupported).is_none(), "{to}");
2936 }
2937 }
2938
2939
2940 #[test]
2941 fn explicit_default_bitmap_group_exports_inline() {
2942 use crate::model::{DocNode, ParagraphChildNode};
2943 let mut package = inline_group_package("", "", false);
2944 let part = package.parts.get_mut("word/document.xml").unwrap();
2945 part.content = part.content.replace("position:absolute;", "position:absolute;mso-wrap-style:square;");
2946 let mut model = crate::build_doc_model(&package);
2947 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
2948 panic!("paragraph")
2949 };
2950 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
2951 panic!("image")
2952 };
2953 assert!(image.floating.is_none());
2954 assert_eq!(svg_fragment_count(image, "data:image/png;base64,"), 2);
2955 image.width_px = Some(160.0);
2956 image.height_px = Some(80.0);
2957 paragraph.source_xml = None;
2958 let exported = crate::serialize_doc_model(&model, Some(&package));
2959 let xml = &exported.parts["word/document.xml"].content;
2960 assert!(xml.contains("<wp:inline "));
2961 assert!(!xml.contains("<wp:anchor "));
2962 }
2963
2964 #[test]
2965 fn grouped_bitmap_root_wrap_survives_edit_and_export() {
2966 use crate::model::{DocNode, ParagraphChildNode};
2967 for (kind, expected, export_tag) in [
2968 ("topAndBottom", ImageWrapType::TopAndBottom, "<wp:wrapTopAndBottom"),
2969 ("square", ImageWrapType::Square, "<wp:wrapSquare"),
2970 ("tight", ImageWrapType::Tight, "<wp:wrapTight"),
2971 ("through", ImageWrapType::Through, "<wp:wrapThrough"),
2972 ("none", ImageWrapType::WrapNone, "<wp:wrapNone"),
2973 ] {
2974 let metadata = format!(r#"<w10:wrap type="{kind}" side="right"/>"#);
2975 let package = inline_group_package(";position:absolute;left:12px;top:8px", &metadata, true);
2976 let mut model = crate::build_doc_model(&package);
2977 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
2978 panic!("paragraph")
2979 };
2980 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
2981 panic!("image")
2982 };
2983 assert_eq!(svg_fragment_count(image, "data:image/png;base64,"), 2);
2984 let floating = image.floating.as_ref().unwrap();
2985 assert_eq!(floating.wrap_type, Some(expected.clone()), "{kind}");
2986 assert_eq!(floating.wrap_text, Some(ImageWrapText::Right));
2987 assert_eq!((floating.x_px, floating.y_px), (Some(12.0), Some(8.0)));
2988 image.width_px = Some(160.0);
2989 image.height_px = Some(80.0);
2990 paragraph.source_xml = None;
2991 let exported = crate::serialize_doc_model(&model, Some(&package));
2992 let xml = &exported.parts["word/document.xml"].content;
2993 assert!(xml.contains("<wp:anchor "));
2994 assert!(xml.contains(export_tag), "{kind}");
2995 let reparsed = crate::build_doc_model(&exported);
2996 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
2997 panic!("paragraph")
2998 };
2999 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3000 panic!("image")
3001 };
3002 assert_eq!(image.floating.as_ref().unwrap().wrap_type, Some(expected), "{kind}");
3003 }
3004 }
3005
3006 #[test]
3007 fn group_wrap_metadata_creates_anchor_without_position_hints() {
3008 let mut package = style_group_package();
3009 let part = package.parts.get_mut("word/document.xml").unwrap();
3010 part.content = part.content.replace("</v:group>", r#"<w10:wrap type="topAndBottom"/></v:group>"#);
3011 let image = render_style_group(&package).unwrap();
3012 let floating = image.floating.as_ref().unwrap();
3013 assert_eq!(floating.wrap_type, Some(ImageWrapType::TopAndBottom));
3014 assert_eq!((floating.x_px, floating.y_px), (None, None));
3015 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 2);
3016 }
3017
3018 fn first_group_image(package: &crate::OoxmlPackage) -> ImageRunNode {
3019 let model = crate::build_doc_model(package);
3020 let crate::model::DocNode::Paragraph(paragraph) = &model.nodes[0] else {
3021 panic!("paragraph")
3022 };
3023 let crate::model::ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3024 panic!("image")
3025 };
3026 (**image).clone()
3027 }
3028
3029 fn svg_fragment_count(image: &ImageRunNode, fragment: &str) -> usize {
3030 let encoded = svg_data_uri(fragment);
3031 image.src.as_ref().unwrap().matches(encoded.split_once(',').unwrap().1).count()
3032 }
3033
3034 #[test]
3035 fn inline_overflow_preserves_logical_frame_payload_and_stories() {
3036 let package = overflow_group_package("width:270pt;height:105pt", r#"coordorigin="0,0" coordsize="360,140""#,
3037 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3038 let image = first_group_image(&package);
3039 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3040 assert!(image.floating.is_none());
3041 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3042 x_px: -0.5, y_px: 0.0, width_px: 361.0, height_px: 140.0,
3043 layout_width_px: 360.0, layout_height_px: 140.0,
3044 }));
3045 assert_eq!(svg_fragment_count(&image, r#"viewBox="-0.5 0 361 140""#), 1);
3046 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3047 let stories = image.content_nodes.as_ref().unwrap();
3048 assert_eq!(stories.len(), 2);
3049 for (story, expected) in stories.iter().zip(["Upper story", "Lower story"]) {
3050 let crate::model::DocNode::Paragraph(paragraph) = story else {
3051 panic!("story paragraph")
3052 };
3053 let crate::model::ParagraphChildNode::Text(text) = ¶graph.children[0] else {
3054 panic!("story text")
3055 };
3056 assert_eq!(text.text, expected);
3057 }
3058 let encoded = serde_json::to_value(&image).unwrap();
3059 assert_eq!(encoded["paintBounds"]["xPx"], -0.5);
3060 assert_eq!(serde_json::from_value::<ImageRunNode>(encoded).unwrap(), image);
3061 }
3062
3063 #[test]
3064 fn inline_overflow_maps_nonzero_coordinate_origin_and_nonunit_scale() {
3065 let package = overflow_group_package("width:540pt;height:420pt", r#"coordorigin="10,20" coordsize="360,140""#,
3066 r##"<v:line from="10,20" to="370,160" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3067 let image = first_group_image(&package);
3068 assert_eq!((image.width_px, image.height_px), (Some(720.0), Some(560.0)));
3069 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3070 x_px: -0.5, y_px: -0.5, width_px: 721.0, height_px: 561.0,
3071 layout_width_px: 720.0, layout_height_px: 560.0,
3072 }));
3073 assert_eq!(svg_fragment_count(&image, r#"viewBox="9.75 19.875 360.5 140.25""#), 1);
3074 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3075 assert!(image.floating.is_none());
3076 }
3077
3078 #[test]
3079 fn floating_overflow_keeps_existing_anchor_and_canvas_contract() {
3080 let package = overflow_group_package("position:absolute;left:12px;top:8px;width:270pt;height:105pt", r#"coordsize="360,140""#,
3081 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3082 let image = first_group_image(&package);
3083 assert_eq!((image.width_px, image.height_px), (Some(361.0), Some(140.0)));
3084 assert!(image.paint_bounds.is_none());
3085 let floating = image.floating.as_ref().unwrap();
3086 assert_eq!((floating.x_px, floating.y_px), (Some(11.5), Some(8.0)));
3087 assert!(serde_json::to_value(&image).unwrap().get("paintBounds").is_none());
3088 }
3089
3090 #[test]
3091 fn in_frame_inline_group_omits_paint_metadata() {
3092 let package = overflow_group_package("width:270pt;height:105pt", r#"coordsize="360,140""#,
3093 r##"<v:line from="1,120" to="359,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3094 let image = first_group_image(&package);
3095 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3096 assert!(image.paint_bounds.is_none());
3097 assert!(image.floating.is_none());
3098 assert!(serde_json::to_value(&image).unwrap().get("paintBounds").is_none());
3099 }
3100
3101 #[test]
3102 fn inline_reference_normalization_precedes_overflow_mapping_only_in_admitted_domain() {
3103 let mut package = overflow_group_package("width:270pt;height:105pt;mso-position-horizontal-relative:char;mso-position-vertical-relative:line", r#"coordsize="360,140""#,
3104 r##"<v:line from="0,120" to="360,120" strokecolor="#3355aa" strokeweight="0.75pt"/>"##);
3105 let part = package.parts.get_mut("word/document.xml").unwrap();
3106 part.content = part.content.replace("</v:group>", "<o:lock v:ext=\"edit\" aspectratio=\"f\"/></v:group>");
3107 let image = first_group_image(&package);
3108 assert!(image.paint_bounds.is_some());
3109 assert!(image.floating.is_none());
3110 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3111 }
3112
3113 #[test]
3114 fn single_bitmap_overflow_keeps_inline_frame_and_payload() {
3115 let mut package = inline_group_package("", "", false);
3116 let document = package.parts.get_mut("word/document.xml").unwrap();
3117 let start = document.content.find("<v:group ").unwrap();
3118 let end = document.content.find("</v:group>").unwrap() + "</v:group>".len();
3119 document.content.replace_range(start..end,
3120 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>"##);
3121 let image = first_group_image(&package);
3122 assert_eq!((image.width_px, image.height_px), (Some(360.0), Some(140.0)));
3123 assert!(image.floating.is_none());
3124 assert_eq!(image.paint_bounds.as_deref(), Some(&ImagePaintBounds {
3125 x_px: -0.5, y_px: 0.0, width_px: 361.0, height_px: 140.0,
3126 layout_width_px: 360.0, layout_height_px: 140.0,
3127 }));
3128 assert_eq!(svg_fragment_count(&image, "data:image/png;base64,"), 1);
3129 assert!(image.content_nodes.is_none());
3130 for (find, replace) in [
3131 ("left:-0.5;top:0;width:361", "left:0;top:0;width:360"),
3132 ("width:270pt;height:105pt", "position:absolute;left:12px;top:8px;width:270pt;height:105pt"),
3133 ] {
3134 let mut unchanged = package.clone();
3135 let document = unchanged.parts.get_mut("word/document.xml").unwrap();
3136 document.content = document.content.replace(find, replace);
3137 let fallback = first_group_image(&unchanged);
3138 assert!(fallback.paint_bounds.is_none());
3139 assert_eq!(fallback.content_type.as_deref(), Some("image/png"));
3140 }
3141 let mut missing_relationship = package.clone();
3142 missing_relationship.parts.get_mut("word/_rels/document.xml.rels").unwrap().content =
3143 r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#.to_string();
3144 let model = crate::build_doc_model(&missing_relationship);
3145 assert_eq!(model.metadata.warnings.iter().filter(|warning| warning.as_str() == "Missing relationship target for rId1").count(), 1);
3146 let mut unsupported = package.clone();
3147 unsupported.parts.get_mut("word/_rels/document.xml.rels").unwrap().content =
3148 unsupported.parts["word/_rels/document.xml.rels"].content.replace("image.png", "image.wmf");
3149 let bytes = unsupported.binary_assets.remove("word/media/image.png").unwrap();
3150 unsupported.binary_assets.insert("word/media/image.wmf".to_string(), bytes);
3151 let fallback = first_group_image(&unsupported);
3152 assert!(fallback.paint_bounds.is_none());
3153 assert_ne!(fallback.content_type.as_deref(), Some("image/svg+xml"));
3154 }
3155
3156 #[test]
3157 fn edited_group_exports_inline_only_without_anchor_data() {
3158 use crate::model::{DocNode, ParagraphChildNode};
3159 for (style, metadata, expanded, anchored) in [
3160 ("", "", true, false),
3161 ("", "<w10:anchorlock/>", true, false),
3162 (";position:static", "", true, false),
3163 (";position:relative", "", true, false),
3164 (";position:absolute", "", true, true),
3165 (";left:0px;top:0px", "", true, true),
3166 (";margin-left:0;margin-top:0", "", true, true),
3167 (";mso-position-horizontal:center", "", true, true),
3168 (";mso-wrap-style:none", "", true, true),
3169 (";z-index:0", "", true, true),
3170 (";mso-wrap-distance-left:0px", "", true, true),
3171 ("", r#"<w10:wrap type="none"/>"#, true, true),
3172 ("", "", false, true),
3173 ] {
3174 let package = inline_group_package(style, metadata, expanded);
3175 let mut model = crate::build_doc_model(&package);
3176 let DocNode::Paragraph(paragraph) = &mut model.nodes[0] else {
3177 panic!("paragraph")
3178 };
3179 let ParagraphChildNode::Image(image) = &mut paragraph.children[0] else {
3180 panic!("image")
3181 };
3182 assert_eq!(image.content_type.as_deref(), Some("image/svg+xml"));
3183 assert_eq!(image.floating.is_some(), anchored, "{style} {metadata}");
3184 let restored_wrap = style.is_empty() && metadata == r#"<w10:wrap type="none"/>"#;
3185 if restored_wrap {
3186 assert_eq!(
3187 serde_json::to_value(&image.floating).unwrap(),
3188 serde_json::json!({"wrapType": "none"})
3189 );
3190 }
3191 let original_source = image.src.clone();
3192 image.width_px = Some(160.0);
3193 image.height_px = Some(80.0);
3194 assert_eq!(image.src, original_source);
3195 paragraph.source_xml = None;
3196 let exported = crate::serialize_doc_model(&model, Some(&package));
3197 let xml = &exported.parts["word/document.xml"].content;
3198 assert_eq!(xml.contains("<wp:anchor "), anchored, "{style} {metadata}");
3199 assert_eq!(xml.contains("<wp:inline "), !anchored, "{style} {metadata}");
3200 assert!(xml.contains(r#"<wp:extent cx="1524000" cy="762000"/>"#));
3201 assert!(exported.binary_assets.values().any(|bytes| {
3202 std::str::from_utf8(bytes).is_ok_and(|svg| {
3203 svg.starts_with("<svg") && svg.matches("data:image/png;base64,").count() == 2
3204 })
3205 }));
3206 if restored_wrap {
3207 assert!(xml.contains(r#"<wp:positionH relativeFrom="margin">"#));
3208 assert!(!xml.contains(r#"<wp:positionH relativeFrom="line">"#));
3209 assert!(xml.contains("<wp:wrapNone/>"));
3210 let reparsed = crate::build_doc_model(&exported);
3211 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
3212 panic!("paragraph")
3213 };
3214 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3215 panic!("image")
3216 };
3217 let floating = image.floating.as_ref().unwrap();
3218 assert_eq!(floating.horizontal_relative_to.as_deref(), Some("margin"));
3219 assert_eq!(floating.wrap_type, Some(ImageWrapType::WrapNone));
3220 assert_eq!((image.width_px, image.height_px), (Some(160.0), Some(80.0)));
3221 }
3222 if !anchored {
3223 assert!(!xml.contains("<wp:positionH"));
3224 let reparsed = crate::build_doc_model(&exported);
3225 let DocNode::Paragraph(paragraph) = &reparsed.nodes[0] else {
3226 panic!("paragraph")
3227 };
3228 let ParagraphChildNode::Image(image) = ¶graph.children[0] else {
3229 panic!("image")
3230 };
3231 assert!(image.floating.is_none());
3232 assert_eq!((image.width_px, image.height_px), (Some(160.0), Some(80.0)));
3233 }
3234 }
3235 }
3236
3237 #[test]
3238 fn reference_hint_normalization_preserves_every_anchor_field() {
3239 let style = parse_css_style_declarations("width:100pt;height:80pt;mso-position-horizontal-relative:char;mso-position-vertical-relative:line");
3240 let hints = serde_json::json!({"horizontalRelativeTo":"line","verticalRelativeTo":"line"});
3241 let floating: ImageFloating = serde_json::from_value(hints.clone()).unwrap();
3242 assert!(vml_inline_reference_hints(&style, false, Some(&floating)));
3243 assert!(!vml_inline_reference_hints(&style, true, Some(&floating)));
3244 for (field, value) in [
3245 ("xPx", serde_json::json!(0)),
3246 ("yPx", serde_json::json!(0)),
3247 ("horizontalAlign", serde_json::json!("center")),
3248 ("verticalAlign", serde_json::json!("center")),
3249 ("distLPx", serde_json::json!(0)),
3250 ("distRPx", serde_json::json!(0)),
3251 ("distTPx", serde_json::json!(0)),
3252 ("distBPx", serde_json::json!(0)),
3253 ("wrapType", serde_json::json!("none")),
3254 ("wrapText", serde_json::json!("bothSides")),
3255 ("behindDocument", serde_json::json!(false)),
3256 ("zIndex", serde_json::json!(0)),
3257 ] {
3258 let mut modified = hints.clone();
3259 modified[field] = value;
3260 let anchor: ImageFloating = serde_json::from_value(modified).unwrap();
3261 assert!(
3262 !vml_inline_reference_hints(&style, false, Some(&anchor)),
3263 "{field}"
3264 );
3265 }
3266 }
3267
3268 #[test]
3269 fn group_wrap_types_are_not_shape_type_references() {
3270 use crate::{package::OoxmlPart, parse::empty_style_sheet};
3271 use base64::Engine;
3272 use std::{cell::RefCell, collections::HashMap};
3273 let xml = format!(
3274 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>"##,
3275 rectangular_bitmap_type()
3276 );
3277 let render = |extension: &str, source: &str, part_prefix: &str| {
3278 let name = "word/document.xml".to_string();
3279 let parts = HashMap::from([(
3280 name.clone(),
3281 OoxmlPart {
3282 name,
3283 content: format!("{part_prefix}{source}"),
3284 },
3285 )]);
3286 let target = format!("word/media/image.{extension}");
3287 let pixels = base64::engine::general_purpose::STANDARD
3288 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==")
3289 .unwrap();
3290 let payload = if extension == "emf" {
3291 vec![1, 2, 3]
3292 } else {
3293 pixels
3294 };
3295 let assets = HashMap::from([(target.clone(), payload)]);
3296 let context = ParseContext {
3297 relationships: HashMap::from([("rId1".to_string(), target)]),
3298 content_types: Default::default(),
3299 parts: &parts,
3300 binary_assets: &assets,
3301 style_sheet: empty_style_sheet(),
3302 warnings: RefCell::new(Vec::new()),
3303 };
3304 vml_group_image(source, &context)
3305 };
3306 let image = render("png", &xml, "").unwrap();
3307 assert_eq!(image.src.unwrap().matches("%3Cimage%20").count(), 2);
3308 assert!(render("emf", &xml, "").is_none());
3309 let unknown_shape = xml.replacen(
3310 "left:10;width:20;height:20\"><v:imagedata",
3311 "left:10;width:20;height:20\" path=\"m0,0l1,1xe\"><v:imagedata",
3312 1,
3313 );
3314 assert!(render("png", &unknown_shape, "").is_none());
3315 assert!(render("png", &xml.replace("#_x0000_t75", "#_x0000_t999"), "").is_none());
3316 let direct = xml
3317 .replace(
3318 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>"##,
3319 "",
3320 )
3321 .replace("editas=\"canvas\" ", "")
3322 .replace("<v:shape type=", "<v:shape stroked=\"f\" strokeweight=\"1px\" type=");
3323 assert!(render("png", &direct, "").is_some());
3324 assert!(render("emf", &direct, "").is_none());
3325 let conflicting = rectangular_bitmap_type().replace("stroked=\"f\"", "stroked=\"t\"");
3326 assert!(render("png", &direct, &conflicting).is_none());
3327 for metadata in [
3328 r#"<w10:wrap type="none"/><w10:wrap type="none"/>"#,
3329 r#"<w10:wrap type="square"/>"#,
3330 r#"<w10:wrap type="none" anchorx="page"/>"#,
3331 r#"<w10:anchorlock type="none"/>"#,
3332 ] {
3333 let unsupported = direct.replace(r#"<w10:wrap type="none"/>"#, metadata);
3334 assert!(render("png", &unsupported, "").is_none());
3335 }
3336 }
3337
3338 #[test]
3339 fn compact_vml_paths_resolve_relative_coordinates() {
3340 assert_eq!(
3341 vml_svg_path("m10,20r5,-2,-3,4xe").as_deref(),
3342 Some("M10 20L15 18L12 22Z")
3343 );
3344 assert_eq!(
3345 vml_svg_path("m10,l,,,20r5,l5,30r10,l15,20,15,xe").as_deref(),
3346 Some("M10 0L0 0L0 20L5 20L5 30L15 30L15 20L15 0Z")
3347 );
3348 assert_eq!(
3349 vml_svg_path("m10,20r5,5xr2,3e").as_deref(),
3350 Some("M10 20L15 25ZL12 23")
3351 );
3352 assert_eq!(
3353 vml_svg_path("m0,0c10,10,,,25,13e").as_deref(),
3354 Some("M0 0C10 10 0 0 25 13")
3355 );
3356 assert!(vml_svg_path("m0,0r1e").is_none());
3357 assert!(vml_svg_path("m0,0r@1,@2e").is_none());
3358 assert!(vml_svg_path("m0,0q1,2e").is_none());
3359 }
3360
3361 #[test]
3362 fn explicit_vml_paths_keep_their_serialization() {
3363 assert_eq!(
3364 vml_svg_path("m 1,2 l 3,4 5,6 x e").as_deref(),
3365 Some("M1,2L3,4 5,6Z")
3366 );
3367 assert_eq!(vml_pixels("0"), Some(0.0));
3368 assert_eq!(vml_pixels("12.5"), Some(12.5));
3369 assert_eq!(vml_pixels("3pt"), Some(4.0));
3370 }
3371
3372 #[test]
3373 fn vml_wrap_element_overrides_style_and_preserves_side() {
3374 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>"#;
3375 let floating = parse_vml_floating_anchor_from_run_xml(xml).expect("VML anchor");
3376 assert_eq!(floating.wrap_type, Some(ImageWrapType::WrapNone));
3377 assert_eq!(floating.wrap_text, Some(ImageWrapText::Right));
3378 assert_eq!(floating.z_index, Some(-1));
3379 assert_eq!(floating.behind_document, Some(true));
3380 assert!(floating.x_px.is_some_and(|x| (x + 80.0).abs() < 0.01));
3381 assert!(floating.y_px.is_some_and(|y| (y + 60.0).abs() < 0.01));
3382 }
3383
3384 #[test]
3385 fn outer_vml_size_wins_over_nested_drawing_extent() {
3386 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>"#;
3387 let (width, height, _) = resolve_run_image_layout(xml);
3388 assert!(width.is_some_and(|value| (value - 744.0).abs() < 0.01));
3389 assert!(height.is_some_and(|value| (value - 984.0).abs() < 0.01));
3390 }
3391}
3392
3393#[cfg(test)]
3394mod shared_vml_layout_style_tests {
3395 use super::*;
3396
3397 #[test]
3398 fn first_shape_with_missing_or_empty_style_blocks_later_shapes() {
3399 for first in [r#"<v:shape/>"#, r#"<v:shape style=""/>"#, r#"<v:shape style=" "/>"#] {
3400 let xml = format!(r#"{first}<v:shape style="width:40px;left:12px"/>"#);
3401 let (width, height, floating) = resolve_run_image_layout(&xml);
3402 assert_eq!((width, height), (None, None));
3403 assert!(floating.is_none());
3404 assert_eq!(parse_vml_size(&xml), (None, None));
3405 assert!(parse_vml_floating_anchor_from_run_xml(&xml).is_none());
3406 }
3407 }
3408
3409 #[test]
3410 fn dimension_and_position_declaration_precedence_remains_distinct() {
3411 let xml = r#"<v:shape style="width:8px;width:16px;height:12pt;left:1px;left:2px"/>"#;
3412 let (width, height, floating) = resolve_run_image_layout(xml);
3413 assert_eq!((width, height), (Some(8.0), Some(16.0)));
3414 assert_eq!(floating.unwrap().x_px, Some(2.0));
3415 assert_eq!(parse_vml_size(xml), (Some(8.0), Some(16.0)));
3416 assert_eq!(parse_vml_floating_anchor_from_run_xml(xml).unwrap().x_px, Some(2.0));
3417 }
3418
3419 #[test]
3420 fn case_folding_and_existing_token_boundaries_select_the_first_shape() {
3421 for name in ["v:shape", "V:SHAPE", "v:ſhape", "v:shapeλ", "v:shape-extra"] {
3422 let xml = format!(r#"<{name} style="width:7px;left:3px"/><v:shape style="width:9px;left:5px"/>"#);
3423 let (width, _, floating) = resolve_run_image_layout(&xml);
3424 assert_eq!(width, Some(7.0), "{name}");
3425 assert_eq!(floating.unwrap().x_px, Some(3.0), "{name}");
3426 }
3427 let xml = r#"<v:shape_extra style="width:7px;left:3px"/><v:shape style="width:9px;left:5px"/>"#;
3428 let (width, _, floating) = resolve_run_image_layout(xml);
3429 assert_eq!(width, Some(9.0));
3430 assert_eq!(floating.unwrap().x_px, Some(5.0));
3431 }
3432
3433 #[test]
3434 fn vml_dimensions_override_drawing_extent_without_changing_anchor_priority() {
3435 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>"#;
3436 let (width, height, floating) = resolve_run_image_layout(xml);
3437 assert_eq!((width, height), (Some(8.0), Some(9.0)));
3438 let floating = floating.unwrap();
3439 assert_eq!(floating.x_px, Some(10.0));
3440 assert_eq!(floating.horizontal_relative_to.as_deref(), Some("page"));
3441 }
3442
3443 #[test]
3444 fn absent_vml_dimensions_keep_extent_and_empty_anchor_falls_back() {
3445 let xml = r#"<v:shape style="left:17px"/><wp:extent cx="952500" cy="1905000"/><wp:anchor/>"#;
3446 let (width, height, floating) = resolve_run_image_layout(xml);
3447 assert_eq!((width, height), (Some(100.0), Some(200.0)));
3448 assert_eq!(floating.unwrap().x_px, Some(17.0));
3449 }
3450
3451 #[test]
3452 fn wrap_selection_keeps_whole_run_scope() {
3453 let xml = r#"<w10:wrap type="tight" side="right"/><v:shape style="width:8px;left:3px"><w10:wrap type="square" side="left"/></v:shape>"#;
3454 let (width, _, floating) = resolve_run_image_layout(xml);
3455 assert_eq!(width, Some(8.0));
3456 let floating = floating.unwrap();
3457 assert!(matches!(floating.wrap_type, Some(ImageWrapType::Tight)));
3458 assert!(matches!(floating.wrap_text, Some(ImageWrapText::Right)));
3459 }
3460}