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 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
254pub 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 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 ¶graphs,
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 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) = ¶graph.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) = ¶graph.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) = ¶graph.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) = ¶graph.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) = ¶graph.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}