diff --git a/src/formats/epub/mod.rs b/src/formats/epub/mod.rs index 26296c9a..de9e9e14 100644 --- a/src/formats/epub/mod.rs +++ b/src/formats/epub/mod.rs @@ -265,4 +265,56 @@ mod tests { .collect::(); assert_eq!(text, "chapter text", "sixty-four itemrefs naming one part"); } + + #[test] + fn a_title_page_with_a_bare_ampersand_is_still_read() { + // A chapter that is not well-formed XML used to be dropped whole, so + // a title page reading "Tom & Jerry" vanished and the book appeared + // to start at the following chapter. + let parts = [ + ( + "META-INF/container.xml", + r#" + + "#, + ), + ( + "c.opf", + r#" + + + + + + "#, + ), + ( + "title.xhtml", + r#" +

Tom & Jerry (1940)

"#, + ), + ( + "ch1.xhtml", + r#" +

chapter one

"#, + ), + ]; + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in &parts { + w.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(body.as_bytes()).unwrap(); + } + + let doc = parse(&w.finish().unwrap().into_inner()).unwrap(); + let text = doc + .blocks + .iter() + .filter_map(|b| match b { + Block::Paragraph(inlines) => Some(crate::model::inlines_to_plain_text(inlines)), + _ => None, + }) + .collect::(); + assert_eq!(text, "Tom & Jerry (1940)chapter one", "the title page opens the book"); + } } diff --git a/src/package/xml.rs b/src/package/xml.rs index 32ff65d8..30d183c8 100644 --- a/src/package/xml.rs +++ b/src/package/xml.rs @@ -204,6 +204,12 @@ pub fn parse_xml(bytes: &[u8]) -> Result { let utf8 = to_utf8(bytes); let mut reader = NsReader::from_reader(utf8.as_ref()); reader.config_mut().check_end_names = false; + // A bare `&` is common in real XHTML and HTML-ish parts ("R&D", "50% & + // rising"). Rejecting it would discard the whole part - and the scan for + // the missing `;` runs to end of input, so one stray ampersand costs + // every element after it. Dangling ampersands stay literal text instead; + // well-formed references still arrive as `GeneralRef`. + reader.config_mut().allow_dangling_amp = true; let mut interner: HashMap, Rc> = HashMap::new(); let mut root = @@ -627,4 +633,29 @@ mod tests { assert_eq!(root.text(), "leaf"); assert_eq!(root.descendants("", "never").count(), 0); } + + #[test] + fn dangling_ampersand_stays_literal_text() { + // A `&` with no reference after it is not well-formed XML, but real + // XHTML carries it constantly. It must not cost the rest of the part: + // the scan for the missing `;` runs to end of input, so rejecting + // here would drop every element that follows. + for (xml, want) in [ + (&br#"

Tom & Jerry

after

"#[..], "Tom & Jerryafter"), + (&br#"

R&D

after

"#[..], "R&Dafter"), + (&br#"

50% & rising

after

"#[..], "50% & risingafter"), + (&br#"

trailing &

"#[..], "trailing &"), + ] { + let root = parse_xml(xml).unwrap(); + assert_eq!(root.text(), want, "{}", String::from_utf8_lossy(xml)); + } + } + + #[test] + fn well_formed_references_still_resolve_beside_a_dangling_one() { + // Leniency for the bare `&` must not stop the real references on + // either side of it from expanding. + let root = parse_xml(br#"

a & b & c A d  e

"#).unwrap(); + assert_eq!(root.text(), "a & b & c A d \u{a0}e"); + } }