mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"><Title></text:placeholder><text:placeholder text:placeholder-type="text"><Name></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"><Name></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"><Name></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 & mark"><Logo></text:placeholder> and <text:placeholder text:placeholder-type="nonsense"><X></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 & mark"><Logo></text:placeholder>"#),
|
||||
"{c}"
|
||||
);
|
||||
assert!(c.contains(r#"<text:placeholder text:placeholder-type="text"><X></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}");
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user