Skip to main content

rust_docx/parse/
images.rs

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    // Unsigned wrap distances can encode signed negative values.
81    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
226/// Image effects are resolved into the rendered source, with no CSS approximation.
227pub 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    // A VML textbox can contain DrawingML images. Its outer VML dimensions
548    // govern the textbox; a nested wp:extent governs only the inline image.
549    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                    &paragraphs,
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    // A grouped drawing renders through the group pipeline so sibling pictures and
2116    // vector layers survive alongside the textbox content; only a bare textbox shape
2117    // (where the group renderer yields nothing) falls back to the flat textbox SVG.
2118    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    // Image and text tokens use offsets after alternate selection.
2379    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) = &paragraph.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) = &paragraph.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) = &paragraph.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) = &paragraph.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) = &paragraph.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}