M8.1: Bibliography DOCX round-trip: support extended fields (#410) and upstream sync (#532) (#790)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* clippy: pass parse_persons by value (needless_borrows_for_generic_args)

* rustfmt after merging main

---------

Co-authored-by: yefan911 <yefan911@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
yefan911 2026-10-11 23:39:24 -06:00 committed by GitHub
parent 833d0b00c2
commit f0431abd23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 686 additions and 2 deletions

View File

@ -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<math::MathProps>,
/// 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<String>,
/// 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,
}

View File

@ -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).

View File

@ -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<String>,
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<Document, DocxError> {
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<String> = 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;

View File

@ -440,6 +440,27 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
entries.push(("docProps/custom.xml".into(), crate::custom::write(&doc.custom_props)));
overrides.push(("/docProps/custom.xml".into(), "application/vnd.openxmlformats-officedocument.custom-properties+xml".into()));
}
if !doc.sources.is_empty() {
root.add(rt::CUSTOM_XML, "customXml/item1.xml", false);
let mut item_rels = PartRels::default();
item_rels.add(rt::CUSTOM_XML_PROPS, "itemProps1.xml", false);
push_part(
&mut entries,
&mut overrides,
"customXml/item1.xml",
bibliography_xml(doc),
"application/vnd.openxmlformats-officedocument.customXml+xml",
item_rels,
);
push_part(
&mut entries,
&mut overrides,
"customXml/itemProps1.xml",
bibliography_props_xml(),
"application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
PartRels::default(),
);
}
entries.insert(0, ("_rels/.rels".into(), root.xml()));
// Content types.
@ -1058,3 +1079,211 @@ fn app_xml(doc: &Document) -> Vec<u8> {
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<u8> {
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<u8> {
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()
}

View File

@ -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"),

View File

@ -163,6 +163,51 @@ const SNIPPETS: &[&str] = &[
"<w:sectPr/>",
];
#[test]
fn malformed_or_hostile_bibliography_never_panics() {
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
let cases: &[&[u8]] = &[
b"<not_sources/>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><unclosed",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source/></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:NameList><b:Person/></b:NameList></b:Author></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag></b:Tag><b:SourceType></b:SourceType><b:Author><b:NameList><b:Person><b:Last>,,,,</b:Last><b:First></b:First></b:Person></b:NameList></b:Author></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:Editor><b:NameList><b:Person><b:Last>Ed</b:Last></b:Person></b:NameList></b:Editor></b:Author><b:Edition>1st</b:Edition><b:Issue>99</b:Issue><b:Month>13</b:Month><b:Day>32</b:Day><b:YearAccessed>2025</b:YearAccessed><b:MonthAccessed>02</b:MonthAccessed><b:DayAccessed>30</b:DayAccessed><b:StandardNumber>doi:10.0/0</b:StandardNumber></b:Source></b:Sources>",
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag>Hostile1</b:Tag><b:Editor><b:Person/></b:Editor><b:Accessed>---///---</b:Accessed></b:Source></b:Sources>",
b"\xff\xfe<\0b\0:\0S\0",
];
let root_rels_bib = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
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!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
let mut sources = String::from(r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" StyleName="MLA">"#);
for i in 0..60_000 {
sources.push_str(&format!("<b:Source><b:Tag>T{i}</b:Tag><b:Title>Title {i}</b:Title></b:Source>"));
// Untagged duplicates all collapse into one source.
sources.push_str("<b:Source><b:Title>Same</b:Title></b:Source>");
}
sources.push_str("</b:Sources>");
let root_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
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() })]

View File

@ -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("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>JournalArticle</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
assert!(item_xml.contains("<b:Issue>4</b:Issue>"), "{item_xml}");
assert!(item_xml.contains("<b:DOI>10.1000/182</b:DOI>"), "{item_xml}");
assert!(item_xml.contains("<b:YearAccessed>2024</b:YearAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{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};

View File

@ -297,6 +297,8 @@ pub fn specs() -> Vec<CommandSpec> {
CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::<Vec<_>>()))
})?;
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."));

View File

@ -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;