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