diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4bdc068..a0f2440 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -445,6 +445,10 @@ pub struct Settings { /// Equation options (`m:mathPr`), when the document has them. #[serde(skip_serializing_if = "Option::is_none")] pub math: Option, + /// Citation and bibliography style (References › Style) by name, when the document + /// records one (OOXML's `b:Sources/@StyleName`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bib_style: Option, /// The template the document is attached to (Tools › Templates and Add-ins): the target of /// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it. #[serde(skip_serializing_if = "Option::is_none")] @@ -525,6 +529,7 @@ impl Default for Settings { grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, math: None, + bib_style: None, attached_template: None, link_styles: false, } diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 002dd70..36b0d0a 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -19,6 +19,7 @@ pub mod rt { pub const CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"; pub const APP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"; pub const CUSTOM: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; + pub const CUSTOM_XML_PROPS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps"; pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"; pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"; @@ -39,6 +40,7 @@ pub mod rt { pub const DIAGRAM_LAYOUT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout"; pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle"; pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors"; + pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; } /// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run). diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 058db92..92615ea 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -36,6 +36,20 @@ const MAX_PARTS: usize = 50_000; /// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's /// charts and diagrams; later ones are left empty. const MAX_GRAPHIC_WORK: usize = 2_000_000; +/// Bibliography sources kept from a file (Word's own source manager holds far fewer). +const MAX_SOURCES: usize = 10_000; +/// Custom XML parts looked at for bibliography sources. +const MAX_BIBLIOGRAPHY_PARTS: usize = 64; +/// Characters kept of a bibliography's citation style name. +const MAX_STYLE_NAME: usize = 64; + +/// Bibliography sources already read: tags, and (title, author) of untagged ones. +#[derive(Default)] +struct BibSeen { + tags: HashSet, + untagged: HashSet<(String, String)>, +} + /// Longest attached-template path we keep (bytes); a longer one is not a real path. const MAX_TEMPLATE_PATH: usize = 32 * 1024; @@ -181,6 +195,31 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { let ex = part(rt::COMMENTS_EX); lenient("comments", r.read_comments(&p, ex.as_deref())); } + // Bibliography sources live in a custom XML data store (`b:Sources`), related from the + // package root or the main part; Word's own files sometimes leave the relationship out, so + // the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once. + let mut bib_parts: Vec = Vec::new(); + for rel in root_rels.list.iter().chain(rels.list.iter()) { + if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) { + bib_parts.push(rel.target.clone()); + if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS { + break; + } + } + } + for i in 1..=16 { + let path = format!("customXml/item{i}.xml"); + if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) { + bib_parts.push(path); + } + } + let mut seen_sources = BibSeen::default(); + for path in &bib_parts { + if r.doc.sources.len() >= MAX_SOURCES { + break; + } + lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources)); + } if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) { r.doc.settings.page_color = Some(bg); @@ -1067,6 +1106,130 @@ impl Reader<'_> { Ok(()) } + /// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most + /// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`. + fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> { + let Some(root) = self.xml(path)? else { return Ok(()) }; + if root.local() != "Sources" { + return Ok(()); + } + if self.doc.settings.bib_style.is_none() + && let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim()) + && !name.is_empty() + { + self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect()); + } + let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default(); + let parse_persons = |parent: &El| -> String { + let mut names = Vec::new(); + let person_els: Vec<&El> = { + let in_lists: Vec<&El> = + parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect(); + if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists } + }; + for name_el in person_els { + let last = child_text(name_el, "Last"); + let first = child_text(name_el, "First"); + let middle = child_text(name_el, "Middle"); + let name = if !first.is_empty() && !middle.is_empty() { + format!("{last}, {first} {middle}") + } else if !first.is_empty() { + format!("{last}, {first}") + } else { + last + }; + if !name.is_empty() { + names.push(name); + } + } + names.join("; ") + }; + + for src_el in root.els().filter(|e| e.local() == "Source") { + if self.doc.sources.len() >= MAX_SOURCES { + log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped"); + break; + } + let tag = child_text(src_el, "Tag"); + let raw_kind = child_text(src_el, "SourceType"); + let kind = crate::write::from_source_type(&raw_kind); + let (author, editor) = match src_el.els().find(|e| e.local() == "Author") { + Some(a) => { + let auth = + a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a)); + let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default(); + (auth, ed) + } + None => (String::new(), String::new()), + }; + let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor }; + let title = child_text(src_el, "Title"); + let year = child_text(src_el, "Year"); + let month = child_text(src_el, "Month"); + let day = child_text(src_el, "Day"); + let city = child_text(src_el, "City"); + let publisher = child_text(src_el, "Publisher"); + let journal = child_text(src_el, "JournalName"); + let edition = child_text(src_el, "Edition"); + let volume = child_text(src_el, "Volume"); + let issue = child_text(src_el, "Issue"); + let pages = child_text(src_el, "Pages"); + let url = child_text(src_el, "URL"); + let accessed = match child_text(src_el, "Accessed") { + a if !a.is_empty() => a, + _ => { + let y = child_text(src_el, "YearAccessed"); + let m = child_text(src_el, "MonthAccessed"); + let d = child_text(src_el, "DayAccessed"); + if !y.is_empty() && !m.is_empty() && !d.is_empty() { + format!("{y}-{m}-{d}") + } else if !y.is_empty() && !m.is_empty() { + format!("{y}-{m}") + } else { + y + } + } + }; + let doi = match child_text(src_el, "DOI") { + d if !d.is_empty() => d, + _ => child_text(src_el, "StandardNumber"), + }; + + // Keep sources with some content, once each (by tag, or title and author untagged). + if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() { + continue; + } + let fresh = if tag.is_empty() { + seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() })) + } else { + seen.tags.insert(tag.clone()) + }; + if fresh { + self.doc.sources.push(wordcraft_doc::Source { + tag, + kind, + author, + title, + year, + city, + publisher, + journal, + volume, + pages, + url, + editor, + edition, + issue, + month, + day, + accessed, + doi, + }); + } + } + Ok(()) + } + fn read_core(&mut self, path: &str) { let Ok(Some(root)) = self.xml(path) else { return }; let c = &mut self.doc.core; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 89df4e8..84fe5a1 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -440,6 +440,27 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result Vec { w.close("Properties"); w.into_bytes() } + +fn to_source_type(kind: &str) -> &str { + match kind.to_ascii_lowercase().as_str() { + "book" => "Book", + "booksection" | "chapter" => "BookSection", + "article" | "journal" | "journalarticle" => "JournalArticle", + "periodical" | "articleinaperiodical" => "ArticleInAPeriodical", + "website" | "site" | "internetsite" => "InternetSite", + "documentfrominternetsite" => "DocumentFromInternetSite", + "electronicsource" => "ElectronicSource", + "report" => "Report", + "conference" | "proceedings" | "conferenceproceedings" => "ConferenceProceedings", + "case" => "Case", + "art" => "Art", + "film" => "Film", + "interview" => "Interview", + "patent" => "Patent", + "performance" => "Performance", + "soundrecording" => "SoundRecording", + "standard" => "Standard", + "thesis" => "Thesis", + "other" | "misc" => "Misc", + _ => "Book", + } +} + +pub(crate) fn from_source_type(st: &str) -> String { + match st.trim() { + "Book" | "book" => "book".into(), + "BookSection" | "booksection" => "bookSection".into(), + "JournalArticle" | "journalarticle" | "article" => "article".into(), + "ArticleInAPeriodical" | "articleinaperiodical" => "periodical".into(), + "InternetSite" | "internetsite" | "website" => "website".into(), + "DocumentFromInternetSite" | "documentfrominternetsite" => "website".into(), + "ElectronicSource" | "electronicsource" => "electronicsource".into(), + "Report" | "report" => "report".into(), + "ConferenceProceedings" | "conferenceproceedings" => "conference".into(), + "Case" | "case" => "case".into(), + "SoundRecording" | "soundrecording" => "soundrecording".into(), + "Film" | "film" => "film".into(), + "Interview" | "interview" => "interview".into(), + "Patent" | "patent" => "patent".into(), + "Performance" | "performance" => "performance".into(), + "Standard" | "standard" => "standard".into(), + "Thesis" | "thesis" => "thesis".into(), + "Misc" | "misc" => "other".into(), + "" => "book".into(), + other => other.to_ascii_lowercase(), + } +} + +/// The document's citation style, APA when it has none, and a style sheet file name made from +/// it (`\APA.XSL`, `\MLA.XSL`). +fn bib_style(doc: &Document) -> (&str, String) { + let name = doc.settings.bib_style.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or("APA"); + let file: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); + (name, format!("\\{}.XSL", if file.is_empty() { "APA" } else { &file })) +} + +fn write_name_list(w: &mut W, names: &str) { + w.open("b:NameList", &[]); + let persons: Vec<_> = names.split(';').map(str::trim).filter(|s| !s.is_empty()).collect(); + for a in persons { + w.open("b:Person", &[]); + // Parse "Last, First" or "Last, First Middle" + let (last, first_middle) = match a.split_once(',') { + Some((l, f)) => (l.trim(), f.trim()), + None => (a, ""), + }; + w.leaf("b:Last", &[], last); + if !first_middle.is_empty() { + let parts: Vec<&str> = first_middle.split_whitespace().collect(); + if let Some(first) = parts.first() { + w.leaf("b:First", &[], first); + } + if let Some(slice) = parts.get(1..).filter(|s| !s.is_empty()) { + let mid = slice.join(" "); + w.leaf("b:Middle", &[], &mid); + } + } + w.close("b:Person"); + } + w.close("b:NameList"); +} + +fn split_accessed_date(s: &str) -> (Option<&str>, Option<&str>, Option<&str>) { + let s = s.trim(); + if s.is_empty() { + return (None, None, None); + } + let parts: Vec<&str> = if s.contains('-') { + s.split('-').collect() + } else if s.contains('/') { + s.split('/').collect() + } else { + Vec::new() + }; + if let [y, m, d] = parts.as_slice() { + (Some(y.trim()), Some(m.trim()), Some(d.trim())) + } else if let [y, m] = parts.as_slice() { + (Some(y.trim()), Some(m.trim()), None) + } else { + (Some(s), None, None) + } +} + +fn bibliography_xml(doc: &Document) -> Vec { + let (style_name, selected) = bib_style(doc); + let mut w = W::new(); + w.open( + "b:Sources", + &[ + ("SelectedStyle", selected.as_str()), + ("StyleName", style_name), + ("xmlns:b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"), + ("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"), + ], + ); + for src in &doc.sources { + w.open("b:Source", &[]); + w.leaf("b:Tag", &[], &src.tag); + w.leaf("b:SourceType", &[], to_source_type(&src.kind)); + // Author & Editor + if !src.author.is_empty() || !src.editor.is_empty() { + w.open("b:Author", &[]); + if !src.author.is_empty() { + w.open("b:Author", &[]); + write_name_list(&mut w, &src.author); + w.close("b:Author"); + } + if !src.editor.is_empty() { + w.open("b:Editor", &[]); + write_name_list(&mut w, &src.editor); + w.close("b:Editor"); + } + w.close("b:Author"); + } + if !src.title.is_empty() { + w.leaf("b:Title", &[], &src.title); + } + if !src.year.is_empty() { + w.leaf("b:Year", &[], &src.year); + } + if !src.month.is_empty() { + w.leaf("b:Month", &[], &src.month); + } + if !src.day.is_empty() { + w.leaf("b:Day", &[], &src.day); + } + if !src.city.is_empty() { + w.leaf("b:City", &[], &src.city); + } + if !src.publisher.is_empty() { + w.leaf("b:Publisher", &[], &src.publisher); + } + if !src.journal.is_empty() { + w.leaf("b:JournalName", &[], &src.journal); + } + if !src.edition.is_empty() { + w.leaf("b:Edition", &[], &src.edition); + } + if !src.volume.is_empty() { + w.leaf("b:Volume", &[], &src.volume); + } + if !src.issue.is_empty() { + w.leaf("b:Issue", &[], &src.issue); + } + if !src.pages.is_empty() { + w.leaf("b:Pages", &[], &src.pages); + } + if !src.url.is_empty() { + w.leaf("b:URL", &[], &src.url); + } + if !src.accessed.is_empty() { + let (y, m, d) = split_accessed_date(&src.accessed); + if let Some(y) = y.filter(|s| !s.is_empty()) { + w.leaf("b:YearAccessed", &[], y); + } + if let Some(m) = m.filter(|s| !s.is_empty()) { + w.leaf("b:MonthAccessed", &[], m); + } + if let Some(d) = d.filter(|s| !s.is_empty()) { + w.leaf("b:DayAccessed", &[], d); + } + w.leaf("b:Accessed", &[], &src.accessed); + } + if !src.doi.is_empty() { + w.leaf("b:DOI", &[], &src.doi); + w.leaf("b:StandardNumber", &[], &src.doi); + } + w.close("b:Source"); + } + w.close("b:Sources"); + w.into_bytes() +} + +fn bibliography_props_xml() -> Vec { + let mut w = W::new(); + w.open( + "ds:datastoreItem", + &[("xmlns:ds", "http://schemas.openxmlformats.org/officeDocument/2006/customXml"), ("ds:itemID", "{3315B9A8-2323-4554-B8B2-1234567890AB}")], + ); + w.open("ds:schemaRefs", &[]); + w.empty("ds:schemaRef", &[("ds:uri", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography")]); + w.close("ds:schemaRefs"); + w.close("ds:datastoreItem"); + w.into_bytes() +} diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index d38ea80..925122c 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -46,6 +46,7 @@ pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/201 /// Namespaces that only appear in non-body parts. const OTHER_NAMESPACES: &[(&str, &str)] = &[ + ("b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"), ("rel", "http://schemas.openxmlformats.org/package/2006/relationships"), ("ct", "http://schemas.openxmlformats.org/package/2006/content-types"), ("cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"), diff --git a/crates/docx/tests/malformed.rs b/crates/docx/tests/malformed.rs index 3c4d8c2..f1725d8 100644 --- a/crates/docx/tests/malformed.rs +++ b/crates/docx/tests/malformed.rs @@ -163,6 +163,51 @@ const SNIPPETS: &[&str] = &[ "", ]; +#[test] +fn malformed_or_hostile_bibliography_never_panics() { + let doc = format!("body"); + let cases: &[&[u8]] = &[ + b"", + b"", + b"", + b",,,,", + b"Ed1st99133220250230doi:10.0/0", + b"Hostile1---///---", + b"\xff\xfe<\0b\0:\0S\0", + ]; + let root_rels_bib = r#""#; + for case in cases { + let z = zip(&[("_rels/.rels", root_rels_bib.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", case)]); + let res = read(&z); + if let Ok(d) = res { + let _ = write(&d); + } + } +} + +/// A file with a huge number of bibliography sources (duplicates and distinct ones) reads in +/// linear time and keeps at most 10,000 of them. +#[test] +fn hostile_bibliography_source_count_is_capped() { + let doc = format!("body"); + let mut sources = String::from(r#""#); + for i in 0..60_000 { + sources.push_str(&format!("T{i}Title {i}")); + // Untagged duplicates all collapse into one source. + sources.push_str("Same"); + } + sources.push_str(""); + let root_rels = r#""#; + let z = zip(&[("_rels/.rels", root_rels.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", sources.as_bytes())]); + let start = std::time::Instant::now(); + let d = read(&z).unwrap(); + assert_eq!(d.sources.len(), 10_000); + assert_eq!(d.sources.iter().filter(|s| s.tag.is_empty()).count(), 1, "untagged duplicates are kept once"); + assert_eq!(d.settings.bib_style.as_deref(), Some("MLA")); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); +} + proptest! { #![proptest_config(ProptestConfig { cases: 300, ..ProptestConfig::default() })] diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 49f7b54..4d97596 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -11,7 +11,7 @@ use wordcraft_doc::props::{ use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; use wordcraft_doc::table::{Cell, Table}; -use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block}; +use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block}; fn rt(doc: &Document) -> Document { let bytes = wordcraft_docx::write(doc).expect("write"); @@ -1260,6 +1260,21 @@ fn kitchen_sink() -> Document { blocks.push(Arc::new(Block::Table(t))); blocks.push(para_block(Paragraph::new())); d.body = blocks; + d.sources.push(Source { + tag: "Sink2026".into(), + kind: "book".into(), + author: "Author, First".into(), + title: "Title".into(), + year: "2026".into(), + editor: "Editor, Chief".into(), + edition: "3rd".into(), + issue: "2".into(), + month: "08".into(), + day: "24".into(), + accessed: "2026-08-25".into(), + doi: "10.1234/sink.2026".into(), + ..Default::default() + }); d } @@ -1275,6 +1290,7 @@ fn second_generation_is_stable() { assert_eq!(d2.revisions, d1.revisions); assert_eq!(d2.settings, d1.settings); assert_eq!(d2.media, d1.media); + assert_eq!(d2.sources, d1.sources); assert_eq!(d2, d1); } @@ -1703,6 +1719,154 @@ fn rotated_group_and_shadow_rotation_round_trip() { assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]); } +#[test] +fn bibliography_sources_round_trip() { + let mut d = Document::new(); + d.sources.push(Source { + tag: "Rivera2021".into(), + kind: "book".into(), + author: "Rivera, Alex; Chen, Mei".into(), + title: "Shared Spaces".into(), + year: "2021".into(), + publisher: "Harbor Books".into(), + city: "Portland".into(), + editor: "Johnson, Robert".into(), + edition: "2nd".into(), + month: "10".into(), + day: "15".into(), + ..Default::default() + }); + d.sources.push(Source { + tag: "Smith2023".into(), + kind: "article".into(), + author: "Smith, John David".into(), + title: "Modern Typography".into(), + year: "2023".into(), + journal: "Design Quarterly".into(), + volume: "12".into(), + pages: "45-60".into(), + url: "https://example.com/typo".into(), + issue: "4".into(), + doi: "10.1000/182".into(), + ..Default::default() + }); + d.sources.push(Source { + tag: "WebSite2024".into(), + kind: "website".into(), + author: "Solo".into(), + title: "Open Fonts".into(), + year: "2024".into(), + url: "https://example.org".into(), + accessed: "2024-05-15".into(), + ..Default::default() + }); + d.sources.push(Source { + tag: "Chapter2025".into(), + kind: "bookSection".into(), + author: "Lee, Harper".into(), + title: "Chapter One".into(), + year: "2025".into(), + journal: "Anthology of Prose".into(), + pages: "1-20".into(), + ..Default::default() + }); + d.sources.push(Source { + tag: "Misc2026".into(), + kind: "other".into(), + title: "Archive Manuscript".into(), + year: "2026".into(), + city: "Oxford".into(), + ..Default::default() + }); + + let bytes = wordcraft_docx::write(&d).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + + // Verify package-root customXml parts exist + assert!(zip.by_name("customXml/item1.xml").is_ok()); + assert!(zip.by_name("customXml/itemProps1.xml").is_ok()); + assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok()); + + let mut rels_xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("_rels/.rels").unwrap(), &mut rels_xml).unwrap(); + assert!(rels_xml.contains("relationships/customXml"), "{rels_xml}"); + assert!(rels_xml.contains("Target=\"customXml/item1.xml\""), "{rels_xml}"); + + let mut ct_xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut ct_xml).unwrap(); + assert!(ct_xml.contains("PartName=\"/customXml/item1.xml\""), "{ct_xml}"); + assert!(ct_xml.contains("PartName=\"/customXml/itemProps1.xml\""), "{ct_xml}"); + + let mut item_xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap(); + assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}"); + assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}"); + assert!(item_xml.contains("Book"), "{item_xml}"); + assert!(item_xml.contains("JournalArticle"), "{item_xml}"); + assert!(item_xml.contains("InternetSite"), "{item_xml}"); + assert!(item_xml.contains("BookSection"), "{item_xml}"); + assert!(item_xml.contains("Misc"), "{item_xml}"); + assert!(item_xml.contains(""), "{item_xml}"); + assert!(item_xml.contains("RiveraAlex"), "{item_xml}"); + assert!(item_xml.contains("SmithJohnDavid"), "{item_xml}"); + assert!(item_xml.contains("Solo"), "{item_xml}"); + assert!(item_xml.contains(""), "{item_xml}"); + assert!(item_xml.contains("JohnsonRobert"), "{item_xml}"); + assert!(item_xml.contains("2nd"), "{item_xml}"); + assert!(item_xml.contains("10"), "{item_xml}"); + assert!(item_xml.contains("15"), "{item_xml}"); + assert!(item_xml.contains("4"), "{item_xml}"); + assert!(item_xml.contains("10.1000/182"), "{item_xml}"); + assert!(item_xml.contains("2024"), "{item_xml}"); + assert!(item_xml.contains("05"), "{item_xml}"); + assert!(item_xml.contains("15"), "{item_xml}"); + assert!(item_xml.contains("2024-05-15"), "{item_xml}"); + + let back = wordcraft_docx::read(&bytes).expect("read"); + assert_eq!(back.sources.len(), 5); + assert_eq!(back.sources[0].tag, "Rivera2021"); + assert_eq!(back.sources[0].kind, "book"); + assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei"); + assert_eq!(back.sources[0].title, "Shared Spaces"); + assert_eq!(back.sources[0].year, "2021"); + assert_eq!(back.sources[0].publisher, "Harbor Books"); + assert_eq!(back.sources[0].city, "Portland"); + assert_eq!(back.sources[0].editor, "Johnson, Robert"); + assert_eq!(back.sources[0].edition, "2nd"); + assert_eq!(back.sources[0].month, "10"); + assert_eq!(back.sources[0].day, "15"); + + assert_eq!(back.sources[1].tag, "Smith2023"); + assert_eq!(back.sources[1].kind, "article"); + assert_eq!(back.sources[1].author, "Smith, John David"); + assert_eq!(back.sources[1].title, "Modern Typography"); + assert_eq!(back.sources[1].journal, "Design Quarterly"); + assert_eq!(back.sources[1].volume, "12"); + assert_eq!(back.sources[1].pages, "45-60"); + assert_eq!(back.sources[1].url, "https://example.com/typo"); + assert_eq!(back.sources[1].issue, "4"); + assert_eq!(back.sources[1].doi, "10.1000/182"); + + assert_eq!(back.sources[2].tag, "WebSite2024"); + assert_eq!(back.sources[2].kind, "website"); + assert_eq!(back.sources[2].author, "Solo"); + assert_eq!(back.sources[2].title, "Open Fonts"); + assert_eq!(back.sources[2].url, "https://example.org"); + assert_eq!(back.sources[2].accessed, "2024-05-15"); + + assert_eq!(back.sources[3].tag, "Chapter2025"); + assert_eq!(back.sources[3].kind, "bookSection"); + assert_eq!(back.sources[3].author, "Lee, Harper"); + assert_eq!(back.sources[3].title, "Chapter One"); + assert_eq!(back.sources[3].journal, "Anthology of Prose"); + assert_eq!(back.sources[3].pages, "1-20"); + + assert_eq!(back.sources[4].tag, "Misc2026"); + assert_eq!(back.sources[4].kind, "other"); + assert_eq!(back.sources[4].title, "Archive Manuscript"); + assert_eq!(back.sources[4].city, "Oxford"); +} + #[test] fn wrap_polygons_round_trip() { use wordcraft_doc::wrap::{WrapPolygon, shape_outline}; diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 37857ed..2793b13 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -297,6 +297,8 @@ pub fn specs() -> Vec { CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::>())) })?; s.bib_style = st.name.to_string(); + // Recorded in the document too, so a save keeps it. + s.doc.settings.bib_style = Some(st.name.to_string()); update_citations(s)?; Ok(json!({"style": s.bib_style})) }) @@ -976,6 +978,71 @@ mod tests { assert!(s.doc.plain_text(StoryRef::Body).contains("[1]")); } + #[test] + fn bibliography_round_trip_docx() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "Studios matter "})).unwrap(); + s.run( + "references.citation", + &json!({ + "source": { + "author": "Rivera, Alex; Chen, Mei", + "title": "Shared Spaces", + "year": "2021", + "publisher": "Harbor Books", + "city": "Portland", + "editor": "Johnson, Robert", + "edition": "2nd", + "issue": "4", + "month": "10", + "day": "15", + "accessed": "2024-05-15", + "doi": "10.1000/182" + } + }), + ) + .unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.bibliography", &json!({})).unwrap(); + + // Serialize to DOCX and back — sources must survive. + let bytes = wordcraft_docx::write(&s.doc).expect("docx write"); + let doc = wordcraft_docx::read(&bytes).expect("docx read"); + assert_eq!(doc.sources.len(), 1, "round-tripped sources: {:?}", doc.sources); + assert_eq!(doc.sources[0].author, "Rivera, Alex; Chen, Mei"); + assert_eq!(doc.sources[0].title, "Shared Spaces"); + assert_eq!(doc.sources[0].editor, "Johnson, Robert"); + assert_eq!(doc.sources[0].edition, "2nd"); + assert_eq!(doc.sources[0].issue, "4"); + assert_eq!(doc.sources[0].month, "10"); + assert_eq!(doc.sources[0].day, "15"); + assert_eq!(doc.sources[0].accessed, "2024-05-15"); + assert_eq!(doc.sources[0].doi, "10.1000/182"); + + // Round-tripped document in a new session works with citation styles. + let mut s2 = Session::new(doc); + s2.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap(); + let t = s2.doc.plain_text(StoryRef::Body); + assert!(t.contains("(Rivera and Chen, 2021)"), "{t}"); + assert!(t.contains("Rivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books."), "{t}"); + } + + #[test] + fn citation_style_survives_docx_save_and_reopen() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap(); + s.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap(); + let bytes = wordcraft_docx::write(&s.doc).expect("docx write"); + let doc = wordcraft_docx::read(&bytes).expect("docx read"); + assert_eq!(doc.settings.bib_style.as_deref(), Some("Harvard (Anglia)")); + assert_eq!(Session::new(doc.clone()).bib_style, "Harvard (Anglia)"); + let mut other = Session::new(wordcraft_doc::Document::new()); + other.set_document(doc); + assert_eq!(other.bib_style, "Harvard (Anglia)"); + // A document without a recorded style is APA. + assert_eq!(Session::new(wordcraft_doc::Document::new()).bib_style, "APA"); + } + #[test] fn index_and_figures() { let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nPresses are heavy.\nMore kilns.")); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f372e8b..3d5d6db 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -402,12 +402,19 @@ impl Prefs { } } +/// The citation style a document records (References › Style), APA when it records none or +/// one we don't know. +fn doc_bib_style(doc: &Document) -> String { + doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() +} + /// Maximum undo depth. const MAX_UNDO: usize = 500; impl Session { pub fn new(doc: Document) -> Self { let start = doc.start_of(StoryRef::Body); + let bib_style = doc_bib_style(&doc); Session { doc, sel: Selection::caret(start), @@ -443,7 +450,7 @@ impl Session { versions: Vec::new(), building_blocks: Default::default(), autosave: true, - bib_style: "APA".into(), + bib_style, merge: Default::default(), ui_requests: Vec::new(), math: None, @@ -709,6 +716,7 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { self.document_id = self.document_id.wrapping_add(1); + self.bib_style = doc_bib_style(&doc); self.doc = doc; self.doc.ensure_nonempty(); self.password = None;