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