Skip to main content

extend_docx/parse/
images.rs

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