Gap 19: ODT fixes found by round-tripping LibreOffice's own templates

LibreOffice ships 78 text templates (letters, faxes, agendas, reports,
CVs). Round-tripping them through WordCraft exposed these problems:
- Placeholders (`text:placeholder`, "<Recipient's name>") were
  printed text. They now open as plain-text content controls showing
  them, so a click selects one and typing replaces it. Their
  description becomes the control's title. They are written back as
  placeholders of their type while they still show; what was typed is
  written as text.
- A fixed date with neither a value nor text was lost. It is now a date
  field.
- `text:author-name` opens as USERNAME, which the engine left empty.
  USERNAME and USERINITIALS now show the user's name and initials
  from File › Options; a quoted text after the name is shown instead.
- A "First Page" master followed by another (`style:next-style-name`)
  put the running header on the first page. It is now a section with a
  different first page: its own (empty) header on page 1, the next
  master's after that.
- A page layout without a size was Letter. It is now the default
  page's, the user's region's, as LibreOffice uses its default paper.
  ODT import takes the regional page like the other importers.
- Style-only templates (no `content.xml`) failed to open. They now
  open empty with their styles.
- Table columns are now written with their share of the table
  (`style:rel-column-width`) too. A table filling the margins of a
  narrower page keeps its proportions in LibreOffice.

All 78 templates now open and round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-02 06:36:36 +00:00
parent 7848757954
commit ab02c85701
18 changed files with 369 additions and 20 deletions
@@ -234,6 +234,16 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
// The user's name and initials (File › Options › General); a quoted text after
// the name is shown instead.
"USERNAME" | "USERINITIALS" => {
let v = match new_property_value(instr) {
Some(v) => v.to_string(),
None if name == "USERNAME" => s.author.clone(),
None => s.user_initials(),
};
updates.push((k, text_result(instr, v)));
}
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
+14
View File
@@ -327,6 +327,20 @@ fn document_information_fields_show_the_properties() {
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// USERNAME and USERINITIALS show the user's name and initials from File › Options (an ODT's
/// `text:author-name` opens as USERNAME); a quoted text after the name is shown instead.
#[test]
fn user_name_fields_show_the_users_name() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = ["USERNAME", "USERINITIALS", r"USERNAME \* Upper", r#"USERNAME "Someone Else""#];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.author = "Ada Lovelace".into();
s.initials = String::new();
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Ada Lovelace", "AL", "ADA LOVELACE", "Someone Else"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
+1
View File
@@ -50,6 +50,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
let mut doc = match ext.as_str() {
"odt" | "ott" => return Some(odt::import_on(bytes, page)),
"txt" | "text" => txt::import_on(bytes, page),
"md" | "markdown" => markdown::import_on(&txt::decode(bytes), page),
"html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page),
+1 -1
View File
@@ -66,7 +66,7 @@ pub use flat::{export_flat, import_flat};
use import::Pages;
#[cfg(test)]
use import::{Pictures, apply_page_layout, read_entry, read_meta};
pub use import::{import, parse};
pub use import::{import, import_on, parse};
#[cfg(test)]
use limits::MAX_XML_DEPTH;
pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template};
+30 -1
View File
@@ -51,6 +51,8 @@ pub(super) struct Body<'a> {
/// A ruby being read: its base text, ruby text, which of them the text goes to (`true`:
/// the ruby), and the base's formatting.
ruby: Option<(String, String, Option<bool>, Fmt)>,
/// A placeholder being read (§7.7.2 `text:placeholder`): its text, its run's formatting.
placeholder: Option<(String, Fmt, &'static str, String)>,
/// The form controls of `office:forms`, placed where a `draw:control` names them.
forms: super::forms::FormsReader,
pub(super) manifest: Option<&'a HashMap<String, String>>,
@@ -161,6 +163,7 @@ impl<'a> Body<'a> {
break_next: false,
sources: Vec::new(),
ruby: None,
placeholder: None,
index_marks: Vec::new(),
conn_ids: HashMap::new(),
shape_places: HashMap::new(),
@@ -282,6 +285,31 @@ impl<'a> Body<'a> {
}
}
}
// A placeholder (§7.7.2): a plain text content control showing its text until
// something is typed in its place.
Ev::Start(q, a) if q == "text:placeholder" && self.para.is_some() && self.field.is_none() && self.placeholder.is_none() => {
// Its description (§19.800 `text:description`): the control's title.
let about = get(&a, "text:description").map(|d| d.chars().take(MAX_FIELD_RESULT).collect()).unwrap_or_default();
self.placeholder = Some((String::new(), self.cur_fmt(), super::forms::placeholder_type(get(&a, "text:placeholder-type")), about));
}
Ev::Text(t) if self.placeholder.is_some() => {
if let Some((s, ..)) = &mut self.placeholder {
let room = MAX_FIELD_RESULT.saturating_sub(s.len());
s.extend(t.chars().take(room));
}
}
Ev::End(q) if q == "text:placeholder" && self.placeholder.is_some() => {
if let Some((text, fmt, kind, about)) = self.placeholder.take() {
let mut control = wordcraft_doc::control::ContentControl::new(wordcraft_doc::control::ControlKind::Text { multi_line: false });
control.placeholder_text.clone_from(&text);
control.showing_placeholder = true;
// Its element, so it is written back a placeholder of its type.
control.form_xml = super::forms::placeholder_start(kind);
control.title = about;
let shown = control.placeholder_shown().to_string();
self.push_inline(Inline::Control { control: Box::new(control), text: shown, fmt });
}
}
Ev::Text(t) if self.ruby.is_some() => {
if let Some((base, ruby, Some(in_ruby), _)) = &mut self.ruby {
let s = if *in_ruby { ruby } else { base };
@@ -963,7 +991,8 @@ impl Body<'_> {
let anchor = (local(q) == "sequence")
.then(|| get(a, "text:ref-name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 1024).map(String::from))
.flatten();
self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt(), anchor });
let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true");
self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt(), anchor, fixed });
return true;
}
match local(q) {
+8 -2
View File
@@ -109,6 +109,8 @@ pub(in crate::odt) struct OpenField {
/// A sequence's reference name: a bookmark over the caption's label and number, which
/// references to it show.
pub(in crate::odt) anchor: Option<String>,
/// Fixed without a value to be fixed at: a field while it shows nothing, else its text.
pub(in crate::odt) fixed: bool,
}
impl Body<'_> {
@@ -312,7 +314,7 @@ impl Body<'_> {
/// Put the field read so far into the paragraph.
pub(in crate::odt) fn end_field(&mut self) {
let Some(OpenField { instr, result, mut fmt, anchor, .. }) = self.field.take() else { return };
let Some(OpenField { instr, result, mut fmt, anchor, fixed, .. }) = self.field.take() else { return };
fmt.ins = self.open_insertion();
if self.para.is_none() {
self.start_para(None, None);
@@ -326,7 +328,11 @@ impl Body<'_> {
let at = if before.trim() == label { 0 } else { p.inlines.len() };
p.inlines.insert(at, Inline::Anchor(name.clone()));
}
p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt });
if fixed && !result.trim().is_empty() {
p.push_text(&result, &fmt);
} else {
p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt });
}
if let Some(name) = anchor {
p.inlines.push(Inline::AnchorEnd(name));
}
+4 -1
View File
@@ -38,7 +38,10 @@ const SIMPLE: &[(&str, &str)] = &[
/// The Word field code for the `text:` field element `local` with attributes `a`, or `None`
/// when it is no field WordCraft keeps (then its text is read as text).
pub(super) fn instr_of(local: &str, a: &Attrs, picture: impl Fn(&str) -> Option<String>) -> Option<String> {
let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true");
// Fixed, unless it holds no value to be fixed at (LibreOffice's templates: a date that shows
// the day the document is made from them).
let fixed =
get(a, "text:fixed").is_some_and(|v| v.trim() == "true") && (get(a, "text:date-value").is_some() || get(a, "text:time-value").is_some());
// A name a field code can hold as one word.
let name = |k: &str| {
get(a, k)
+23 -1
View File
@@ -32,6 +32,28 @@ const PREFIXES: [&str; 19] = [
"loext", "xml",
];
/// The `text:placeholder-type` (§19.851) of a placeholder: what it stands for.
pub(in crate::odt) fn placeholder_type(t: Option<&str>) -> &'static str {
match t.map(str::trim) {
Some("table") => "table",
Some("text-box") => "text-box",
Some("image") => "image",
Some("object") => "object",
_ => "text",
}
}
/// A placeholder's start tag, which a content control read from one keeps (in
/// [`ContentControl::form_xml`]) to be written back as one.
pub(in crate::odt) fn placeholder_start(kind: &str) -> String {
format!("<text:placeholder text:placeholder-type=\"{kind}\">")
}
/// Whether control `c` was read from a placeholder.
pub(in crate::odt) fn is_placeholder(c: &ContentControl) -> bool {
c.form_xml.starts_with("<text:placeholder ")
}
/// A control read: its content control and the text it holds.
pub(in crate::odt) type Read = (ContentControl, String);
@@ -261,7 +283,7 @@ pub(in crate::odt) fn form_xml(c: &ContentControl, text: &str, id: &str, n: usiz
};
// A control kept verbatim, with its new id, while it is still the control it was read as
// (made another kind, it is written as that).
if !c.form_xml.is_empty() && c.kind == (ControlKind::Text { multi_line: false }) {
if !c.form_xml.is_empty() && c.kind == (ControlKind::Text { multi_line: false }) && !is_placeholder(c) {
let end = c.form_xml.find(|ch: char| ch.is_whitespace() || ch == '>')?;
let (head, rest) = (c.form_xml.get(..end)?, c.form_xml.get(end..)?);
return Some(size((format!("{head} xml:id=\"{id}\" form:id=\"{id}\"{rest}"), 72.0, 20.0)));
+61 -11
View File
@@ -363,7 +363,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// [`parse`], with the last section's properties (the others are on the paragraphs that end them).
fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
parse_tracked(bytes).map(|p| (p.flow, p.last))
parse_tracked(bytes, default_paper()).map(|p| (p.flow, p.last))
}
/// What [`parse_tracked`] reads besides the flow.
@@ -432,8 +432,18 @@ fn hyphenation(blocks: &mut [FBlock]) -> bool {
any
}
/// [`parse_full`], plus what the document holds outside the flow.
fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
/// The content of a package that has none: an empty text.
const EMPTY_CONTENT: &str = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p/></office:text></office:body></office:document-content>"#;
/// The page size of a page layout that states none: WordCraft's default page's.
fn default_paper() -> (f32, f32) {
let d = SectionProps::default();
(d.page_w, d.page_h)
}
/// [`parse_full`], plus what the document holds outside the flow. A page layout that states no
/// size (§20.215 `fo:page-width` is optional) is `paper` (width, height) in size.
fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;
if let Some(mt) = read_entry(&mut z, "mimetype") {
let mt = String::from_utf8_lossy(&mt);
@@ -441,13 +451,18 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
return Err(format!("ODT: not a text document ({})", mt.trim()));
}
}
let content = read_entry(&mut z, "content.xml").ok_or("ODT: content.xml is missing")?;
let styles_xml = read_entry(&mut z, "styles.xml");
// A template of styles only (LibreOffice's style templates) has no content: an empty text.
let content = match read_entry(&mut z, "content.xml") {
Some(c) => c,
None if styles_xml.is_some() => EMPTY_CONTENT.as_bytes().to_vec(),
None => return Err("ODT: content.xml is missing".into()),
};
let mut styles = Styles::default();
let mut pages = Pages::default();
let styles_xml = read_entry(&mut z, "styles.xml");
if let Some(s) = &styles_xml {
styles.read(s, false);
pages = read_pages(s);
pages = read_pages(s, paper);
}
styles.read(&content, true);
let settings = read_entry(&mut z, "settings.xml");
@@ -471,6 +486,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
};
let objects = (entries.as_slice(), &manifest);
let mut parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, objects)).unwrap_or_default();
first_pages(&mut pages);
let mut body = Body::new(&styles, &pages, &mut fetch);
(body.entries, body.manifest) = (objects.0, Some(objects.1));
events(&content, |ev| body.feed(ev));
@@ -533,6 +549,12 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
/// Parse an `.odt` package into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
import_on(bytes, &SectionProps::default())
}
/// [`import`], with `page` the default page (the user's region's): a page layout that states no
/// size is `page`'s size, as LibreOffice makes it its default paper's.
pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String> {
model::begin_import();
let Parsed {
flow,
@@ -558,7 +580,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
sources,
kept,
hidden_controls,
} = parse_tracked(bytes)?;
} = parse_tracked(bytes, (page.page_w, page.page_h))?;
let mut doc = model::to_doc(&flow);
kept.store(&mut doc);
// Elements can't hold a NUL: it parts them.
@@ -612,6 +634,9 @@ pub(super) struct Pages {
pub(super) masters: HashMap<String, SectionProps>,
pub(super) first: Option<SectionProps>,
pub(super) first_name: Option<String>,
/// Each master page's next one (§19.511 `style:next-style-name`): the page style the pages
/// after its first take.
pub(super) next: HashMap<String, String>,
/// What the document's first page style and its note and line numbering settings say for
/// the whole document.
pub(super) doc: PageDoc,
@@ -680,7 +705,7 @@ struct Layout {
footer: Band,
}
fn read_pages(xml: &[u8]) -> Pages {
fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
let mut layouts: HashMap<String, Layout> = HashMap::new();
let mut cur: Option<String> = None;
// Inside a layout's footer style (else its header style).
@@ -688,6 +713,7 @@ fn read_pages(xml: &[u8]) -> Pages {
// Master pages: name, layout, whether a header and a footer show.
let mut masters: Vec<(String, String, bool, bool)> = Vec::new();
let mut in_master = false;
let mut nexts: HashMap<String, String> = HashMap::new();
let mut doc = PageDoc::default();
let mut lines = None;
events(xml, |ev| match ev {
@@ -732,6 +758,9 @@ fn read_pages(xml: &[u8]) -> Pages {
if let (Some(n), Some(l)) = (get(&a, "style:name"), get(&a, "style:page-layout-name")) {
masters.push((n.to_string(), l.to_string(), false, false));
in_master = true;
if let Some(next) = get(&a, "style:next-style-name").map(str::trim).filter(|x| !x.is_empty() && *x != n) {
nexts.insert(n.to_string(), next.to_string());
}
}
}
"header" | "footer" if in_master && q.starts_with("style:") && get(&a, "style:display") != Some("false") => {
@@ -752,10 +781,10 @@ fn read_pages(xml: &[u8]) -> Pages {
},
Ev::Text(_) => {}
});
let mut pages = Pages { doc, ..Default::default() };
let mut pages = Pages { doc, next: nexts, ..Default::default() };
for (name, layout, header, footer) in masters {
let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb }) = layouts.get(&layout) else { continue };
let mut sec = SectionProps::default();
let mut sec = SectionProps { page_w: paper.0, page_h: paper.1, ..SectionProps::default() };
apply_layout(a, &mut sec);
sec.line_numbers.clone_from(&lines);
if pages.first.is_none() {
@@ -787,10 +816,31 @@ fn read_pages(xml: &[u8]) -> Pages {
pages
}
/// A master page followed by another (`style:next-style-name`, LibreOffice's "First Page"
/// before "Default") is a section with a different first page: its own headers and footers on
/// the first page (none there is an empty one), the next master's on the pages after it. Its
/// page size and margins are its own (the first page's).
fn first_pages(pages: &mut Pages) {
let names: Vec<(String, String)> = pages.next.iter().map(|(a, b)| (a.clone(), b.clone())).collect();
for (name, next) in names {
let Some(after) = pages.masters.get(&next).cloned() else { continue };
let Some(sec) = pages.masters.get_mut(&name) else { continue };
let (own_header, own_footer) = (sec.headers.default, sec.footers.default);
sec.headers = after.headers;
sec.footers = after.footers;
sec.headers.first = own_header;
sec.footers.first = own_footer;
sec.title_page = true;
if pages.first_name.as_deref() == Some(name.as_str()) {
pages.first = Some(sec.clone());
}
}
}
/// Replace `sec` with the page setup of the first master page in `styles.xml`, if any.
#[cfg(test)]
pub(super) fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) {
if let Some(first) = read_pages(xml).first {
if let Some(first) = read_pages(xml, default_paper()).first {
*sec = first;
}
}
+1
View File
@@ -30,6 +30,7 @@ mod outline;
mod page_setup;
mod para_borders;
mod parts;
mod placeholders;
mod polygons;
mod rotation;
mod rtl;
@@ -61,6 +61,10 @@ fn a_fixed_date_and_unknown_fields_are_plain_text() {
let d = import(&body(r#"<text:p><text:date text:fixed="true">1 May</text:date>, <text:variable-get text:name="v">4</text:variable-get>, <text:page-number text:select-page="next">5</text:page-number></text:p>"#)).unwrap();
assert!(fields(&d).is_empty());
assert_eq!(d.body[0].as_para().unwrap().text, "1 May, 4, 5");
// A fixed date with neither a value nor text (LibreOffice's templates) shows the day: a
// date field.
let d = import(&body(r#"<text:p><text:date text:fixed="true"/>.</text:p>"#)).unwrap();
assert_eq!(fields(&d).len(), 1);
}
fn field_para(parts: &[(&str, Option<&str>)]) -> std::sync::Arc<wordcraft_doc::Block> {
+24
View File
@@ -143,3 +143,27 @@ fn a_template_is_saved_and_opened_as_one() {
// A document is still a document.
assert_eq!(crate::odt::tests::entry(&crate::export("odt", &d).unwrap().unwrap(), "mimetype"), crate::odt::TEXT_MIME);
}
#[test]
fn a_template_of_styles_only_opens_empty_with_its_styles() {
// LibreOffice's style templates hold `styles.xml` and no `content.xml`.
use std::io::Write;
let styles = format!(
r#"<office:document-styles {}><office:styles><style:style style:name="Standard" style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:style></office:styles></office:document-styles>"#,
crate::odt::tests::NS_T
);
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", crate::odt::TEMPLATE_MIME), ("styles.xml", styles.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let d = crate::import("ott", &z.finish().unwrap().into_inner()).unwrap().unwrap();
assert_eq!(d.body.len(), 1);
assert_eq!(d.styles.default_chr.font.as_deref(), Some("DejaVu Serif"));
// Without styles either, it isn't a document.
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
z.start_file("mimetype", o).unwrap();
z.write_all(crate::odt::TEXT_MIME.as_bytes()).unwrap();
assert!(crate::import("odt", &z.finish().unwrap().into_inner()).unwrap().is_err());
}
@@ -88,3 +88,16 @@ fn a_tables_alignment_width_and_row_heights_are_kept() {
assert_eq!((pt.props.align, pt.props.width_pct), (None, None));
assert!(content_of(&export(&plain).unwrap()).contains(r#"table:align="margins""#));
}
#[test]
fn columns_are_written_with_their_share_of_the_table() {
// A table filling the margins of a page narrower than it was made for keeps its columns'
// proportions only when they are given as shares too (LibreOffice scales by them).
let styles = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="1in"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="3in"/></style:style>"#;
let d = doc(
styles,
r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#,
);
let c = content_of(&export(&d).unwrap());
assert!(c.contains(r#"style:column-width="72pt" style:rel-column-width="7200*""#) && c.contains(r#"style:rel-column-width="21600*""#), "{c}");
}
@@ -153,3 +153,35 @@ fn header_and_footer_distances_round_trip() {
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
assert!(near(s.header, 30.0) && near(s.margin_top, 80.0) && near(s.footer, 20.0) && near(s.margin_bottom, 70.0), "{s:?}");
}
#[test]
fn a_first_page_master_followed_by_another_is_a_different_first_page() {
// LibreOffice's "First Page" style (no header) passes to "Standard" (a header) after the
// first page: one section, the first page's header empty, the others' Standard's.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:styles><style:style style:name="Opening" style:family="paragraph" style:master-page-name="First_20_Page"/></office:styles><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in"/></style:page-layout><style:page-layout style:name="pm2"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="2in"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Running head</text:p></style:header></style:master-page><style:master-page style:name="First_20_Page" style:page-layout-name="pm2" style:next-style-name="Standard"/></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p text:style-name="Opening">Letter.</text:p><text:p>More.</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
let sec = &d.last_section;
assert!(sec.title_page, "a different first page");
let first_text: String = sec
.headers
.first
.and_then(|id| d.parts.get(&id))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect())
.unwrap_or_default();
assert_eq!(first_text, "", "{:?}", sec.headers);
let head = sec.headers.default.and_then(|id| d.parts.get(&id)).expect("the running head");
assert_eq!(head.blocks[0].as_para().map(|p| p.text.as_str()), Some("Running head"));
// The first page's own size and margins.
assert_eq!(sec.margin_top, 144.0);
// Written back: an empty first-page header.
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
let first = s.split("<style:header-first>").nth(1).and_then(|r| r.split("</style:header-first>").next()).unwrap_or("missing");
assert!(first.trim().starts_with("<text:p") && !first.contains("Running head") && s.contains("Running head"), "{s}");
}
@@ -118,3 +118,26 @@ fn line_numbering_is_on_unless_it_says_otherwise() {
assert_eq!(d.last_section.line_numbers.is_some(), on, "{attr}");
}
}
#[test]
fn a_page_layout_without_a_size_is_the_default_pages_size() {
// LibreOffice's templates often state margins but no page size: it is the default paper's
// (the user's region's in WordCraft, as LibreOffice uses its own default).
let styles = format!(
r#"<office:document-styles {}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1.25in" fo:margin-right="1.25in"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#,
crate::odt::tests::NS_T
);
let content = format!(
"<office:document-content {}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>",
crate::odt::tests::NS_T
);
let bytes = crate::odt::tests::package(&content, Some(&styles));
let a4 = wordcraft_doc::SectionProps { page_w: 595.3, page_h: 841.9, ..Default::default() };
let d = crate::import_on("odt", &bytes, &a4).unwrap().unwrap();
assert_eq!((d.last_section.page_w, d.last_section.page_h), (595.3, 841.9));
assert_eq!((d.last_section.margin_top, d.last_section.margin_left), (72.0, 90.0));
// A stated size is the file's.
let sized = styles.replace(r#"<style:page-layout-properties "#, r#"<style:page-layout-properties fo:page-width="5in" fo:page-height="7in" "#);
let d = crate::import_on("odt", &crate::odt::tests::package(&content, Some(&sized)), &a4).unwrap().unwrap();
assert_eq!((d.last_section.page_w, d.last_section.page_h), (360.0, 504.0));
}
@@ -0,0 +1,92 @@
//! Placeholders (ODF 1.3 §7.7.2 `text:placeholder`: "click here and type" text, as LibreOffice's
//! templates use): opened as plain text content controls showing their placeholder, so a click
//! selects one and typing replaces it, and written back as placeholders while they still show it.
use crate::model::Inline;
use crate::odt::tests::{NS_T, content_of, package, paras};
use crate::odt::{export, import, parse};
fn doc(body: &str) -> Vec<u8> {
package(
&format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#),
None,
)
}
/// Each control: (its placeholder text, showing it, the text shown).
fn controls(bytes: &[u8]) -> Vec<(String, bool, String)> {
paras(&parse(bytes).unwrap())
.into_iter()
.flat_map(|p| &p.inlines)
.filter_map(|i| match i {
Inline::Control { control, text, .. } => Some((control.placeholder_text.clone(), control.showing_placeholder, text.clone())),
_ => None,
})
.collect()
}
#[test]
fn placeholders_open_as_controls_showing_them() {
let bytes = doc(
r#"<text:p>Dear <text:placeholder text:placeholder-type="text">&lt;Title&gt;</text:placeholder><text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>,</text:p>"#,
);
assert_eq!(controls(&bytes), [("<Title>".into(), true, "<Title>".into()), ("<Name>".into(), true, "<Name>".into())]);
// The paragraph's own text around them.
let d = import(&bytes).unwrap();
let text = d.body[0].as_para().unwrap().text.replace(wordcraft_doc::para::OBJ, "");
assert_eq!(text, "Dear <Title><Name>,");
}
#[test]
fn placeholders_are_written_back_while_they_show() {
let bytes = doc(r#"<text:p>Dear <text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>,</text:p>"#);
let saved = export(&import(&bytes).unwrap()).unwrap();
let c = content_of(&saved);
assert!(c.contains(r#"<text:placeholder text:placeholder-type="text">&lt;Name&gt;</text:placeholder>"#), "{c}");
assert!(!c.contains("<form:"), "{c}");
assert_eq!(controls(&saved), controls(&bytes));
}
#[test]
fn an_empty_or_hostile_placeholder_never_panics() {
let long = "x".repeat(200_000);
for body in [
r#"<text:p><text:placeholder/></text:p>"#.to_string(),
r#"<text:placeholder>outside a paragraph</text:placeholder><text:p>p</text:p>"#.to_string(),
format!(r#"<text:p><text:placeholder>{long}</text:placeholder></text:p>"#),
r#"<text:p><text:placeholder><text:placeholder>nested</text:placeholder></text:placeholder></text:p>"#.to_string(),
r#"<text:p><text:placeholder>unclosed</text:p>"#.to_string(),
] {
if let Ok(d) = import(&doc(&body)) {
let _ = export(&d).unwrap();
}
}
}
#[test]
fn a_placeholders_type_is_kept_and_typed_text_is_text() {
let bytes = doc(
r#"<text:p><text:placeholder text:placeholder-type="image" text:description="Your logo &amp; mark">&lt;Logo&gt;</text:placeholder> and <text:placeholder text:placeholder-type="nonsense">&lt;X&gt;</text:placeholder></text:p>"#,
);
let c = content_of(&export(&import(&bytes).unwrap()).unwrap());
// Its description is the control's title, written back.
assert!(
c.contains(r#"<text:placeholder text:placeholder-type="image" text:description="Your logo &amp; mark">&lt;Logo&gt;</text:placeholder>"#),
"{c}"
);
assert!(c.contains(r#"<text:placeholder text:placeholder-type="text">&lt;X&gt;</text:placeholder>"#), "{c}");
// Something typed in its place is text.
let mut flow = parse(&bytes).unwrap();
for b in &mut flow.blocks {
if let crate::model::FBlock::Para(p) = b {
for i in &mut p.inlines {
if let Inline::Control { control, text, .. } = i {
control.showing_placeholder = false;
*text = "Acme Ltd".into();
}
}
}
}
let c = content_of(&export(&crate::model::to_doc(&flow)).unwrap());
assert!(!c.contains("<text:placeholder") && c.contains(r#"Acme <text:s text:c="1"/>Ltd"#), "{c}");
}
+25 -1
View File
@@ -1,4 +1,5 @@
use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model};
use wordcraft_doc::control::ControlKind;
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing};
mod charts;
@@ -463,6 +464,26 @@ impl Writer {
}
// A form control (in the body: forms belong to the text, ODF 1.3 §13.2), placed
// here; elsewhere, or of a kind forms don't have, its text.
// A control read from a placeholder (§7.7.2), in the body or anywhere else: a
// placeholder while it shows it, else what was typed in its place.
Inline::Control { control, text, fmt } if super::forms::is_placeholder(control) => {
if control.showing_placeholder && control.kind == (ControlKind::Text { multi_line: false }) {
// Its description: the control's title.
let start = match control.form_xml.strip_suffix('>') {
Some(head) if !control.title.is_empty() => format!("{head} text:description=\"{}\">", x(&control.title)),
_ => control.form_xml.clone(),
};
let el = format!("{start}{}</text:placeholder>", x(control.placeholder_shown()));
match self.text_style(&Fmt { link: None, ..fmt.clone() }) {
Some(n) => out.push_str(&format!("<text:span text:style-name=\"{n}\">{el}</text:span>")),
None => out.push_str(&el),
}
prev_space = false;
} else {
self.plain_inlines(&[Inline::Text(text.clone(), fmt.clone())], out);
prev_space = text.ends_with(' ');
}
}
Inline::Control { control, text, fmt } => {
let id = format!("control{}", self.forms.len() + 1);
match super::forms::form_xml(control, text, &id, self.forms.len() + 1).filter(|_| self.forms_allowed) {
@@ -779,8 +800,11 @@ impl Writer {
pt(total)
));
for (c, w) in widths.iter().enumerate() {
// Its share of the table too (§20.334 `style:rel-column-width`), so a table filling
// the margins of a narrower page keeps its columns' proportions.
let rel = if w.is_finite() { (w.clamp(0.0, 1584.0) * 100.0).round().max(1.0) as u32 } else { 1 };
s.push_str(&format!(
"<style:style style:name=\"Table{tn}.C{}\" style:family=\"table-column\"><style:table-column-properties style:column-width=\"{}\"/></style:style>",
"<style:style style:name=\"Table{tn}.C{}\" style:family=\"table-column\"><style:table-column-properties style:column-width=\"{}\" style:rel-column-width=\"{rel}*\"/></style:style>",
c + 1,
pt(*w)
));
File diff suppressed because one or more lines are too long