From f0431abd23bf85583c5a30cf13a1d5f8138dfb22 Mon Sep 17 00:00:00 2001 From: yefan911 <121825709+yefan911@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:39:24 -0600 Subject: [PATCH] M8.1: Bibliography DOCX round-trip: support extended fields (#410) and upstream sync (#532) (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M8.1: Bibliography sources DOCX round-trip Store bibliography sources in package-root custom XML (customXml/item1.xml) with datastore item properties (customXml/itemProps1.xml), relationships, root customXml relationship (_rels/.rels), and content type overrides per ECMA-376 §15.2. Map internal source types to Word b:SourceType values (Book, BookSection, JournalArticle, ArticleInAPeriodical, InternetSite, DocumentFromInternetSite, ElectronicSource, Report, ConferenceProceedings, Case, Art, Film, Interview, Patent, Performance, SoundRecording, Standard, Thesis, Misc) and parse them back. Write SelectedStyle and StyleName attributes on b:Sources. Use safe slice indexing for author names parsing. Scan customXml relationships and customXml/item*.xml on read, extracting sources from b:Sources in the bibliography namespace. Add roundtrip integration tests in crates/docx/tests/roundtrip.rs verifying all parts, relationships, and attributes, update second-generation kitchen sink stability test, add hostile-input tests in crates/docx/tests/malformed.rs, and verify integration with citation styles in engine citations test. Co-Authored-By: Claude Fable 5 * Review fixes for #532: bounded source reading, real citation style - Read at most 10,000 bibliography sources and dedupe them with hash sets, so a hostile file with ~100k b:Source elements can't make opening quadratic; each custom XML part is read at most once (64 parts at most). Test: hostile_bibliography_source_count_is_capped. - Drop the rt::BIBLIOGRAPHY relationship branch (not an OPC relationship type) and the stray comment in write_as. - Write the document's citation style (References > Style) in b:Sources/@StyleName and @SelectedStyle instead of always APA: Settings::bib_style records it, the style command sets it, opening a file reads it back into the session. - The b: namespace goes with the other non-body namespaces (it isn't declared on document.xml), matching #612. Co-Authored-By: Claude Opus 5.5 * Keep the custom XML properties relationship apart from customXml So #612, which adds the same rt::CUSTOM_XML line, merges with this branch without a conflict. Co-Authored-By: Claude Opus 5.5 * clippy: pass parse_persons by value (needless_borrows_for_generic_args) * rustfmt after merging main --------- Co-authored-by: yefan911 Co-authored-by: Claude Fable 5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 + crates/docx/src/package.rs | 2 + crates/docx/src/read/mod.rs | 163 ++++++++++++++++++++ crates/docx/src/write/mod.rs | 229 +++++++++++++++++++++++++++++ crates/docx/src/xml.rs | 1 + crates/docx/tests/malformed.rs | 45 ++++++ crates/docx/tests/roundtrip.rs | 166 ++++++++++++++++++++- crates/engine/src/cmd/citations.rs | 67 +++++++++ crates/engine/src/session.rs | 10 +- 9 files changed, 686 insertions(+), 2 deletions(-) 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;