mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
Gap 19: ODT page margins default as LibreOffice's; loose drawings join a paragraph
More fixes from LibreOffice's bundled templates: - A page margin the file leaves unsaid was 1 in, and a page layout with no properties at all was ignored. Both now take LibreOffice's defaults: 2 cm margins (observed black-box). LibreOffice's CV template now lays out as LibreOffice draws it. - Drawings anchored outside any paragraph (LibreOffice's page-anchored frames before the first paragraph) made a paragraph of their own. That paragraph started the document on the default page style rather than the one the first paragraph asks for, which put the running header on a letter's first page and pushed the letter onto two pages. They now join the next paragraph; before a table or at the end, they still get a paragraph of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -7,6 +7,9 @@ use super::{
|
||||
model,
|
||||
};
|
||||
|
||||
/// Most drawings waiting outside a paragraph.
|
||||
const MAX_LOOSE: usize = 10_000;
|
||||
|
||||
pub(in crate::odt) mod drawing;
|
||||
mod indexes;
|
||||
mod notes;
|
||||
@@ -57,6 +60,10 @@ pub(super) struct Body<'a> {
|
||||
forms: super::forms::FormsReader,
|
||||
pub(super) manifest: Option<&'a HashMap<String, String>>,
|
||||
containers: Vec<Vec<FBlock>>,
|
||||
/// Drawings anchored in the text but outside any paragraph (LibreOffice's page-anchored
|
||||
/// frames before the first one): the next paragraph's, so they don't make a paragraph of
|
||||
/// their own (which would start the document on another page style).
|
||||
loose: Vec<Inline>,
|
||||
fmt: Vec<Fmt>,
|
||||
para: Option<Para>,
|
||||
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
|
||||
@@ -170,6 +177,7 @@ impl<'a> Body<'a> {
|
||||
para_seq: 0,
|
||||
manifest: None,
|
||||
containers: vec![Vec::new()],
|
||||
loose: Vec::new(),
|
||||
fmt: Vec::new(),
|
||||
para: None,
|
||||
xml_space_end: false,
|
||||
@@ -341,6 +349,11 @@ impl<'a> Body<'a> {
|
||||
std::mem::take(&mut self.forms.hidden)
|
||||
}
|
||||
|
||||
/// Whether the reader is in the text itself: not in a table, note, comment or frame.
|
||||
pub(in crate::odt) fn at_body_level(&self) -> bool {
|
||||
self.tables.is_empty() && self.cells.is_empty() && self.note.is_none() && self.frame.is_none() && self.containers.len() == 1
|
||||
}
|
||||
|
||||
pub(super) fn take_blocks(&mut self) -> Vec<FBlock> {
|
||||
// What truncated XML left open: a field, a note or comment (its paragraph goes on).
|
||||
self.end_field();
|
||||
@@ -348,6 +361,11 @@ impl<'a> Body<'a> {
|
||||
self.end_note();
|
||||
}
|
||||
self.end_para();
|
||||
// Drawings after the last paragraph: a paragraph of their own.
|
||||
if !self.loose.is_empty() {
|
||||
self.start_para(None, None);
|
||||
self.end_para();
|
||||
}
|
||||
while self.containers.len() > 1 {
|
||||
let c = self.containers.pop().unwrap_or_default();
|
||||
self.container().extend(c);
|
||||
@@ -513,6 +531,12 @@ impl Body<'_> {
|
||||
self.para_cont = false;
|
||||
}
|
||||
self.fmt.push(base);
|
||||
// Drawings waiting outside a paragraph come first in it.
|
||||
if !self.loose.is_empty() && self.note.is_none() {
|
||||
let mut loose = std::mem::take(&mut self.loose);
|
||||
loose.append(&mut p.inlines);
|
||||
p.inlines = loose;
|
||||
}
|
||||
self.para = Some(p);
|
||||
}
|
||||
|
||||
@@ -788,6 +812,10 @@ impl Body<'_> {
|
||||
self.start_para(get(a, "text:style-name"), h);
|
||||
}
|
||||
"table" if q.starts_with("table:") => {
|
||||
// Drawings waiting for a paragraph get their own before a table.
|
||||
if !self.loose.is_empty() && self.tables.is_empty() {
|
||||
self.start_para(None, None);
|
||||
}
|
||||
self.end_para();
|
||||
// A body table whose style names a master page starts that page (ODF 1.3 §19.509).
|
||||
if let Some(next) = get(a, "table:style-name").and_then(|s| self.page_section(s)) {
|
||||
|
||||
@@ -190,6 +190,12 @@ impl Body<'_> {
|
||||
|
||||
/// `inline` at the end of the paragraph being read (a new one if none is).
|
||||
pub(in crate::odt) fn push_inline(&mut self, inline: Inline) {
|
||||
// A drawing in the text outside any paragraph waits for the next one.
|
||||
let drawing = matches!(inline, Inline::Image(_) | Inline::Shape { .. } | Inline::Chart(_) | Inline::Group { .. });
|
||||
if self.para.is_none() && drawing && self.at_body_level() && self.loose.len() < super::MAX_LOOSE {
|
||||
self.loose.push(inline);
|
||||
return;
|
||||
}
|
||||
if self.para.is_none() {
|
||||
self.start_para(None, None);
|
||||
}
|
||||
|
||||
@@ -435,6 +435,9 @@ fn hyphenation(blocks: &mut [FBlock]) -> bool {
|
||||
/// 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 margin a page layout leaves unsaid (LibreOffice's page margins: 2 cm).
|
||||
const DEFAULT_MARGIN: f32 = 2.0 * 72.0 / 2.54;
|
||||
|
||||
/// The page size of a page layout that states none: WordCraft's default page's.
|
||||
fn default_paper() -> (f32, f32) {
|
||||
let d = SectionProps::default();
|
||||
@@ -725,7 +728,15 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
}
|
||||
}
|
||||
"linenumbering-configuration" if q.starts_with("text:") => lines = line_numbering(&a),
|
||||
"page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from),
|
||||
"page-layout" if q.starts_with("style:") => {
|
||||
cur = get(&a, "style:name").map(String::from);
|
||||
// A layout stating nothing is still one: the defaults' page.
|
||||
if let Some(n) = &cur
|
||||
&& layouts.len() < MAX_SECTIONS
|
||||
{
|
||||
layouts.entry(n.clone()).or_default();
|
||||
}
|
||||
}
|
||||
"header-style" => in_footer = false,
|
||||
"footer-style" => in_footer = true,
|
||||
"header-footer-properties" => {
|
||||
@@ -737,10 +748,8 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
}
|
||||
}
|
||||
"page-layout-properties" => {
|
||||
if let Some(n) = &cur
|
||||
&& layouts.len() < MAX_SECTIONS
|
||||
{
|
||||
layouts.insert(n.clone(), Layout { attrs: a, ..Default::default() });
|
||||
if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) {
|
||||
l.attrs = a;
|
||||
}
|
||||
}
|
||||
"columns" if q.starts_with("style:") => {
|
||||
@@ -784,7 +793,16 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
|
||||
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 { page_w: paper.0, page_h: paper.1, ..SectionProps::default() };
|
||||
// What a layout leaves unsaid: the default paper, and LibreOffice's 2 cm margins.
|
||||
let mut sec = SectionProps {
|
||||
page_w: paper.0,
|
||||
page_h: paper.1,
|
||||
margin_top: DEFAULT_MARGIN,
|
||||
margin_bottom: DEFAULT_MARGIN,
|
||||
margin_left: DEFAULT_MARGIN,
|
||||
margin_right: DEFAULT_MARGIN,
|
||||
..SectionProps::default()
|
||||
};
|
||||
apply_layout(a, &mut sec);
|
||||
sec.line_numbers.clone_from(&lines);
|
||||
if pages.first.is_none() {
|
||||
|
||||
@@ -276,7 +276,8 @@ fn page_layout_follows_the_first_master_page() {
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(xml, &mut sec);
|
||||
assert!(sec.landscape);
|
||||
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 72.0));
|
||||
// A margin left unsaid is LibreOffice's 2 cm.
|
||||
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 2.0 * 72.0 / 2.54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -296,8 +297,9 @@ fn hostile_page_layout_values_are_clamped() {
|
||||
apply_page_layout(styles("1e30pt", "1e30in", "1e30pt").as_bytes(), &mut sec);
|
||||
assert_eq!((sec.page_w, sec.page_h), (1584.0, 1584.0));
|
||||
assert_eq!((sec.margin_top, sec.margin_left), (1584.0, 1584.0));
|
||||
// Negative, zero, tiny and non-numeric values leave the defaults alone.
|
||||
let def = wordcraft_doc::SectionProps::default();
|
||||
// Negative, zero, tiny and non-numeric values leave the defaults alone: the default page, and
|
||||
// LibreOffice's 2 cm margins.
|
||||
let def = wordcraft_doc::SectionProps { margin_top: 2.0 * 72.0 / 2.54, margin_right: 2.0 * 72.0 / 2.54, ..Default::default() };
|
||||
for (w, h, m) in [("-10in", "-5in", "-1in"), ("0pt", "0pt", "NaNpt"), ("1pt", "2pt", "infpt"), ("NaN", "inf", "x")] {
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(styles(w, h, m).as_bytes(), &mut sec);
|
||||
|
||||
@@ -215,3 +215,28 @@ fn a_contour_outside_its_view_box_is_worked_out_instead() {
|
||||
let a: crate::odt::xml::Attrs = vec![("svg:viewBox".into(), "0 0 100 100".into()), ("draw:points".into(), "0,0 100,100 0,100".into())];
|
||||
assert!(crate::odt::frames::contour_polygon(&a).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_outside_any_paragraph_belongs_to_the_next_one() {
|
||||
// LibreOffice puts page-anchored frames before the first paragraph. They join it rather
|
||||
// than making a paragraph of their own, which would start the document on the default page
|
||||
// style instead of the one the first paragraph asks for.
|
||||
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"/></office:styles><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in"/></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" style:page-layout-name="pm1" style:next-style-name="Standard"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns} xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="1" svg:x="1in" svg:y="2in" svg:width="2in" svg:height="1in"><draw:text-box><text:p>Address</text:p></draw:text-box></draw:frame><text:p text:style-name="Opening">Dear reader,</text:p><text:p>Body.</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||
let paras: Vec<&wordcraft_doc::Paragraph> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert_eq!(paras.len(), 2, "no paragraph of the frame's own");
|
||||
assert_eq!(paras[0].text.replace(wordcraft_doc::para::OBJ, ""), "Dear reader,");
|
||||
assert_eq!(paras[0].objects.len(), 1);
|
||||
// One section, starting on the first page style: a different first page without the head.
|
||||
assert!(d.last_section.title_page && d.last_section.headers.default.is_some());
|
||||
// Drawings with no paragraph after them still get one.
|
||||
let tail = content.replace(r#"<text:p text:style-name="Opening">Dear reader,</text:p><text:p>Body.</text:p>"#, "");
|
||||
let d = import(&crate::odt::tests::package(&tail, Some(&styles))).unwrap();
|
||||
assert_eq!(d.body.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
@@ -141,3 +141,23 @@ fn a_page_layout_without_a_size_is_the_default_pages_size() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_layout_stating_nothing_has_libreoffices_margins() {
|
||||
// LibreOffice's CV template: a page layout with no properties at all. LibreOffice draws it
|
||||
// on its default paper with 2 cm margins.
|
||||
let ns = crate::odt::tests::NS_T;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="Mpm1"><style:header-style/><style:footer-style/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="Mpm1"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content =
|
||||
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
|
||||
let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&styles))).unwrap().unwrap();
|
||||
let s = &d.last_section;
|
||||
let cm2 = 2.0 * 72.0 / 2.54;
|
||||
assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [cm2; 4]);
|
||||
// One margin stated: that one is the file's.
|
||||
let one = styles.replace("<style:header-style/>", r#"<style:page-layout-properties fo:margin-left="1in"/><style:header-style/>"#);
|
||||
let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&one))).unwrap().unwrap();
|
||||
assert_eq!((d.last_section.margin_left, d.last_section.margin_top), (72.0, cm2));
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user