1use std::fmt;
2use std::io::Write;
3
4use oxml_core::OxmlError;
5use oxml_core::raw_xml::{capture_element, capture_empty_element};
6use oxml_core::xml::{get_attr, local_name, matches_local_name};
7use quick_xml::events::{BytesEnd, BytesStart, Event};
8use quick_xml::{Reader, Writer};
9
10use crate::color::ColorError;
11use crate::fill::FillError;
12use crate::order::OrderedRawChildren;
13
14const MAX_TEXT_SPACING_PERCENT: i32 = 13_200_000;
15
16#[derive(Debug)]
18pub enum TextError {
19 Xml(OxmlError),
20 Color(ColorError),
21 Fill(FillError),
22 UnexpectedElement(String),
23 MissingAttribute {
24 element: String,
25 attribute: String,
26 },
27 MissingBodyProperties,
28 MissingParagraph,
29 DuplicateElement(String),
30 InvalidAttribute {
31 element: String,
32 attribute: String,
33 value: String,
34 },
35}
36
37impl fmt::Display for TextError {
38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Self::Xml(error) => error.fmt(formatter),
41 Self::Color(error) => error.fmt(formatter),
42 Self::Fill(error) => error.fmt(formatter),
43 Self::UnexpectedElement(element) => {
44 write!(formatter, "unexpected DrawingML text element: {element}")
45 }
46 Self::MissingAttribute { element, attribute } => {
47 write!(formatter, "DrawingML {element} requires @{attribute}")
48 }
49 Self::MissingBodyProperties => write!(formatter, "DrawingML txBody requires bodyPr"),
50 Self::MissingParagraph => write!(formatter, "DrawingML txBody requires at least one p"),
51 Self::DuplicateElement(element) => {
52 write!(formatter, "DrawingML text contains duplicate {element}")
53 }
54 Self::InvalidAttribute {
55 element,
56 attribute,
57 value,
58 } => write!(
59 formatter,
60 "DrawingML {element} has invalid @{attribute}: {value}"
61 ),
62 }
63 }
64}
65
66impl std::error::Error for TextError {
67 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
68 match self {
69 Self::Xml(error) => Some(error),
70 Self::Color(error) => Some(error),
71 Self::Fill(error) => Some(error),
72 _ => None,
73 }
74 }
75}
76
77impl From<OxmlError> for TextError {
78 fn from(error: OxmlError) -> Self {
79 Self::Xml(error)
80 }
81}
82
83impl From<ColorError> for TextError {
84 fn from(error: ColorError) -> Self {
85 Self::Color(error)
86 }
87}
88
89impl From<FillError> for TextError {
90 fn from(error: FillError) -> Self {
91 Self::Fill(error)
92 }
93}
94
95pub type Result<T> = std::result::Result<T, TextError>;
96
97#[derive(Clone, Debug, Eq, PartialEq)]
99pub enum Coordinate32Value {
100 Emu(i32),
101 UniversalMeasure(String),
102}
103
104impl Coordinate32Value {
105 fn parse(element: &str, attribute: &str, value: String) -> Result<Self> {
106 if let Ok(value) = value.parse::<i32>() {
107 return Ok(Self::Emu(value));
108 }
109 if is_universal_measure(&value) {
110 return Ok(Self::UniversalMeasure(value));
111 }
112 Err(invalid_attribute(element, attribute, value))
113 }
114
115 fn as_xml(&self) -> Result<String> {
116 match self {
117 Self::Emu(value) => Ok(value.to_string()),
118 Self::UniversalMeasure(value) if is_universal_measure(value) => Ok(value.clone()),
119 Self::UniversalMeasure(value) => {
120 Err(invalid_attribute("bodyPr", "inset", value.clone()))
121 }
122 }
123 }
124}
125
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum TextAnchor {
129 Top,
130 Center,
131 Bottom,
132 Justified,
133 Distributed,
134}
135
136impl TextAnchor {
137 fn parse(value: &str) -> Option<Self> {
138 match value {
139 "t" => Some(Self::Top),
140 "ctr" => Some(Self::Center),
141 "b" => Some(Self::Bottom),
142 "just" => Some(Self::Justified),
143 "dist" => Some(Self::Distributed),
144 _ => None,
145 }
146 }
147
148 const fn as_str(self) -> &'static str {
149 match self {
150 Self::Top => "t",
151 Self::Center => "ctr",
152 Self::Bottom => "b",
153 Self::Justified => "just",
154 Self::Distributed => "dist",
155 }
156 }
157}
158
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum TextWrap {
162 None,
163 Square,
164}
165
166impl TextWrap {
167 fn parse(value: &str) -> Option<Self> {
168 match value {
169 "none" => Some(Self::None),
170 "square" => Some(Self::Square),
171 _ => None,
172 }
173 }
174
175 const fn as_str(self) -> &'static str {
176 match self {
177 Self::None => "none",
178 Self::Square => "square",
179 }
180 }
181}
182
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum TextVertical {
186 Horizontal,
187 Vertical,
188 Vertical270,
189 WordArtVertical,
190 EastAsianVertical,
191 MongolianVertical,
192 WordArtVerticalRtl,
193}
194
195impl TextVertical {
196 fn parse(value: &str) -> Option<Self> {
197 match value {
198 "horz" => Some(Self::Horizontal),
199 "vert" => Some(Self::Vertical),
200 "vert270" => Some(Self::Vertical270),
201 "wordArtVert" => Some(Self::WordArtVertical),
202 "eaVert" => Some(Self::EastAsianVertical),
203 "mongolianVert" => Some(Self::MongolianVertical),
204 "wordArtVertRtl" => Some(Self::WordArtVerticalRtl),
205 _ => None,
206 }
207 }
208
209 const fn as_str(self) -> &'static str {
210 match self {
211 Self::Horizontal => "horz",
212 Self::Vertical => "vert",
213 Self::Vertical270 => "vert270",
214 Self::WordArtVertical => "wordArtVert",
215 Self::EastAsianVertical => "eaVert",
216 Self::MongolianVertical => "mongolianVert",
217 Self::WordArtVerticalRtl => "wordArtVertRtl",
218 }
219 }
220}
221
222#[derive(Clone, Debug, Default, Eq, PartialEq)]
224pub struct NormalAutofit {
225 pub font_scale: Option<String>,
226 pub line_spacing_reduction: Option<String>,
227}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
231pub enum TextAutofit {
232 NoAutofit,
233 Normal(NormalAutofit),
234 ShapeAutofit,
235}
236
237impl TextAutofit {
238 fn from_xml(xml: &[u8]) -> Result<Self> {
239 let mut reader = Reader::from_reader(xml);
240 let mut buffer = Vec::new();
241 loop {
242 match reader
243 .read_event_into(&mut buffer)
244 .map_err(OxmlError::from)?
245 {
246 Event::Empty(element) => return Self::from_start(&element),
247 Event::Start(element) => {
248 let autofit = Self::from_start(&element)?;
249 ensure_empty_element(&mut reader, local_name(element.name().as_ref()))?;
250 return Ok(autofit);
251 }
252 Event::Eof => {
253 return Err(TextError::UnexpectedElement("EOF".to_owned()));
254 }
255 _ => {}
256 }
257 buffer.clear();
258 }
259 }
260
261 fn from_start(start: &BytesStart<'_>) -> Result<Self> {
262 match local_name(start.name().as_ref()) {
263 b"noAutofit" => Ok(Self::NoAutofit),
264 b"spAutoFit" => Ok(Self::ShapeAutofit),
265 b"normAutofit" => {
266 let font_scale = get_attr(start, b"fontScale");
267 let line_spacing_reduction = get_attr(start, b"lnSpcReduction");
268 if let Some(value) = font_scale.as_deref() {
269 validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
270 }
271 if let Some(value) = line_spacing_reduction.as_deref() {
272 validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
273 }
274 Ok(Self::Normal(NormalAutofit {
275 font_scale,
276 line_spacing_reduction,
277 }))
278 }
279 _ => Err(TextError::UnexpectedElement(element_name(start))),
280 }
281 }
282
283 fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
284 match self {
285 Self::NoAutofit => write_empty(writer, BytesStart::new("a:noAutofit")),
286 Self::ShapeAutofit => write_empty(writer, BytesStart::new("a:spAutoFit")),
287 Self::Normal(normal) => {
288 let mut start = BytesStart::new("a:normAutofit");
289 if let Some(value) = normal.font_scale.as_deref() {
290 validate_autofit_percent("fontScale", value, PercentKind::FontScale)?;
291 start.push_attribute(("fontScale", value));
292 }
293 if let Some(value) = normal.line_spacing_reduction.as_deref() {
294 validate_autofit_percent("lnSpcReduction", value, PercentKind::LineSpacing)?;
295 start.push_attribute(("lnSpcReduction", value));
296 }
297 write_empty(writer, start)
298 }
299 }
300 }
301}
302
303#[allow(non_camel_case_types)]
305#[derive(Clone, Debug, Default, Eq, PartialEq)]
306pub struct CT_TextBodyProperties {
307 pub left_inset: Option<Coordinate32Value>,
308 pub top_inset: Option<Coordinate32Value>,
309 pub right_inset: Option<Coordinate32Value>,
310 pub bottom_inset: Option<Coordinate32Value>,
311 pub anchor: Option<TextAnchor>,
312 pub wrap: Option<TextWrap>,
313 pub vertical: Option<TextVertical>,
314 pub space_first_last_paragraph: Option<bool>,
315 pub autofit: Option<TextAutofit>,
316 raw_children: OrderedRawChildren,
317}
318
319impl CT_TextBodyProperties {
320 pub fn from_xml(xml: &[u8]) -> Result<Self> {
322 let mut reader = Reader::from_reader(xml);
323 let mut buffer = Vec::new();
324 loop {
325 match reader
326 .read_event_into(&mut buffer)
327 .map_err(OxmlError::from)?
328 {
329 Event::Start(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
330 return Self::from_element(&mut reader, &element);
331 }
332 Event::Empty(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
333 return Self::from_start(&element);
334 }
335 Event::Start(element) | Event::Empty(element) => {
336 return Err(TextError::UnexpectedElement(element_name(&element)));
337 }
338 Event::Eof => {
339 return Err(TextError::Xml(OxmlError::MissingElement(
340 "DrawingML body properties".to_owned(),
341 )));
342 }
343 _ => {}
344 }
345 buffer.clear();
346 }
347 }
348
349 pub(crate) fn from_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<Self> {
350 let mut properties = Self::from_start(start)?;
351 let mut boundary = 0;
352 let mut occurrences = [false; 5];
353 let mut buffer = Vec::new();
354 loop {
355 match reader
356 .read_event_into(&mut buffer)
357 .map_err(OxmlError::from)?
358 {
359 Event::Start(element) => {
360 let name = local_name(element.name().as_ref()).to_vec();
361 let raw = capture_element(reader, &element)?;
362 properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
363 }
364 Event::Empty(element) => {
365 let name = local_name(element.name().as_ref()).to_vec();
366 let raw = capture_empty_element(&element)?;
367 properties.capture_child(&name, raw, &mut boundary, &mut occurrences)?;
368 }
369 Event::End(element) if matches_local_name(element.name().as_ref(), b"bodyPr") => {
370 break;
371 }
372 Event::Eof => return Err(missing_end("bodyPr")),
373 _ => {}
374 }
375 buffer.clear();
376 }
377 Ok(properties)
378 }
379
380 pub(crate) fn from_start(start: &BytesStart<'_>) -> Result<Self> {
381 if !matches_local_name(start.name().as_ref(), b"bodyPr") {
382 return Err(TextError::UnexpectedElement(element_name(start)));
383 }
384 Ok(Self {
385 left_inset: parse_coordinate(start, b"lIns")?,
386 top_inset: parse_coordinate(start, b"tIns")?,
387 right_inset: parse_coordinate(start, b"rIns")?,
388 bottom_inset: parse_coordinate(start, b"bIns")?,
389 anchor: parse_enum(start, b"anchor", TextAnchor::parse)?,
390 wrap: parse_enum(start, b"wrap", TextWrap::parse)?,
391 vertical: parse_enum(start, b"vert", TextVertical::parse)?,
392 space_first_last_paragraph: parse_optional_bool(start, b"spcFirstLastPara")?,
393 ..Self::default()
394 })
395 }
396
397 fn capture_child(
398 &mut self,
399 name: &[u8],
400 raw: Vec<u8>,
401 boundary: &mut usize,
402 occurrences: &mut [bool; 5],
403 ) -> Result<()> {
404 if let Some(index) = schema_choice_index(name) {
405 if occurrences[index] {
406 return Err(TextError::DuplicateElement(
407 String::from_utf8_lossy(name).into_owned(),
408 ));
409 }
410 occurrences[index] = true;
411 }
412 if is_autofit(name) {
413 self.autofit = Some(TextAutofit::from_xml(&raw)?);
414 *boundary = (*boundary).max(2);
415 return Ok(());
416 }
417 self.raw_children.push(*boundary, raw);
418 *boundary = (*boundary).max(raw_boundary_after(name));
419 Ok(())
420 }
421
422 pub fn to_xml(&self) -> Result<Vec<u8>> {
424 let mut writer = Writer::new(Vec::new());
425 self.write_xml(&mut writer)?;
426 Ok(writer.into_inner())
427 }
428
429 pub(crate) fn write_xml<W: Write>(&self, writer: &mut Writer<W>) -> Result<()> {
430 let mut start = BytesStart::new("a:bodyPr");
431 let left_inset = self
432 .left_inset
433 .as_ref()
434 .map(Coordinate32Value::as_xml)
435 .transpose()?;
436 let top_inset = self
437 .top_inset
438 .as_ref()
439 .map(Coordinate32Value::as_xml)
440 .transpose()?;
441 let right_inset = self
442 .right_inset
443 .as_ref()
444 .map(Coordinate32Value::as_xml)
445 .transpose()?;
446 let bottom_inset = self
447 .bottom_inset
448 .as_ref()
449 .map(Coordinate32Value::as_xml)
450 .transpose()?;
451 for (name, value) in [
452 ("lIns", left_inset.as_deref()),
453 ("tIns", top_inset.as_deref()),
454 ("rIns", right_inset.as_deref()),
455 ("bIns", bottom_inset.as_deref()),
456 ] {
457 if let Some(value) = value {
458 start.push_attribute((name, value));
459 }
460 }
461 if let Some(anchor) = self.anchor {
462 start.push_attribute(("anchor", anchor.as_str()));
463 }
464 if let Some(wrap) = self.wrap {
465 start.push_attribute(("wrap", wrap.as_str()));
466 }
467 if let Some(vertical) = self.vertical {
468 start.push_attribute(("vert", vertical.as_str()));
469 }
470 if let Some(value) = self.space_first_last_paragraph {
471 start.push_attribute(("spcFirstLastPara", if value { "1" } else { "0" }));
472 }
473
474 if self.autofit.is_none() && self.raw_children.is_empty() {
475 return write_empty(writer, start);
476 }
477 writer
478 .write_event(Event::Start(start))
479 .map_err(OxmlError::from)?;
480 emit_raw(writer, self.raw_children.at(0))?;
481 emit_raw(writer, self.raw_children.at(1))?;
482 if let Some(autofit) = &self.autofit {
483 autofit.write_xml(writer)?;
484 }
485 for boundary in 2..=5 {
486 emit_raw(writer, self.raw_children.at(boundary))?;
487 }
488 writer
489 .write_event(Event::End(BytesEnd::new("a:bodyPr")))
490 .map_err(OxmlError::from)?;
491 Ok(())
492 }
493
494 pub fn raw_children(&self) -> &OrderedRawChildren {
495 &self.raw_children
496 }
497}
498
499fn parse_coordinate(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<Coordinate32Value>> {
500 get_attr(start, attribute)
501 .map(|value| Coordinate32Value::parse("bodyPr", &String::from_utf8_lossy(attribute), value))
502 .transpose()
503}
504
505fn parse_enum<T>(
506 start: &BytesStart<'_>,
507 attribute: &[u8],
508 parse: impl FnOnce(&str) -> Option<T>,
509) -> Result<Option<T>> {
510 let Some(value) = get_attr(start, attribute) else {
511 return Ok(None);
512 };
513 parse(&value)
514 .map(Some)
515 .ok_or_else(|| invalid_attribute("bodyPr", &String::from_utf8_lossy(attribute), value))
516}
517
518fn parse_optional_bool(start: &BytesStart<'_>, attribute: &[u8]) -> Result<Option<bool>> {
519 let Some(value) = get_attr(start, attribute) else {
520 return Ok(None);
521 };
522 match value.as_str() {
523 "1" | "true" => Ok(Some(true)),
524 "0" | "false" => Ok(Some(false)),
525 _ => Err(invalid_attribute(
526 "bodyPr",
527 &String::from_utf8_lossy(attribute),
528 value,
529 )),
530 }
531}
532
533fn is_autofit(name: &[u8]) -> bool {
534 matches!(name, b"noAutofit" | b"normAutofit" | b"spAutoFit")
535}
536
537fn schema_choice_index(name: &[u8]) -> Option<usize> {
538 match name {
539 b"prstTxWarp" => Some(0),
540 name if is_autofit(name) => Some(1),
541 b"scene3d" => Some(2),
542 b"sp3d" | b"flatTx" => Some(3),
543 b"extLst" => Some(4),
544 _ => None,
545 }
546}
547
548fn raw_boundary_after(name: &[u8]) -> usize {
549 match name {
550 b"prstTxWarp" => 1,
551 name if is_autofit(name) => 2,
552 b"scene3d" => 3,
553 b"sp3d" | b"flatTx" => 4,
554 b"extLst" => 5,
555 _ => 0,
556 }
557}
558
559enum PercentKind {
560 FontScale,
561 LineSpacing,
562}
563
564fn validate_autofit_percent(attribute: &str, value: &str, kind: PercentKind) -> Result<()> {
565 if is_percentage_string(value) {
566 return Ok(());
567 }
568 let integer = value
569 .parse::<i32>()
570 .map_err(|_| invalid_attribute("normAutofit", attribute, value.to_owned()))?;
571 let valid = match kind {
572 PercentKind::FontScale => (1_000..=100_000).contains(&integer),
573 PercentKind::LineSpacing => (0..=MAX_TEXT_SPACING_PERCENT).contains(&integer),
574 };
575 if valid {
576 Ok(())
577 } else {
578 Err(invalid_attribute(
579 "normAutofit",
580 attribute,
581 value.to_owned(),
582 ))
583 }
584}
585
586fn is_percentage_string(value: &str) -> bool {
587 let Some(number) = value.strip_suffix('%') else {
588 return false;
589 };
590 is_signed_decimal(number)
591}
592
593fn is_universal_measure(value: &str) -> bool {
594 if value.len() < 3 {
595 return false;
596 }
597 let (number, unit) = value.split_at(value.len() - 2);
598 matches!(unit, "mm" | "cm" | "in" | "pt" | "pc" | "pi") && is_signed_decimal(number)
599}
600
601fn is_signed_decimal(value: &str) -> bool {
602 let unsigned = value.strip_prefix('-').unwrap_or(value);
603 let mut parts = unsigned.split('.');
604 let Some(integer) = parts.next() else {
605 return false;
606 };
607 if integer.is_empty() || !integer.bytes().all(|byte| byte.is_ascii_digit()) {
608 return false;
609 }
610 if let Some(fraction) = parts.next()
611 && (fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()))
612 {
613 return false;
614 }
615 parts.next().is_none()
616}
617
618fn ensure_empty_element(reader: &mut Reader<&[u8]>, expected: &[u8]) -> Result<()> {
619 let mut buffer = Vec::new();
620 loop {
621 match reader
622 .read_event_into(&mut buffer)
623 .map_err(OxmlError::from)?
624 {
625 Event::End(element) if matches_local_name(element.name().as_ref(), expected) => {
626 return Ok(());
627 }
628 Event::Text(text) if text.iter().all(u8::is_ascii_whitespace) => {}
629 Event::Comment(_) => {}
630 Event::Eof => return Err(missing_end(&String::from_utf8_lossy(expected))),
631 Event::Start(element) | Event::Empty(element) => {
632 return Err(TextError::UnexpectedElement(element_name(&element)));
633 }
634 _ => {
635 return Err(TextError::UnexpectedElement(
636 String::from_utf8_lossy(expected).into_owned(),
637 ));
638 }
639 }
640 buffer.clear();
641 }
642}
643
644fn emit_raw<'a, W: Write>(
645 writer: &mut Writer<W>,
646 children: impl Iterator<Item = &'a [u8]>,
647) -> Result<()> {
648 for child in children {
649 writer.get_mut().write_all(child).map_err(OxmlError::from)?;
650 }
651 Ok(())
652}
653
654fn write_empty<W: Write>(writer: &mut Writer<W>, start: BytesStart<'_>) -> Result<()> {
655 writer
656 .write_event(Event::Empty(start))
657 .map_err(OxmlError::from)?;
658 Ok(())
659}
660
661fn invalid_attribute(element: &str, attribute: &str, value: String) -> TextError {
662 TextError::InvalidAttribute {
663 element: element.to_owned(),
664 attribute: attribute.to_owned(),
665 value,
666 }
667}
668
669fn element_name(element: &BytesStart<'_>) -> String {
670 String::from_utf8_lossy(element.name().as_ref()).into_owned()
671}
672
673pub(crate) fn missing_end(element: &str) -> TextError {
674 TextError::Xml(OxmlError::MissingElement(format!(
675 "closing DrawingML {element}"
676 )))
677}
678
679#[cfg(test)]
680mod tests {
681 use std::panic;
682
683 use super::{CT_TextBodyProperties, Coordinate32Value, TextAutofit};
684 use crate::text::CT_TextBody;
685
686 #[test]
687 fn every_body_property_autofit_form_round_trips_in_schema_order() {
688 let cases: &[(&[u8], &[u8])] = &[
689 (
690 br#"<q:bodyPr lIns="-2147483648" tIns="2147483647" rIns="1.25in" bIns="0" anchor="dist" wrap="square" vert="wordArtVertRtl" spcFirstLastPara="true"><x:warp/><q:prstTxWarp prst="textPlain"/><x:beforeFit/><q:noAutofit/></q:bodyPr>"#,
691 br#"<a:bodyPr lIns="-2147483648" tIns="2147483647" rIns="1.25in" bIns="0" anchor="dist" wrap="square" vert="wordArtVertRtl" spcFirstLastPara="1"><x:warp/><q:prstTxWarp prst="textPlain"/><x:beforeFit/><a:noAutofit/></a:bodyPr>"#,
692 ),
693 (
694 br#"<q:bodyPr><q:spAutoFit/></q:bodyPr>"#,
695 br#"<a:bodyPr><a:spAutoFit/></a:bodyPr>"#,
696 ),
697 (
698 br#"<q:bodyPr><q:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></q:bodyPr>"#,
699 br#"<a:bodyPr><a:normAutofit fontScale="62.500%" lnSpcReduction="20000"/></a:bodyPr>"#,
700 ),
701 ];
702
703 for (xml, expected) in cases {
704 let parsed = CT_TextBodyProperties::from_xml(xml).unwrap();
705 let written = parsed.to_xml().unwrap();
706 assert_eq!(&written, expected);
707 assert_eq!(CT_TextBodyProperties::from_xml(&written).unwrap(), parsed);
708 }
709
710 let parsed = CT_TextBodyProperties::from_xml(cases[0].0).unwrap();
711 assert_eq!(parsed.left_inset, Some(Coordinate32Value::Emu(i32::MIN)));
712 assert_eq!(parsed.space_first_last_paragraph, Some(true));
713 assert!(matches!(parsed.autofit, Some(TextAutofit::NoAutofit)));
714 }
715
716 #[test]
717 fn body_properties_preserve_unknown_children_at_their_boundaries() {
718 let xml = br#"<q:bodyPr><x:before/><q:prstTxWarp prst="textPlain"><x:warp/></q:prstTxWarp><x:beforeFit/><q:normAutofit fontScale="62500"/><x:afterFit/><q:scene3d><x:scene/></q:scene3d><x:afterScene/><q:sp3d><x:shape/></q:sp3d><x:after3d/><q:extLst><x:ext/></q:extLst><x:afterExt/></q:bodyPr>"#;
719 let written = CT_TextBodyProperties::from_xml(xml)
720 .unwrap()
721 .to_xml()
722 .unwrap();
723 assert_eq!(written, br#"<a:bodyPr><x:before/><q:prstTxWarp prst="textPlain"><x:warp/></q:prstTxWarp><x:beforeFit/><a:normAutofit fontScale="62500"/><x:afterFit/><q:scene3d><x:scene/></q:scene3d><x:afterScene/><q:sp3d><x:shape/></q:sp3d><x:after3d/><q:extLst><x:ext/></q:extLst><x:afterExt/></a:bodyPr>"#);
724 }
725
726 #[test]
727 fn malformed_body_properties_return_errors_without_panicking() {
728 let body_cases: &[&[u8]] = &[
729 br#"<q:notBodyPr/>"#,
730 br#"<q:bodyPr anchor="middle"/>"#,
731 br#"<q:bodyPr wrap="tight"/>"#,
732 br#"<q:bodyPr vert="sideways"/>"#,
733 br#"<q:bodyPr lIns="2147483648"/>"#,
734 br#"<q:bodyPr rIns="1.in"/>"#,
735 br#"<q:bodyPr spcFirstLastPara="yes"/>"#,
736 br#"<q:bodyPr><q:normAutofit fontScale="999"/></q:bodyPr>"#,
737 br#"<q:bodyPr><q:normAutofit fontScale="100001"/></q:bodyPr>"#,
738 br#"<q:bodyPr><q:normAutofit fontScale=".5%"/></q:bodyPr>"#,
739 br#"<q:bodyPr><q:normAutofit lnSpcReduction="13200001"/></q:bodyPr>"#,
740 br#"<q:bodyPr><q:noAutofit><x:child/></q:noAutofit></q:bodyPr>"#,
741 br#"<q:bodyPr><q:noAutofit/><q:spAutoFit/></q:bodyPr>"#,
742 br#"<q:bodyPr><q:sp3d/><q:flatTx/></q:bodyPr>"#,
743 ];
744 for xml in body_cases {
745 let result = panic::catch_unwind(|| CT_TextBodyProperties::from_xml(xml));
746 assert!(result.is_ok(), "body-property parser panicked");
747 assert!(result.unwrap().is_err(), "malformed body properties parsed");
748 }
749
750 let shell_cases: &[&[u8]] = &[
751 br#"<q:txBody><q:p/></q:txBody>"#,
752 br#"<q:txBody/>"#,
753 br#"<q:txBody><q:bodyPr/><q:bodyPr/><q:p/></q:txBody>"#,
754 br#"<q:txBody><q:bodyPr/><q:lstStyle/><q:lstStyle/><q:p/></q:txBody>"#,
755 ];
756 for xml in shell_cases {
757 let result = panic::catch_unwind(|| CT_TextBody::from_xml(xml));
758 assert!(result.is_ok(), "text-body parser panicked");
759 assert!(result.unwrap().is_err(), "malformed text body parsed");
760 }
761
762 let empty = CT_TextBody::from_xml(br#"<q:txBody><q:bodyPr/></q:txBody>"#).unwrap();
763 assert_eq!(empty.paragraph_count(), 0);
764 assert!(empty.to_xml().is_ok());
765
766 let mut properties = CT_TextBodyProperties {
767 left_inset: Some(Coordinate32Value::UniversalMeasure("NaNin".to_owned())),
768 ..CT_TextBodyProperties::default()
769 };
770 assert!(properties.to_xml().is_err());
771 properties.left_inset = Some(Coordinate32Value::Emu(i32::MAX));
772 assert!(properties.to_xml().is_ok());
773 }
774}