Files
wordcraft/crates/engine/tests/it/odt_toc.rs
T
Claude 2877514d9e Gap 19: ODT bibliography marks as citations of sources
ODT citations (`text:bibliography-mark`) were read as their text, and
the sources they carry were lost. So an ODT bibliography had nothing
to update from.
- Reading: each mark is now a `CITATION` field of its source, which
  becomes one of the document's bibliography sources (the first mark
  of an identifier gives it). The identifier is the tag, the type the
  kind, plus author, title, year, publisher, address, journal, volume,
  pages, URL, editor, edition, number and month.
- Writing: a `CITATION` of a known source is written back as a mark
  carrying the source.

References › Update rebuilds an ODT bibliography from them. A
LibreOffice citation and bibliography round-trip pixel-identical and
validate against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:13:47 +00:00

114 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A table of contents in an ODT file (ODF 1.3 §8.3 `text:table-of-content`) opens as one
//! WordCraft updates: References › Update Table regenerates its entries from the document's
//! headings, with or without a title, and it is saved back as an ODF table of contents. The ODT
//! is original and synthetic, shaped like LibreOffice's.
use std::io::Write;
use serde_json::json;
use wordcraft_engine::{Session, io};
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
/// A document with a table of contents whose entries are stale (made before the headings were
/// renamed), titled or not.
fn odt(titled: bool) -> Vec<u8> {
let title = if titled {
r#"<text:index-title text:name="T_Head"><text:p text:style-name="Contents_20_Heading">Contents</text:p></text:index-title>"#
} else {
""
};
let content = format!(
r#"<office:document-content {NS}><office:body><office:text>
<text:table-of-content text:name="T"><text:table-of-content-source text:outline-level="2"/><text:index-body>{title}<text:p text:style-name="Contents_20_1">Old chapter<text:tab/>1</text:p></text:index-body></text:table-of-content>
<text:h text:outline-level="1">Planning</text:h><text:p>Text.</text:p>
<text:h text:outline-level="2">Budget</text:h><text:p>More text.</text:p>
<text:h text:outline-level="3">Too deep</text:h><text:p>Last.</text:p>
</office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
/// The text of the body's paragraphs in the TOC styles (without an untitled table's field
/// paragraph, which the engine keeps where the table started).
fn entries(s: &Session) -> Vec<String> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.map(|p| p.text.split('\t').next().unwrap_or("").trim_matches(wordcraft_doc::para::OBJ).to_string())
.filter(|t| !t.is_empty())
.collect()
}
#[test]
fn an_odt_table_of_contents_updates_from_the_headings() {
for titled in [true, false] {
let doc = io::open_bytes("toc.odt", &odt(titled)).unwrap();
let mut s = Session::new(doc);
assert_eq!(entries(&s), ["Old chapter"], "titled: {titled}");
s.run("references.updateToc", &json!({})).unwrap();
// Two levels, as the file's source says.
assert_eq!(entries(&s), ["Planning", "Budget"], "titled: {titled}");
// Saved and opened again, it is still a table of contents.
let again = io::open_bytes("toc.odt", &io::save_bytes("toc.odt", &s.doc).unwrap()).unwrap();
let mut s2 = Session::new(again);
assert_eq!(entries(&s2), ["Planning", "Budget"], "titled: {titled}");
s2.run("references.updateToc", &json!({})).unwrap();
assert_eq!(entries(&s2), ["Planning", "Budget"], "titled: {titled}");
}
}
#[test]
fn an_odt_index_updates_from_its_marks() {
// An alphabetical index whose entries are index marks in the text (`XE` fields once open):
// Update Index lists them, rather than emptying the index.
let content = format!(
r#"<office:document-content {NS}><office:body><office:text>
<text:alphabetical-index text:name="A"><text:alphabetical-index-source/><text:index-body><text:p>stale, 9</text:p></text:index-body></text:alphabetical-index>
<text:p>Clay <text:alphabetical-index-mark text:string-value="kilns"/>is fired; <text:alphabetical-index-mark-start text:id="m"/>glazes<text:alphabetical-index-mark-end text:id="m"/> shine.</text:p>
</office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let mut s = Session::new(io::open_bytes("index.odt", &z.finish().unwrap().into_inner()).unwrap());
s.run("references.updateIndex", &json!({})).unwrap();
let text: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect();
assert!(text.iter().any(|t| t.starts_with("glazes")), "{text:?}");
assert!(text.iter().any(|t| t.starts_with("kilns")), "{text:?}");
assert!(!text.iter().any(|t| t.contains("stale")), "{text:?}");
}
#[test]
fn an_odt_bibliography_updates_from_its_citations_sources() {
// A bibliography and a citation carrying its source: Update lists the source.
let content = format!(
r#"<office:document-content {NS}><office:body><office:text>
<text:p>Clay <text:bibliography-mark text:identifier="Smi20" text:bibliography-type="book" text:author="Smith, Jane" text:title="Kilns and Fire" text:year="2020">[Smi20]</text:bibliography-mark>.</text:p>
<text:bibliography text:name="B"><text:bibliography-source/><text:index-body><text:p>stale</text:p></text:index-body></text:bibliography>
</office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let mut s = Session::new(io::open_bytes("bib.odt", &z.finish().unwrap().into_inner()).unwrap());
s.run("references.updateIndex", &json!({})).unwrap();
let text: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect();
assert!(text.iter().any(|t| t.contains("Kilns and Fire")), "{text:?}");
assert!(!text.iter().any(|t| t.contains("stale")), "{text:?}");
}