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