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