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