Skip to main content

pptx_parse/
comments.rs

1use serde::{Deserialize, Serialize};
2
3use crate::relationships::{Relationship, relationship_types};
4use crate::xml::{ParseBudget, XmlElement, serialize_xml};
5use crate::{PptxError, xml::parse_xml};
6
7pub(crate) const NS_P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
8pub(crate) const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main";
9pub(crate) const THREADING_EXT: &str = "{C676402C-5697-4E1C-873F-D02D1690AC5C}";
10pub(crate) const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/main";
11pub(crate) const NS_PC: &str = "http://schemas.microsoft.com/office/powerpoint/2013/main/command";
12pub(crate) const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
13
14pub(crate) const CT_COMMENT_AUTHORS: &str =
15    "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
16pub(crate) const CT_COMMENTS: &str =
17    "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
18pub(crate) const CT_MODERN_AUTHORS: &str = "application/vnd.ms-powerpoint.authors+xml";
19pub(crate) const CT_MODERN_COMMENTS: &str = "application/vnd.ms-powerpoint.comments+xml";
20
21pub(crate) const LEGACY_AUTHORS_PART: &str = "ppt/commentAuthors.xml";
22pub(crate) const MODERN_AUTHORS_PART: &str = "ppt/authors.xml";
23
24pub(crate) const EMU_PER_MASTER_UNIT: f64 = 1587.5;
25
26pub(crate) fn emu_to_master(emu: i64) -> i64 {
27    (emu as f64 / EMU_PER_MASTER_UNIT).round() as i64
28}
29
30pub(crate) fn master_to_emu(master: i64) -> i64 {
31    (master as f64 * EMU_PER_MASTER_UNIT).round() as i64
32}
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub enum CommentFlavor {
37    #[default]
38    Legacy,
39    Modern,
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct CommentAuthor {
45    pub id: String,
46    pub name: String,
47    #[serde(default)]
48    pub initials: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub last_index: Option<u32>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub color_index: Option<u32>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub user_id: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub provider_id: Option<String>,
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct Comment {
62    pub id: String,
63    pub author_id: String,
64    pub slide_part_path: String,
65    pub text: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub created: Option<String>,
68    pub x_emu: i64,
69    pub y_emu: i64,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub parent_id: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub status: Option<String>,
74    pub flavor: CommentFlavor,
75}
76
77pub(crate) struct SlideCommentParts {
78    pub legacy: Option<String>,
79    pub modern: Option<String>,
80}
81
82pub(crate) fn slide_comment_parts(relationships: &[Relationship]) -> SlideCommentParts {
83    SlideCommentParts {
84        legacy: resolved_by_exact_type(relationships, relationship_types::COMMENTS),
85        modern: resolved_by_exact_type(relationships, relationship_types::MODERN_COMMENTS),
86    }
87}
88
89pub(crate) fn authors_part(
90    relationships: &[Relationship],
91    flavor: CommentFlavor,
92) -> Option<String> {
93    let relationship_type = match flavor {
94        CommentFlavor::Legacy => relationship_types::COMMENT_AUTHORS,
95        CommentFlavor::Modern => relationship_types::MODERN_AUTHORS,
96    };
97    resolved_by_exact_type(relationships, relationship_type)
98}
99
100fn resolved_by_exact_type(
101    relationships: &[Relationship],
102    relationship_type: &str,
103) -> Option<String> {
104    relationships
105        .iter()
106        .find(|relationship| relationship.is_type(relationship_type))
107        .and_then(|relationship| relationship.resolved_target.clone())
108}
109
110pub(crate) fn parse_comment_authors(
111    bytes: &[u8],
112    part: &str,
113    flavor: CommentFlavor,
114    budget: &mut ParseBudget<'_>,
115) -> Result<Vec<CommentAuthor>, PptxError> {
116    let root = parse_xml(bytes, part, budget)?;
117    let (list, element) = match flavor {
118        CommentFlavor::Legacy => ("cmAuthorLst", "cmAuthor"),
119        CommentFlavor::Modern => ("authorLst", "author"),
120    };
121    if root.local_name() != list {
122        return Ok(Vec::new());
123    }
124    let mut authors = Vec::new();
125    for child in root.children_named(element) {
126        budget.charge_comment(part)?;
127        authors.push(CommentAuthor {
128            id: child.attribute_local("id").unwrap_or_default().to_owned(),
129            name: child.attribute_local("name").unwrap_or_default().to_owned(),
130            initials: child
131                .attribute_local("initials")
132                .unwrap_or_default()
133                .to_owned(),
134            last_index: unsigned_attribute(child, "lastIdx"),
135            color_index: unsigned_attribute(child, "clrIdx"),
136            user_id: child.attribute_local("userId").map(str::to_owned),
137            provider_id: child.attribute_local("providerId").map(str::to_owned),
138        });
139    }
140    Ok(authors)
141}
142
143pub(crate) fn parse_comments(
144    bytes: &[u8],
145    part: &str,
146    slide_part_path: &str,
147    flavor: CommentFlavor,
148    budget: &mut ParseBudget<'_>,
149) -> Result<Vec<Comment>, PptxError> {
150    let root = parse_xml(bytes, part, budget)?;
151    if root.local_name() != "cmLst" {
152        return Ok(Vec::new());
153    }
154    let mut comments = Vec::new();
155    for child in root.children_named("cm") {
156        budget.charge_comment(part)?;
157        match flavor {
158            CommentFlavor::Legacy => comments.push(legacy_comment(child, slide_part_path)),
159            CommentFlavor::Modern => {
160                let comment = modern_comment(child, slide_part_path, None);
161                let parent_id = comment.id.clone();
162                comments.push(comment);
163                for reply in child
164                    .child("replyLst")
165                    .into_iter()
166                    .flat_map(|list| list.children_named("reply"))
167                {
168                    budget.charge_comment(part)?;
169                    comments.push(modern_comment(
170                        reply,
171                        slide_part_path,
172                        Some(parent_id.clone()),
173                    ));
174                }
175            }
176        }
177    }
178    Ok(comments)
179}
180
181fn legacy_comment(element: &XmlElement, slide_part_path: &str) -> Comment {
182    let author_id = element
183        .attribute_local("authorId")
184        .unwrap_or("0")
185        .to_owned();
186    let index = element.attribute_local("idx").unwrap_or("1").to_owned();
187    let position = element.child("pos");
188    Comment {
189        id: format!("{author_id}:{index}"),
190        author_id,
191        slide_part_path: slide_part_path.to_owned(),
192        text: element
193            .child("text")
194            .map(XmlElement::text_content)
195            .unwrap_or_default(),
196        created: element.attribute_local("dt").map(str::to_owned),
197        x_emu: master_to_emu(coordinate(position, "x")),
198        y_emu: master_to_emu(coordinate(position, "y")),
199        parent_id: element
200            .child("extLst")
201            .and_then(|list| {
202                list.children_named("ext")
203                    .find(|ext| ext.attribute("uri") == Some(THREADING_EXT))
204            })
205            .and_then(|ext| ext.child("threadingInfo"))
206            .and_then(|thread| thread.child("parentCm"))
207            .and_then(|parent| {
208                Some(format!(
209                    "{}:{}",
210                    parent.attribute("authorId")?,
211                    parent.attribute("idx")?
212                ))
213            }),
214        status: None,
215        flavor: CommentFlavor::Legacy,
216    }
217}
218
219fn modern_comment(
220    element: &XmlElement,
221    slide_part_path: &str,
222    parent_id: Option<String>,
223) -> Comment {
224    let position = element.child("pos");
225    Comment {
226        id: element.attribute_local("id").unwrap_or_default().to_owned(),
227        author_id: element
228            .attribute_local("authorId")
229            .unwrap_or_default()
230            .to_owned(),
231        slide_part_path: slide_part_path.to_owned(),
232        text: element
233            .child("txBody")
234            .map(comment_text)
235            .unwrap_or_default(),
236        created: element.attribute_local("created").map(str::to_owned),
237        x_emu: coordinate(position, "x"),
238        y_emu: coordinate(position, "y"),
239        parent_id,
240        status: element.attribute_local("status").map(str::to_owned),
241        flavor: CommentFlavor::Modern,
242    }
243}
244
245fn comment_text(body: &XmlElement) -> String {
246    body.children_named("p")
247        .map(|paragraph| {
248            paragraph
249                .child_elements()
250                .filter_map(|child| match child.local_name() {
251                    "r" | "fld" => child.child("t").map(XmlElement::text_content),
252                    "br" => Some("\n".to_owned()),
253                    _ => None,
254                })
255                .collect::<String>()
256        })
257        .collect::<Vec<_>>()
258        .join("\n")
259}
260
261fn coordinate(position: Option<&XmlElement>, name: &str) -> i64 {
262    position
263        .and_then(|element| element.attribute_local(name))
264        .and_then(|value| value.parse::<i64>().ok())
265        .unwrap_or(0)
266}
267
268fn unsigned_attribute(element: &XmlElement, name: &str) -> Option<u32> {
269    element.attribute_local(name)?.parse::<u32>().ok()
270}
271
272pub struct CommentsWrite {
273    pub flavor: CommentFlavor,
274    pub authors: Vec<CommentAuthorWrite>,
275    pub per_slide: Vec<(CommentSlide, Vec<CommentWrite>)>,
276}
277
278#[derive(Clone, Debug, PartialEq, Eq)]
279pub enum CommentSlide {
280    Existing(String),
281    Added(usize),
282}
283
284pub struct CommentAuthorWrite {
285    pub id: String,
286    pub name: String,
287    pub initials: String,
288    pub last_index: u32,
289    pub color_index: u32,
290    pub user_id: Option<String>,
291    pub provider_id: Option<String>,
292}
293
294pub struct CommentWrite {
295    pub id: String,
296    pub author_id: String,
297    pub index: u32,
298    pub text: String,
299    pub created: Option<String>,
300    pub x_emu: i64,
301    pub y_emu: i64,
302    pub status: Option<String>,
303    pub replies: Vec<CommentWrite>,
304    /// Source slide part and comment ID for a copy.
305    pub source: Option<(String, String)>,
306}
307
308impl CommentsWrite {
309    pub(crate) fn authors_part_path(&self) -> &'static str {
310        match self.flavor {
311            CommentFlavor::Legacy => LEGACY_AUTHORS_PART,
312            CommentFlavor::Modern => MODERN_AUTHORS_PART,
313        }
314    }
315
316    pub(crate) fn authors_content_type(&self) -> &'static str {
317        match self.flavor {
318            CommentFlavor::Legacy => CT_COMMENT_AUTHORS,
319            CommentFlavor::Modern => CT_MODERN_AUTHORS,
320        }
321    }
322
323    pub(crate) fn authors_relationship_type(&self) -> &'static str {
324        match self.flavor {
325            CommentFlavor::Legacy => relationship_types::COMMENT_AUTHORS,
326            CommentFlavor::Modern => relationship_types::MODERN_AUTHORS,
327        }
328    }
329
330    pub(crate) fn comments_content_type(&self) -> &'static str {
331        match self.flavor {
332            CommentFlavor::Legacy => CT_COMMENTS,
333            CommentFlavor::Modern => CT_MODERN_COMMENTS,
334        }
335    }
336
337    pub(crate) fn comments_relationship_type(&self) -> &'static str {
338        match self.flavor {
339            CommentFlavor::Legacy => relationship_types::COMMENTS,
340            CommentFlavor::Modern => relationship_types::MODERN_COMMENTS,
341        }
342    }
343
344    pub(crate) fn comments_part_path(&self, slide_number: usize) -> String {
345        match self.flavor {
346            CommentFlavor::Legacy => format!("ppt/comments/comment{slide_number}.xml"),
347            CommentFlavor::Modern => format!("ppt/comments/modernComment{slide_number}.xml"),
348        }
349    }
350}
351
352pub(crate) fn authors_xml(write: &CommentsWrite) -> Vec<u8> {
353    let root = match write.flavor {
354        CommentFlavor::Legacy => {
355            let mut root = XmlElement::new("p:cmAuthorLst").with_attribute("xmlns:p", NS_P);
356            for author in &write.authors {
357                root = root.with_child(
358                    XmlElement::new("p:cmAuthor")
359                        .with_attribute("id", author.id.clone())
360                        .with_attribute("name", author.name.clone())
361                        .with_attribute("initials", author.initials.clone())
362                        .with_attribute("lastIdx", author.last_index.to_string())
363                        .with_attribute("clrIdx", author.color_index.to_string()),
364                );
365            }
366            root
367        }
368        CommentFlavor::Modern => {
369            let mut root = XmlElement::new("p188:authorLst").with_attribute("xmlns:p188", NS_P188);
370            for author in &write.authors {
371                root = root.with_child(
372                    XmlElement::new("p188:author")
373                        .with_attribute("id", author.id.clone())
374                        .with_attribute("name", author.name.clone())
375                        .with_attribute("initials", author.initials.clone())
376                        .with_attribute("userId", author.user_id.clone().unwrap_or_default())
377                        .with_attribute(
378                            "providerId",
379                            author.provider_id.clone().unwrap_or_default(),
380                        ),
381                );
382            }
383            root
384        }
385    };
386    serialize_xml(&root)
387}
388
389pub(crate) fn comments_xml(
390    write: &CommentsWrite,
391    comments: &[CommentWrite],
392    slide_id: u32,
393) -> Vec<u8> {
394    let root = match write.flavor {
395        CommentFlavor::Legacy => {
396            let mut root = XmlElement::new("p:cmLst").with_attribute("xmlns:p", NS_P);
397            for comment in comments {
398                root = root.with_child(legacy_comment_element(comment, None));
399                for reply in &comment.replies {
400                    root = root.with_child(legacy_comment_element(reply, Some(comment)));
401                }
402            }
403            root
404        }
405        CommentFlavor::Modern => {
406            let mut root = XmlElement::new("p188:cmLst")
407                .with_attribute("xmlns:a", NS_A)
408                .with_attribute("xmlns:pc", NS_PC)
409                .with_attribute("xmlns:p188", NS_P188);
410            for comment in comments {
411                root = root.with_child(modern_comment_element(comment, slide_id));
412            }
413            root
414        }
415    };
416    serialize_xml(&root)
417}
418
419pub(crate) fn legacy_comment_element(
420    comment: &CommentWrite,
421    parent: Option<&CommentWrite>,
422) -> XmlElement {
423    let mut element = XmlElement::new("p:cm")
424        .with_attribute("authorId", comment.author_id.clone())
425        .with_attribute("idx", comment.index.to_string());
426    if let Some(created) = &comment.created {
427        element.set_attribute("dt", created.clone());
428    }
429    let anchor = parent.unwrap_or(comment);
430    element = element
431        .with_child(
432            XmlElement::new("p:pos")
433                .with_attribute("x", emu_to_master(anchor.x_emu).to_string())
434                .with_attribute("y", emu_to_master(anchor.y_emu).to_string()),
435        )
436        .with_child(XmlElement::new("p:text").with_text(comment.text.clone()));
437    if let Some(parent) = parent {
438        element = element.with_child(
439            XmlElement::new("p:extLst").with_child(
440                XmlElement::new("p:ext")
441                    .with_attribute("uri", THREADING_EXT)
442                    .with_child(
443                        XmlElement::new("p15:threadingInfo")
444                            .with_attribute("xmlns:p15", NS_P15)
445                            .with_attribute("timeZoneBias", "0")
446                            .with_child(
447                                XmlElement::new("p15:parentCm")
448                                    .with_attribute("authorId", parent.author_id.clone())
449                                    .with_attribute("idx", parent.index.to_string()),
450                            ),
451                    ),
452            ),
453        );
454    }
455    element
456}
457
458pub(crate) fn modern_comment_element(comment: &CommentWrite, slide_id: u32) -> XmlElement {
459    let mut element = modern_body_element("p188:cm", comment)
460        .with_child(
461            XmlElement::new("pc:sldMkLst")
462                .with_child(XmlElement::new("pc:docMk"))
463                .with_child(
464                    XmlElement::new("pc:sldMk").with_attribute("sldId", slide_id.to_string()),
465                ),
466        )
467        .with_child(
468            XmlElement::new("p188:pos")
469                .with_attribute("x", comment.x_emu.to_string())
470                .with_attribute("y", comment.y_emu.to_string()),
471        );
472    if !comment.replies.is_empty() {
473        let mut replies = XmlElement::new("p188:replyLst");
474        for reply in &comment.replies {
475            replies = replies.with_child(
476                modern_body_element("p188:reply", reply).with_child(text_body(&reply.text)),
477            );
478        }
479        element = element.with_child(replies);
480    }
481    element.with_child(text_body(&comment.text))
482}
483
484pub(crate) fn modern_body_element(name: &str, comment: &CommentWrite) -> XmlElement {
485    let mut element = XmlElement::new(name)
486        .with_attribute("id", comment.id.clone())
487        .with_attribute("authorId", comment.author_id.clone());
488    if let Some(created) = &comment.created {
489        element.set_attribute("created", created.clone());
490    }
491    if let Some(status) = &comment.status {
492        element.set_attribute("status", status.clone());
493    }
494    element
495}
496
497pub(crate) fn text_body(text: &str) -> XmlElement {
498    let mut body = XmlElement::new("p188:txBody")
499        .with_child(XmlElement::new("a:bodyPr"))
500        .with_child(XmlElement::new("a:lstStyle"));
501    for paragraph in text.split('\n') {
502        body = body.with_child(
503            XmlElement::new("a:p").with_child(
504                XmlElement::new("a:r")
505                    .with_child(XmlElement::new("a:t").with_text(paragraph.to_owned())),
506            ),
507        );
508    }
509    body
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::relationships::TargetMode;
516    use crate::xml::ParseLimits;
517
518    fn relationship(relationship_type: &str, target: &str) -> Relationship {
519        Relationship {
520            id: "rId9".to_owned(),
521            relationship_type: relationship_type.to_owned(),
522            target: target.to_owned(),
523            target_mode: TargetMode::Internal,
524            resolved_target: Some(target.to_owned()),
525        }
526    }
527
528    #[test]
529    fn the_modern_comment_relationship_is_not_read_as_a_legacy_one() {
530        let modern = [relationship(
531            relationship_types::MODERN_COMMENTS,
532            "ppt/comments/modernComment1.xml",
533        )];
534        let parts = slide_comment_parts(&modern);
535        assert_eq!(
536            parts.modern.as_deref(),
537            Some("ppt/comments/modernComment1.xml")
538        );
539        assert!(
540            parts.legacy.is_none(),
541            "the modern type also ends with /comments, so suffix matching would conflate them"
542        );
543
544        let legacy = [relationship(
545            relationship_types::COMMENTS,
546            "ppt/comments/comment1.xml",
547        )];
548        let parts = slide_comment_parts(&legacy);
549        assert_eq!(parts.legacy.as_deref(), Some("ppt/comments/comment1.xml"));
550        assert!(parts.modern.is_none());
551    }
552
553    #[test]
554    fn master_units_round_trip_through_emu() {
555        assert_eq!(emu_to_master(914_400), 576, "one inch");
556        assert_eq!(master_to_emu(576), 914_400);
557        assert_eq!(emu_to_master(0), 0);
558        assert_eq!(emu_to_master(2_381), 1);
559        assert_eq!(emu_to_master(-914_400), -576);
560    }
561
562    #[test]
563    fn legacy_comments_and_authors_parse() {
564        let limits = ParseLimits::default();
565        let mut budget = ParseBudget::new(&limits);
566        let authors = parse_comment_authors(
567            br#"<p:cmAuthorLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cmAuthor id="0" name="Mary Smith" initials="mas" lastIdx="3" clrIdx="0"/></p:cmAuthorLst>"#,
568            "ppt/commentAuthors.xml",
569            CommentFlavor::Legacy,
570            &mut budget,
571        )
572        .unwrap();
573        assert_eq!(authors.len(), 1);
574        assert_eq!(authors[0].name, "Mary Smith");
575        assert_eq!(authors[0].last_index, Some(3));
576        assert_eq!(authors[0].color_index, Some(0));
577
578        let comments = parse_comments(
579            br#"<p:cmLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cm authorId="0" dt="2005-11-13T17:00:22.071" idx="1"><p:pos x="576" y="288"/><p:text>Hello &amp; welcome</p:text></p:cm></p:cmLst>"#,
580            "ppt/comments/comment1.xml",
581            "ppt/slides/slide1.xml",
582            CommentFlavor::Legacy,
583            &mut budget,
584        )
585        .unwrap();
586        assert_eq!(comments.len(), 1);
587        assert_eq!(comments[0].id, "0:1");
588        assert_eq!(comments[0].text, "Hello & welcome");
589        assert_eq!(comments[0].x_emu, 914_400);
590        assert_eq!(comments[0].y_emu, 457_200);
591        assert_eq!(comments[0].flavor, CommentFlavor::Legacy);
592    }
593
594    #[test]
595    fn modern_replies_carry_their_thread_root() {
596        let limits = ParseLimits::default();
597        let mut budget = ParseBudget::new(&limits);
598        let comments = parse_comments(
599            br#"<p188:cmLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p188="http://schemas.microsoft.com/office/powerpoint/2018/8/main"><p188:cm id="{ROOT}" authorId="{A}" created="2024-12-30T20:26:06.503" status="resolved"><p188:replyLst><p188:reply id="{REPLY}" authorId="{A}"><p188:txBody><a:p><a:r><a:t>Second</a:t></a:r></a:p></p188:txBody></p188:reply></p188:replyLst><p188:txBody><a:p><a:r><a:t>First</a:t></a:r></a:p></p188:txBody></p188:cm></p188:cmLst>"#,
600            "ppt/comments/modernComment1.xml",
601            "ppt/slides/slide1.xml",
602            CommentFlavor::Modern,
603            &mut budget,
604        )
605        .unwrap();
606        assert_eq!(comments.len(), 2);
607        assert_eq!(comments[0].id, "{ROOT}");
608        assert_eq!(comments[0].text, "First");
609        assert_eq!(comments[0].status.as_deref(), Some("resolved"));
610        assert_eq!(comments[1].id, "{REPLY}");
611        assert_eq!(comments[1].text, "Second");
612        assert_eq!(comments[1].parent_id.as_deref(), Some("{ROOT}"));
613    }
614
615    #[test]
616    fn a_comment_body_is_xml_escaped_on_the_way_out() {
617        let write = CommentsWrite {
618            flavor: CommentFlavor::Legacy,
619            authors: vec![CommentAuthorWrite {
620                id: "0".to_owned(),
621                name: "A & B".to_owned(),
622                initials: "AB".to_owned(),
623                last_index: 1,
624                color_index: 0,
625                user_id: None,
626                provider_id: None,
627            }],
628            per_slide: Vec::new(),
629        };
630        let comment = CommentWrite {
631            id: String::new(),
632            author_id: "0".to_owned(),
633            index: 1,
634            text: "<script> & \"quotes\"".to_owned(),
635            created: None,
636            x_emu: 0,
637            y_emu: 0,
638            status: None,
639            replies: Vec::new(),
640            source: None,
641        };
642        let xml = String::from_utf8(comments_xml(&write, &[comment], 256)).unwrap();
643        assert!(xml.contains("&lt;script&gt; &amp; \"quotes\""));
644        assert!(!xml.contains("<script>"));
645        let authors = String::from_utf8(authors_xml(&write)).unwrap();
646        assert!(authors.contains("name=\"A &amp; B\""));
647    }
648}