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