mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f781cfda | ||
|
|
8a97427f51 | ||
|
|
92eac2aa7c | ||
|
|
29a5abf3e7 | ||
|
|
bd9e726cda | ||
|
|
15b608660f | ||
|
|
52fbc7f068 | ||
|
|
d75364067d | ||
|
|
d102b2f08b |
+17
-1
File diff suppressed because one or more lines are too long
@@ -186,6 +186,11 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
|
||||
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
|
||||
/// drawing on its anchor paragraph's page.
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub page: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
|
||||
@@ -185,6 +185,10 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.wrap = Wrap::Square;
|
||||
}
|
||||
// A drawing anchored to a page is on this one now.
|
||||
if f.page > 0 {
|
||||
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
|
||||
}
|
||||
f.h_rel = Anchor::Page;
|
||||
f.v_rel = Anchor::Page;
|
||||
f.h_align = None;
|
||||
@@ -199,6 +203,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
s.touch();
|
||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||||
edit_float(s, &at, |f| {
|
||||
// On its paragraph now: a page it was anchored to is gone, not only hidden.
|
||||
f.page = 0;
|
||||
f.h_rel = Anchor::Column;
|
||||
f.v_rel = Anchor::Paragraph;
|
||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
@@ -10,10 +10,15 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
|
||||
// OpenDocument file says how big they are, and one wider than the text is meant to be;
|
||||
// changing it would take its paragraph off being written as read. Its reader fits those
|
||||
// that state no size.
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
if !matches!(other, "odt" | "ott" | "fodt") {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
}
|
||||
draw_charts(&mut doc);
|
||||
doc
|
||||
})
|
||||
|
||||
@@ -5218,6 +5218,42 @@ fn arrange_bounds_resizes_and_moves_objects() {
|
||||
assert_eq!(box_area(&mut s).rect, n);
|
||||
}
|
||||
|
||||
/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the
|
||||
/// file's page number goes, or the save would put it back. Only resized, it stays on that page.
|
||||
#[test]
|
||||
fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</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();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
let frame_tag = |s: &Session| {
|
||||
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||
let mut c = String::new();
|
||||
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
|
||||
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
|
||||
};
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
select_box(&mut s);
|
||||
run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80}));
|
||||
assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s));
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
select_box(&mut s);
|
||||
run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300}));
|
||||
let tag = frame_tag(&s);
|
||||
assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}");
|
||||
// Moved, then placed by the margins: the file's page number is gone, not only hidden.
|
||||
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
|
||||
let tag = frame_tag(&s);
|
||||
assert!(!tag.contains("anchor-page-number"), "moved, then placed: {tag}");
|
||||
}
|
||||
|
||||
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
|
||||
/// it anchors to it would push that paragraph onto the next page (#3169).
|
||||
#[test]
|
||||
@@ -12688,6 +12724,73 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
|
||||
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
|
||||
#[test]
|
||||
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||
let ns = 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" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
|
||||
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", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/r.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let width = doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
|
||||
let want = 18.0 * 72.0 / 2.54;
|
||||
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||
let mut saved = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
|
||||
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
|
||||
}
|
||||
|
||||
/// Numbering or a new style makes the styles be written from the model on save: a document in
|
||||
/// German stays in German, rather than taking the default language of what opens it next.
|
||||
#[test]
|
||||
fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="de" fo:country="DE"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</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()), ("styles.xml", styles.as_str())]
|
||||
{
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
|
||||
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
|
||||
run(&mut s, command, params);
|
||||
let saved = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let back = crate::io::open_bytes("t.odt", &saved).unwrap();
|
||||
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||
@@ -12932,6 +13035,47 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
|
||||
/// formats' are; one with a stated size keeps it, in line too.
|
||||
#[test]
|
||||
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let png = png.into_inner();
|
||||
let open = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></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", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/a.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
|
||||
.unwrap()
|
||||
};
|
||||
let (w, h) = open("");
|
||||
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
|
||||
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
|
||||
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
|
||||
@@ -78,7 +78,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
|
||||
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
|
||||
/// its custom character style by name (only its direct formatting then its own) and a
|
||||
/// monospaced font by its name.
|
||||
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
||||
fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
||||
let mut f = fmt_of(doc, props, in_code);
|
||||
if !opts.chars {
|
||||
return f;
|
||||
@@ -104,6 +104,16 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
||||
if let Some(id) = custom {
|
||||
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
|
||||
}
|
||||
// A size stated without a complex-script size leaves the complex size to what is above it
|
||||
// (a style's, or this size where none says one): said where it isn't this size, since a
|
||||
// writer that gives every script the one size would otherwise change it.
|
||||
let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some()));
|
||||
if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) {
|
||||
let cs = doc.styles.resolve_char(para_style, props).size_cs;
|
||||
if f.size != Some(cs) {
|
||||
f.extra.get_or_insert_with(Default::default).size_cs = Some(cs);
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
f.code = false;
|
||||
f.font = e.shown_font().map(Arc::from);
|
||||
@@ -116,11 +126,15 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
||||
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
|
||||
let set = |v: Option<bool>| v.filter(|b| *b);
|
||||
let x = CharProps {
|
||||
underline: e.underline.filter(|u| !matches!(u, Underline::None | Underline::Single)),
|
||||
// What it turns off, over what the style of its paragraph or span turns on, too.
|
||||
bold: e.bold.filter(|b| !*b),
|
||||
italic: e.italic.filter(|b| !*b),
|
||||
strike: e.strike.filter(|b| !*b),
|
||||
underline: e.underline.filter(|u| *u != Underline::Single),
|
||||
underline_color: e.underline_color,
|
||||
double_strike: set(e.double_strike),
|
||||
caps: set(e.caps),
|
||||
small_caps: set(e.small_caps),
|
||||
caps: e.caps,
|
||||
small_caps: e.small_caps,
|
||||
hidden: set(e.hidden),
|
||||
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
|
||||
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
|
||||
@@ -182,6 +196,9 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
||||
tabs
|
||||
});
|
||||
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
|
||||
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
|
||||
// heading style's where the style is based on one).
|
||||
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
|
||||
let x = wordcraft_doc::props::ParaProps {
|
||||
tabs,
|
||||
shading: p.shading,
|
||||
@@ -191,7 +208,7 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
||||
drop_cap: p.drop_cap.filter(|l| *l > 1),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
|
||||
keep_lines: p.keep_lines.filter(|k| *k),
|
||||
outline_level: p.outline_level,
|
||||
outline_level,
|
||||
widow_control: p.widow_control.filter(|w| !*w),
|
||||
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
|
||||
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
|
||||
@@ -413,14 +430,25 @@ fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>,
|
||||
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
|
||||
}
|
||||
|
||||
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
|
||||
/// style's list, not a list of its own: it states that level, and the heading style of that level
|
||||
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
|
||||
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
p.props.outline_level == Some(li.level)
|
||||
&& li.level < 6
|
||||
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
|
||||
}
|
||||
|
||||
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
|
||||
/// unless `opts` asks for them, and none for a heading numbered only through its style, which a
|
||||
/// format with heading numbering of its own (ODT's outline style) numbers instead.
|
||||
fn flow_list(kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
||||
/// unless `opts` asks for them, and none for a heading numbered only through its style or
|
||||
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
|
||||
/// numbers instead, in its first six levels.
|
||||
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
||||
let mut li = list?;
|
||||
if !opts.lists {
|
||||
(li.list, li.label) = (0, None);
|
||||
} else if matches!(kind, Kind::Heading(_)) && p.props.numbering.is_none() {
|
||||
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
|
||||
return None;
|
||||
}
|
||||
Some(li)
|
||||
@@ -458,7 +486,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
let tracked = opts.tracked;
|
||||
let (kind, list) = para_kind(doc, p);
|
||||
let list = flow_list(kind, list, p, opts);
|
||||
let list = flow_list(doc, kind, list, p, opts);
|
||||
let mut done: Vec<Para> = Vec::new();
|
||||
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
|
||||
let mut out = Para {
|
||||
@@ -494,7 +522,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
// Formats that keep hidden text get it, marked hidden.
|
||||
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
|
||||
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
|
||||
let mut f = run_fmt(doc, props, in_code, opts);
|
||||
let mut f = run_fmt(doc, p.props.style.as_deref(), props, in_code, opts);
|
||||
if tracked {
|
||||
(f.ins, f.del) = rev_marks(doc, props);
|
||||
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
|
||||
|
||||
@@ -50,6 +50,20 @@ impl WithExtra for CharProps {
|
||||
if f.underline {
|
||||
self.underline_color = x.underline_color;
|
||||
}
|
||||
// Turned off on the text, over what the style of its paragraph or span turns on.
|
||||
if !f.bold && x.bold == Some(false) {
|
||||
self.bold = Some(false);
|
||||
}
|
||||
if !f.italic && x.italic == Some(false) {
|
||||
self.italic = Some(false);
|
||||
}
|
||||
if !f.strike && x.strike == Some(false) {
|
||||
// A double strike under it goes too.
|
||||
(self.strike, self.double_strike) = (Some(false), Some(false));
|
||||
}
|
||||
if !f.underline && x.underline == Some(Underline::None) {
|
||||
self.underline = Some(Underline::None);
|
||||
}
|
||||
if x.double_strike == Some(true) {
|
||||
(self.strike, self.double_strike) = (None, Some(true));
|
||||
}
|
||||
|
||||
@@ -1259,6 +1259,7 @@ impl Body<'_> {
|
||||
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
|
||||
self.group = Some(OpenGroup {
|
||||
anchor: get(a, "text:anchor-type").map(String::from),
|
||||
page: get(a, "text:anchor-page-number").map(String::from),
|
||||
style: get(a, "draw:style-name").map(String::from),
|
||||
members: Vec::new(),
|
||||
});
|
||||
@@ -1300,10 +1301,14 @@ impl Body<'_> {
|
||||
}
|
||||
"table-column" => {
|
||||
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
|
||||
let share = self.styles.column_share(get(a, "table:style-name")).unwrap_or(0.0);
|
||||
let cells = get(a, "table:default-cell-style-name").and_then(|s| self.styles.cell_background(s));
|
||||
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
|
||||
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
|
||||
t.shares.extend(std::iter::repeat_n(share, n.min(room)));
|
||||
t.column_backgrounds.extend(std::iter::repeat_n(cells, n.min(room)));
|
||||
}
|
||||
}
|
||||
"table-row" => self.start_row(a),
|
||||
@@ -1311,7 +1316,16 @@ impl Body<'_> {
|
||||
self.end_para();
|
||||
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
|
||||
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
|
||||
// WordCraft's tables have their backgrounds on their cells: a row's or table's on
|
||||
// those with none of their own, as it shows behind them. A cell with no style has
|
||||
// its column's default cell style; LibreOffice doesn't apply a row's.
|
||||
let behind = self.tables.last().and_then(|t| t.row_background.or(t.background));
|
||||
let in_column = self.tables.last().and_then(|t| {
|
||||
let at: usize = t.row.iter().map(|c| c.colspan.max(1) as usize).sum();
|
||||
t.column_backgrounds.get(at).copied().flatten()
|
||||
});
|
||||
let own = get(a, "table:style-name").map_or(in_column, |s| self.styles.cell_background(s));
|
||||
let shading = own.or(behind);
|
||||
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
|
||||
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
|
||||
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
|
||||
@@ -1391,16 +1405,35 @@ impl Body<'_> {
|
||||
// LibreOffice's tables keep their columns' widths, whatever their content (and however
|
||||
// far past the margins): Word's fixed layout.
|
||||
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
|
||||
// What the shares divide: an aligned table's stated width, however wide, as LibreOffice
|
||||
// keeps it; else its part of the text width.
|
||||
let room = width.filter(|_| align.is_some() && rel.is_none()).or_else(|| width_pct.map(|p| self.sec.text_width() * p / 100.0));
|
||||
let contained = !self.tables.is_empty() || self.containers.len() > 1 || self.sec.columns.count > 1;
|
||||
let extra =
|
||||
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
|
||||
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
|
||||
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||
self.tables.push(TableB {
|
||||
rows: Vec::new(),
|
||||
row: Vec::new(),
|
||||
hcover: 0,
|
||||
widths: Vec::new(),
|
||||
shares: Vec::new(),
|
||||
room,
|
||||
contained,
|
||||
column_backgrounds: Vec::new(),
|
||||
extra,
|
||||
background,
|
||||
row_background: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
|
||||
fn start_row(&mut self, a: &Attrs) {
|
||||
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
|
||||
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||
let keep = self.styles.row_keep(get(a, "table:style-name"));
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
t.row_background = background;
|
||||
t.row.clear();
|
||||
t.hcover = 0;
|
||||
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
|
||||
|
||||
@@ -61,9 +61,12 @@ pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> {
|
||||
|
||||
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
|
||||
/// an embedded formula object.
|
||||
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far.
|
||||
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor and page, its style, and its members
|
||||
/// so far.
|
||||
pub(in crate::odt) struct OpenGroup {
|
||||
pub(in crate::odt) anchor: Option<String>,
|
||||
/// Its `text:anchor-page-number`.
|
||||
pub(in crate::odt) page: Option<String>,
|
||||
pub(in crate::odt) style: Option<String>,
|
||||
pub(in crate::odt) members: Vec<model::Member>,
|
||||
}
|
||||
@@ -108,6 +111,13 @@ impl Body<'_> {
|
||||
self.frame = None;
|
||||
return;
|
||||
}
|
||||
// In line with no stated size, a picture comes at its own: fitted to the text, as
|
||||
// other formats' pictures are. A stated size is the file's.
|
||||
let room = self.sec.text_width();
|
||||
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
|
||||
// (Still in line when raised, turned or mirrored.)
|
||||
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
|
||||
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
|
||||
let inline = match (boxed, eq) {
|
||||
(Some(blocks), _) => {
|
||||
let mut look = std::mem::take(&mut self.frame_look);
|
||||
@@ -146,12 +156,14 @@ impl Body<'_> {
|
||||
.and_then(|data| picture(data, w, h, alt.trim()))
|
||||
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
|
||||
shown.map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
img.object = Some(std::sync::Arc::new(o));
|
||||
Inline::Image(img)
|
||||
})
|
||||
}
|
||||
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
if let Some(clip) = self.frame_clip.take() {
|
||||
img.crop = crop_fractions(clip, &img.data);
|
||||
@@ -338,7 +350,9 @@ impl Body<'_> {
|
||||
}
|
||||
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
|
||||
let graphic = self.styles.shape_graphic(g.style.as_deref());
|
||||
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||
let mut float =
|
||||
crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||
float.page = crate::odt::frames::page_of(g.anchor.as_deref(), g.page.as_deref(), &graphic);
|
||||
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
|
||||
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
|
||||
@@ -358,6 +372,7 @@ impl Body<'_> {
|
||||
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
||||
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
|
||||
f.z = crate::odt::frames::z_of(a);
|
||||
f.page = crate::odt::frames::page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), &g);
|
||||
f
|
||||
} else {
|
||||
crate::odt::frames::place(a, w, h, &g, self.text_size())
|
||||
@@ -393,14 +408,50 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
|
||||
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
|
||||
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
|
||||
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
|
||||
if wordcraft_metafile::is_metafile(&data) {
|
||||
// What the frame states, if it could be a size (far past any page, kept from overflowing).
|
||||
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
|
||||
let (w, h) = (stated(w), stated(h));
|
||||
let mut img = if wordcraft_metafile::is_metafile(&data) {
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
|
||||
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
|
||||
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
|
||||
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
|
||||
} else {
|
||||
make_img(data, w, h, alt)?
|
||||
};
|
||||
// A size the frame states is the file's, however large (a picture across a whole page): only
|
||||
// a picture with none is shrunk to a column. Stated on one side, the other follows the
|
||||
// picture's proportions.
|
||||
let (w, h) = match (w, h) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, w * height_per_width(&img.data)),
|
||||
(None, Some(h)) => (h / height_per_width(&img.data), h),
|
||||
(None, None) => return Some(img),
|
||||
};
|
||||
// Past what a document holds, it shrinks in proportion.
|
||||
let (w, h) = match w.max(h) {
|
||||
most if most <= 1584.0 => (w, h),
|
||||
_ if w >= h => (1584.0, h * 1584.0 / w),
|
||||
_ => (w * 1584.0 / h, 1584.0),
|
||||
};
|
||||
(img.w, img.h) = (w.max(1.0), h.max(1.0));
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
|
||||
fn fit_to(img: &mut model::Img, room: Option<f32>) {
|
||||
if let Some(room) = room.filter(|room| img.w > *room) {
|
||||
img.h = (img.h * room / img.w).max(1.0);
|
||||
img.w = room.max(1.0);
|
||||
}
|
||||
make_img(data, w, h, alt)
|
||||
}
|
||||
|
||||
/// A picture's height for each point of its width, by its own size, before anything caps it (1
|
||||
/// when it doesn't say).
|
||||
fn height_per_width(data: &[u8]) -> f32 {
|
||||
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
|
||||
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
|
||||
}
|
||||
|
||||
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
|
||||
|
||||
@@ -73,14 +73,67 @@ pub(in crate::odt) struct TableB {
|
||||
pub(in crate::odt) hcover: usize,
|
||||
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
|
||||
pub(in crate::odt) widths: Vec<f32>,
|
||||
/// Each column's share of the table (`style:rel-column-width`), 0 where the style gives none.
|
||||
pub(in crate::odt) shares: Vec<f32>,
|
||||
/// The table's width, points: what the shares divide.
|
||||
pub(in crate::odt) room: Option<f32>,
|
||||
/// It is in a cell, a frame or a column narrower than the page, whose width isn't known here.
|
||||
pub(in crate::odt) contained: bool,
|
||||
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
|
||||
pub(in crate::odt) extra: crate::model::TableExtra,
|
||||
/// The background of each column's default cell style (`table:default-cell-style-name`).
|
||||
pub(in crate::odt) column_backgrounds: Vec<Option<wordcraft_doc::props::Rgb>>,
|
||||
/// The table's background, and the row's being read.
|
||||
pub(in crate::odt) background: Option<wordcraft_doc::props::Rgb>,
|
||||
pub(in crate::odt) row_background: Option<wordcraft_doc::props::Rgb>,
|
||||
}
|
||||
|
||||
/// The narrowest column the document model keeps (a narrower one would lose the table its grid).
|
||||
const MIN_COLUMN: f32 = 6.0;
|
||||
|
||||
/// Each column's width out of `room` points, as LibreOffice shares it out: by its share if it has
|
||||
/// one, else its stated width, which comes off the room first; none narrower than a column can be
|
||||
/// (6 pt). Empty if a column has neither, or no room is left to share.
|
||||
fn widths_of_shares(widths: &[f32], shares: &[f32], room: f32) -> Vec<f32> {
|
||||
let columns = || widths.iter().zip(shares);
|
||||
let usable = |v: f32| v.is_finite() && v > 0.0;
|
||||
let left = room - columns().filter(|(_, s)| **s <= 0.0).map(|(w, _)| *w).sum::<f32>();
|
||||
let total: f32 = shares.iter().filter(|s| **s > 0.0).sum();
|
||||
if widths.len() != shares.len() || !usable(left) || !usable(total) || columns().any(|(w, s)| *w <= 0.0 && *s <= 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
columns().map(|(w, s)| if *s > 0.0 { (s / total * left).max(MIN_COLUMN) } else { *w }).collect()
|
||||
}
|
||||
|
||||
impl TableB {
|
||||
pub(in crate::odt) fn finish(self) -> FTable {
|
||||
// A column without a width leaves the whole grid to the equal split.
|
||||
let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() };
|
||||
// Columns stating their shares (LibreOffice's tables filling the margins, §20.334) divide
|
||||
// the table's width by them; a column with neither leaves the whole grid to the equal split.
|
||||
// An aligned table's shares go before the widths its columns state too, as LibreOffice
|
||||
// reads them. (A table filling the margins is fitted to them whatever its widths say, and
|
||||
// files give it both in proportion; in something narrower than the page, whose width isn't
|
||||
// known here, stated widths are the best guess.)
|
||||
let all_widths = self.widths.iter().all(|w| *w > 0.0);
|
||||
let all_shares = !self.shares.is_empty() && self.shares.iter().all(|s| *s > 0.0);
|
||||
let aligned = self.extra.align.is_some() && !self.contained;
|
||||
let by_shares = self
|
||||
.room
|
||||
.filter(|_| all_shares && (aligned || !all_widths))
|
||||
.map(|room| widths_of_shares(&self.widths, &self.shares, room))
|
||||
.filter(|w| !w.is_empty());
|
||||
let widths = if let Some(w) = by_shares {
|
||||
w
|
||||
} else if all_widths {
|
||||
self.widths
|
||||
} else {
|
||||
// With nothing to divide, or in something narrower than the page with widths stated
|
||||
// too, the shares can't be worked out here: the equal split, as before.
|
||||
let mixed = self.shares.iter().any(|s| *s <= 0.0);
|
||||
match self.room.filter(|_| !(self.contained && mixed)) {
|
||||
Some(room) => widths_of_shares(&self.widths, &self.shares, room),
|
||||
None => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut rows = self.rows;
|
||||
share_edges(&mut rows);
|
||||
// A table whose cells all say "no line" draws none.
|
||||
|
||||
@@ -417,12 +417,26 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float
|
||||
f.rot = rot;
|
||||
}
|
||||
f.z = z_of(a);
|
||||
f.page = page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), g);
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The page a drawing anchored as `anchor` (or as its style says) is on: `number`, its
|
||||
/// `text:anchor-page-number` (§19.872). 0 when it states none, or isn't anchored to a page, where
|
||||
/// LibreOffice ignores the number.
|
||||
pub(super) fn page_of(anchor: Option<&str>, number: Option<&str>, g: &Graphic) -> u32 {
|
||||
if anchor.or(g.anchor.as_deref()).map(str::trim) != Some("page") {
|
||||
return 0;
|
||||
}
|
||||
number.and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(MAX_ANCHOR_PAGE)
|
||||
}
|
||||
|
||||
/// Highest anchor page kept.
|
||||
const MAX_ANCHOR_PAGE: u32 = 1_000_000;
|
||||
|
||||
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
|
||||
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
|
||||
pub(super) fn z_of(a: &Attrs) -> u32 {
|
||||
@@ -564,7 +578,10 @@ pub(super) fn turned(f: &Float) -> bool {
|
||||
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
|
||||
/// `draw:transform` when it is turned (`w` × `h` its size).
|
||||
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
|
||||
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
|
||||
// A page number holds until it is placed by its paragraph: moved in WordCraft.
|
||||
let by_paragraph =
|
||||
matches!(f.h_rel, Anchor::Column | Anchor::Character) || matches!(f.v_rel, Anchor::Paragraph | Anchor::Line | Anchor::Character);
|
||||
let anchor = if (f.page > 0 && !by_paragraph) || (f.h_rel == Anchor::Page && f.v_rel == Anchor::Page) {
|
||||
"page"
|
||||
} else if f.v_rel == Anchor::Line {
|
||||
"char"
|
||||
|
||||
@@ -296,6 +296,8 @@ fn add_custom_styles(doc: &mut Document, named: &[super::styles::NamedStyle], od
|
||||
}),
|
||||
contextual_spacing: look.contextual,
|
||||
bidi: look.rtl.filter(|r| *r),
|
||||
// The document's nine levels; ODF's tenth is the last of them.
|
||||
outline_level: def.outline.map(|o| o.clamp(1, 9) - 1),
|
||||
..Default::default()
|
||||
};
|
||||
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
|
||||
@@ -327,7 +329,7 @@ fn turned_off(mut chr: wordcraft_doc::props::CharProps, off: [bool; 4]) -> wordc
|
||||
chr.underline = Some(wordcraft_doc::props::Underline::None);
|
||||
}
|
||||
if strike {
|
||||
chr.strike = Some(false);
|
||||
(chr.strike, chr.double_strike) = (Some(false), Some(false));
|
||||
}
|
||||
chr
|
||||
}
|
||||
@@ -362,7 +364,7 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
|
||||
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
||||
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let numbered: Vec<bool> = (0..6).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
||||
let numbered: Vec<bool> = (0..9).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
||||
if !numbered.contains(&true) {
|
||||
return;
|
||||
}
|
||||
@@ -395,11 +397,155 @@ fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
||||
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
|
||||
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
let styles = Arc::make_mut(&mut doc.styles);
|
||||
for (i, on) in numbered.iter().enumerate() {
|
||||
if *on && let Some(st) = Arc::make_mut(&mut doc.styles).get_mut(&format!("Heading{}", i + 1)) {
|
||||
if *on && let Some(st) = styles.get_mut(&format!("Heading{}", i + 1)) {
|
||||
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
|
||||
}
|
||||
}
|
||||
// LibreOffice numbers a custom style at an outline level along with that level's headings.
|
||||
for st in &mut styles.styles {
|
||||
if let Some(l) = st.para.outline_level.filter(|l| numbered.get(usize::from(*l)) == Some(&true)) {
|
||||
st.para.numbering = Some(NumRef { num: nid, level: l });
|
||||
}
|
||||
}
|
||||
// A style at a level the outline doesn't number doesn't take its parent's number from it.
|
||||
let of_outline = |st: &wordcraft_doc::styles::Style| {
|
||||
let at = wordcraft_doc::props::ParaProps { style: Some(st.id.clone()), ..Default::default() };
|
||||
st.para.numbering.is_none()
|
||||
&& st.para.outline_level.is_some_and(|l| numbered.get(usize::from(l)) != Some(&true))
|
||||
&& styles.resolve_para(&at).numbering.is_some_and(|n| n.num == nid)
|
||||
};
|
||||
let unnumbered: Vec<String> = styles.styles.iter().filter(|st| of_outline(st)).map(|st| st.id.clone()).collect();
|
||||
for id in unnumbered {
|
||||
if let Some(st) = styles.get_mut(&id) {
|
||||
// (`num: 0` switches the number off.)
|
||||
st.para.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
}
|
||||
}
|
||||
// A heading's own outline level goes before its style's: LibreOffice numbers it by that level.
|
||||
// A heading in a list is that list's.
|
||||
let styles = doc.styles.clone();
|
||||
each_body_para(&mut doc.body, 0, &mut |p| {
|
||||
let Some(l) = p.props.outline_level.filter(|_| p.props.numbering.is_none()) else { return };
|
||||
let style = styles.resolve_para(&wordcraft_doc::props::ParaProps { style: p.props.style.clone(), ..Default::default() });
|
||||
if style.outline_level.is_none() {
|
||||
return;
|
||||
}
|
||||
let own = (numbered.get(usize::from(l)) == Some(&true)).then_some(NumRef { num: nid, level: l });
|
||||
if own != style.numbering {
|
||||
// (`num: 0` switches the style's number off.)
|
||||
p.props.numbering = Some(own.unwrap_or(NumRef { num: 0, level: 0 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Each paragraph of `blocks`, those in tables too (to 16 levels deep).
|
||||
fn each_body_para(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, f: &mut dyn FnMut(&mut wordcraft_doc::Paragraph)) {
|
||||
for b in blocks {
|
||||
match Arc::make_mut(b) {
|
||||
wordcraft_doc::Block::Para(p) => f(p),
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
each_body_para(&mut cell.blocks, depth + 1, f);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run's complex size comes from: its paragraph's style, its character style, and the size
|
||||
/// and complex size it states (as bits).
|
||||
type SizeSource<'a> = (Option<&'a str>, Option<&'a str>, Option<u32>, Option<u32>);
|
||||
|
||||
/// A complex-script size the same as the text's (what WordCraft writes for every script) is no size
|
||||
/// of its own: a later change of size takes it along. The default's and the styles' go together,
|
||||
/// unless a run's complex size would change: a size stated alone, or another complex size between
|
||||
/// them, shows what's above it, so they stay as the file says. A run's goes where its stays the same.
|
||||
fn one_size_for_all_scripts(doc: &mut Document) {
|
||||
let mut sources = std::collections::BTreeSet::new();
|
||||
for blocks in std::iter::once(&doc.body).chain(doc.parts.values().map(|part| &part.blocks)) {
|
||||
size_sources(blocks, 0, &mut sources);
|
||||
}
|
||||
let sizes = |styles: &wordcraft_doc::styles::StyleSheet| -> Vec<f32> {
|
||||
let of = |(para, chr, size, size_cs): &SizeSource| {
|
||||
let run = wordcraft_doc::props::CharProps {
|
||||
style: chr.map(str::to_string),
|
||||
size: size.map(f32::from_bits),
|
||||
size_cs: size_cs.map(f32::from_bits),
|
||||
..Default::default()
|
||||
};
|
||||
styles.resolve_char(*para, &run).size_cs
|
||||
};
|
||||
sources.iter().map(of).collect()
|
||||
};
|
||||
let as_read = Arc::clone(&doc.styles);
|
||||
let was = sizes(&as_read);
|
||||
let styles = Arc::make_mut(&mut doc.styles);
|
||||
let one_size = |c: &mut wordcraft_doc::props::CharProps| {
|
||||
if c.size.is_some() && c.size_cs == c.size {
|
||||
c.size_cs = None;
|
||||
}
|
||||
};
|
||||
one_size(&mut styles.default_chr);
|
||||
styles.styles.iter_mut().for_each(|st| one_size(&mut st.chr));
|
||||
if sizes(&doc.styles) != was {
|
||||
doc.styles = as_read;
|
||||
}
|
||||
let styles = Arc::clone(&doc.styles);
|
||||
let stories = std::iter::once(&mut doc.body).chain(doc.parts.values_mut().map(|part| &mut part.blocks));
|
||||
for blocks in stories {
|
||||
runs_one_size(blocks, 0, &styles);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the runs in `blocks` (and their tables, to 16 levels deep) take their complex sizes from.
|
||||
fn size_sources<'a>(blocks: &'a [Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<SizeSource<'a>>) {
|
||||
for b in blocks {
|
||||
match &**b {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
for r in &p.runs {
|
||||
out.insert((
|
||||
p.props.style.as_deref(),
|
||||
r.props.style.as_deref(),
|
||||
r.props.size.map(f32::to_bits),
|
||||
r.props.size_cs.map(f32::to_bits),
|
||||
));
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
size_sources(&cell.blocks, depth + 1, out);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`one_size_for_all_scripts`] for the runs in `blocks` (and their tables, to 16 levels deep).
|
||||
fn runs_one_size(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, styles: &wordcraft_doc::styles::StyleSheet) {
|
||||
for b in blocks {
|
||||
match Arc::make_mut(b) {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
let style = p.props.style.clone();
|
||||
for r in &mut p.runs {
|
||||
let Some(said) = r.props.size_cs.filter(|s| Some(*s) == r.props.size) else { continue };
|
||||
r.props.size_cs = None;
|
||||
if styles.resolve_char(style.as_deref(), &r.props).size_cs != said {
|
||||
r.props.size_cs = Some(said);
|
||||
}
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
runs_one_size(&mut cell.blocks, depth + 1, styles);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
|
||||
@@ -655,6 +801,8 @@ struct Parsed {
|
||||
widows: Option<bool>,
|
||||
/// The document's default Asian and complex-script fonts and languages.
|
||||
scripts: wordcraft_doc::props::CharProps,
|
||||
/// The document's default language, if it states one.
|
||||
default_lang: Option<String>,
|
||||
/// The default tab interval, points.
|
||||
default_tab: Option<f32>,
|
||||
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
|
||||
@@ -850,6 +998,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
||||
kern: styles.default_kern,
|
||||
widows: styles.stated_widows(),
|
||||
scripts: styles.default_scripts.clone(),
|
||||
default_lang: styles.document_lang(),
|
||||
default_tab: styles.default_tab,
|
||||
tabs_from_indent: styles.tabs_from_indent,
|
||||
page_doc: pages.doc.clone(),
|
||||
@@ -895,6 +1044,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
kern,
|
||||
widows,
|
||||
scripts,
|
||||
default_lang,
|
||||
default_tab,
|
||||
tabs_from_indent,
|
||||
page_doc,
|
||||
@@ -932,6 +1082,9 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
}
|
||||
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
|
||||
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
|
||||
// The file's own, or none when it states none (each application then uses its own default):
|
||||
// not the English of WordCraft's new documents.
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = default_lang;
|
||||
if let Some(t) = default_tab {
|
||||
doc.settings.default_tab = t;
|
||||
}
|
||||
@@ -970,6 +1123,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
}
|
||||
doc.last_section = last;
|
||||
page_parts::add(&mut doc, parts);
|
||||
one_size_for_all_scripts(&mut doc);
|
||||
if let Some(t) = track {
|
||||
doc.settings.track_changes = t;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn columns_xml(c: &Columns) -> String {
|
||||
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
|
||||
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
|
||||
/// next, written out in full so other programs show them as WordCraft does.
|
||||
fn kind_styles(doc: &Document) -> String {
|
||||
fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut s = String::new();
|
||||
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
|
||||
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
|
||||
@@ -63,14 +63,7 @@ fn kind_styles(doc: &Document) -> String {
|
||||
if let Some(c) = pp.contextual_spacing {
|
||||
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
|
||||
}
|
||||
// A heading level the outline doesn't number names no list style: LibreOffice would
|
||||
// otherwise give it the outline's (unnumbered) level, and its indent.
|
||||
let numbered = pp
|
||||
.numbering
|
||||
.and_then(|r| doc.numbering.level(r.num, r.level))
|
||||
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
||||
let detach = if outline.is_some() && !numbered { " style:list-style-name=\"\"" } else { "" };
|
||||
let outline = outline.map(|n| format!(" style:default-outline-level=\"{n}\"{detach}")).unwrap_or_default();
|
||||
let outline = outline.map(|n| outline_attrs(doc, n, &pp, on_outline.contains(&id))).unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
|
||||
props_text(&cp)
|
||||
@@ -79,6 +72,42 @@ fn kind_styles(doc: &Document) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// The styles of the headings in `blocks` (and their tables, to 16 levels deep) with a number by
|
||||
/// their own outline level.
|
||||
fn styles_numbered_by_own_level(blocks: &[std::sync::Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<String>) {
|
||||
for b in blocks {
|
||||
match &**b {
|
||||
wordcraft_doc::Block::Para(p) => {
|
||||
if p.props.outline_level.is_some()
|
||||
&& p.props.numbering.is_some_and(|n| n.num != 0)
|
||||
&& let Some(s) = &p.props.style
|
||||
{
|
||||
out.insert(s.clone());
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
styles_numbered_by_own_level(&cell.blocks, depth + 1, out);
|
||||
}
|
||||
}
|
||||
wordcraft_doc::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style's outline level `n` (`style:default-outline-level`), its properties being `pp`.
|
||||
fn outline_attrs(doc: &Document, n: u8, pp: &wordcraft_doc::props::ParaProps, on_outline: bool) -> String {
|
||||
// A level the outline doesn't number names no list style: LibreOffice would otherwise give
|
||||
// it the outline's (unnumbered) level, and its indent. Unless a heading in the style has a
|
||||
// number by its own level (`on_outline`), which that would take from it.
|
||||
let numbered = pp
|
||||
.numbering
|
||||
.and_then(|r| doc.numbering.level(r.num, r.level))
|
||||
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
||||
let detach = if numbered || on_outline { "" } else { " style:list-style-name=\"\"" };
|
||||
format!(" style:default-outline-level=\"{n}\"{detach}")
|
||||
}
|
||||
|
||||
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
|
||||
/// custom style by its id.
|
||||
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
||||
@@ -98,7 +127,7 @@ fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
||||
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
|
||||
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
|
||||
/// paragraph and text formatting over its parent's.
|
||||
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
||||
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||
let mut done = std::collections::BTreeSet::new();
|
||||
let mut s = String::new();
|
||||
@@ -146,16 +175,29 @@ fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) ->
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let parent = odf_parent(doc, st.based_on.as_deref());
|
||||
let outline = pp.outline_level.filter(|l| *l < 9).map(|l| outline_attrs(doc, l + 1, pp, on_outline.contains(&id))).unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
||||
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{outline}><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
||||
super::writer::odf_name(&id),
|
||||
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
|
||||
props_text(&st.chr)
|
||||
props_text(&with_complex_size(doc, &id, &st.chr))
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Paragraph style `id`'s own text formatting `chr`, with the complex-script size it takes when it
|
||||
/// states only a size: a parent's complex size where one sets it (the model takes it from there,
|
||||
/// and the writer would otherwise give it this style's size), else its own size.
|
||||
fn with_complex_size(doc: &Document, id: &str, chr: &wordcraft_doc::props::CharProps) -> wordcraft_doc::props::CharProps {
|
||||
let mut chr = chr.clone();
|
||||
if chr.size.is_some() && chr.size_cs.is_none() {
|
||||
let cs = doc.styles.resolve_char(Some(id), &wordcraft_doc::props::CharProps::default()).size_cs;
|
||||
chr.size_cs = (chr.size != Some(cs)).then_some(cs);
|
||||
}
|
||||
chr
|
||||
}
|
||||
|
||||
/// The custom character styles in `named` and those they are based on, as common text styles.
|
||||
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||
@@ -346,25 +388,31 @@ fn styles_xml(
|
||||
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
|
||||
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
|
||||
s.push_str(&format!(
|
||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
|
||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:font-size-asian=\"{size}\"{size_cs} style:letter-kerning=\"{kern}\"{color}{lang}{scripts}/></style:default-style>",
|
||||
spacing_props(defaults),
|
||||
x(&body_font),
|
||||
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
|
||||
// when unsaid, and readers differ: docs/odf-interpretation.md).
|
||||
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
|
||||
size = pt(size),
|
||||
size_cs = if doc.styles.default_chr.size_cs.is_none() { format!(" style:font-size-complex=\"{}\"", pt(size)) } else { String::new() },
|
||||
// The document's text colour (a colour, not automatic).
|
||||
color = match doc.styles.default_chr.color {
|
||||
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
|
||||
_ => String::new(),
|
||||
},
|
||||
tab = pt(tab),
|
||||
// The document's language, without which each reader uses its own default: Normal's, as
|
||||
// WordCraft has it (a Word file's Normal may state another than its defaults).
|
||||
lang = doc.styles.resolve_char(None, &Default::default()).lang.as_deref().map(|l| super::writer::lang_attrs(l, "")).unwrap_or_default(),
|
||||
scripts = super::writer::script_attrs(&doc.styles.default_chr)
|
||||
));
|
||||
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
|
||||
s.push_str(&kind_styles(doc));
|
||||
let mut on_outline = std::collections::BTreeSet::new();
|
||||
styles_numbered_by_own_level(&doc.body, 0, &mut on_outline);
|
||||
s.push_str(&kind_styles(doc, &on_outline));
|
||||
s.push_str(&outline_style(doc));
|
||||
s.push_str(&custom_styles(doc, named));
|
||||
s.push_str(&custom_styles(doc, named, &on_outline));
|
||||
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
|
||||
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
|
||||
s.push_str(&format!(
|
||||
|
||||
@@ -39,6 +39,26 @@ impl FmtDelta {
|
||||
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
|
||||
}
|
||||
set!(bold, italic, underline, strike, sup, sub, code);
|
||||
// What this turns off stays said on the text, or the style of its paragraph or span would
|
||||
// show through; turned back on, it no longer is.
|
||||
if self.bold.is_some() || self.italic.is_some() || self.underline.is_some() || self.strike.is_some() {
|
||||
use wordcraft_doc::props::Underline;
|
||||
let mut x = f.extra.take().map(|b| *b).unwrap_or_default();
|
||||
if self.strike == Some(false) {
|
||||
x.double_strike = None;
|
||||
}
|
||||
for (state, said) in [(self.bold, &mut x.bold), (self.italic, &mut x.italic), (self.strike, &mut x.strike)] {
|
||||
if let Some(on) = state {
|
||||
*said = (!on).then_some(false);
|
||||
}
|
||||
}
|
||||
match self.underline {
|
||||
Some(false) => x.underline = Some(Underline::None),
|
||||
Some(true) if x.underline == Some(Underline::None) => x.underline = None,
|
||||
_ => {}
|
||||
}
|
||||
f.extra = (x != wordcraft_doc::props::CharProps::default()).then(|| Box::new(x));
|
||||
}
|
||||
if self.color.is_some() {
|
||||
f.color = self.color;
|
||||
}
|
||||
@@ -61,10 +81,12 @@ impl FmtDelta {
|
||||
let size = f.size.unwrap_or(12.0);
|
||||
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
|
||||
}
|
||||
// An explicit "off" only cancels what a parent style set.
|
||||
for b in
|
||||
[&mut x.double_strike, &mut x.caps, &mut x.small_caps, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave]
|
||||
{
|
||||
// An explicit "off" only cancels what a parent style set (capitals are kept: a
|
||||
// paragraph's or span's style may set them too).
|
||||
if self.strike == Some(false) {
|
||||
x.double_strike = None;
|
||||
}
|
||||
for b in [&mut x.double_strike, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave] {
|
||||
if *b == Some(false) {
|
||||
*b = None;
|
||||
}
|
||||
@@ -123,14 +145,10 @@ fn script_properties(a: &Attrs, x: &mut wordcraft_doc::props::CharProps, faces:
|
||||
if let Some(s) = get(a, "style:font-style-complex") {
|
||||
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
|
||||
}
|
||||
fn tag(v: Option<&str>) -> Option<&str> {
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none" && *v != "zxx")
|
||||
}
|
||||
let lang = |l: &str, c: &str| tag(get(a, l)).map(|l| tag(get(a, c)).map_or_else(|| l.to_string(), |c| format!("{l}-{c}")));
|
||||
if let Some(l) = lang("style:language-asian", "style:country-asian") {
|
||||
if let Some(l) = language_tag(a, "style:language-asian", "style:script-asian", "style:country-asian") {
|
||||
x.lang_ea = Some(l);
|
||||
}
|
||||
if let Some(l) = lang("style:language-complex", "style:country-complex") {
|
||||
if let Some(l) = language_tag(a, "style:language-complex", "style:script-complex", "style:country-complex") {
|
||||
x.lang_bidi = Some(l);
|
||||
}
|
||||
}
|
||||
@@ -203,15 +221,26 @@ fn extra_text_properties(a: &Attrs, d: &mut FmtDelta) {
|
||||
if let Some(v) = get(a, "style:font-relief") {
|
||||
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
|
||||
}
|
||||
if let Some(l) = language(a) {
|
||||
x.lang = Some(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// The language `fo:language`, `fo:script` and `fo:country` state: see [`language_tag`].
|
||||
fn language(a: &Attrs) -> Option<String> {
|
||||
language_tag(a, "fo:language", "fo:script", "fo:country")
|
||||
}
|
||||
|
||||
/// The language the attributes `language`, `script` and `country` state, as a tag (`de-DE`,
|
||||
/// `sr-Latn-RS`); `None` when they state none, or no language at all (`none`, `zxx`). A script of
|
||||
/// any other shape than a script's is left out: written back, it would read as a country.
|
||||
fn language_tag(a: &Attrs, language: &str, script: &str, country: &str) -> Option<String> {
|
||||
fn tag(v: Option<&str>) -> Option<&str> {
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
}
|
||||
if let Some(l) = tag(get(a, "fo:language")).filter(|l| *l != "none" && *l != "zxx") {
|
||||
x.lang = Some(match tag(get(a, "fo:country")).filter(|c| *c != "none") {
|
||||
Some(c) => format!("{l}-{c}"),
|
||||
None => l.to_string(),
|
||||
});
|
||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none")
|
||||
}
|
||||
let language = tag(get(a, language)).filter(|l| *l != "zxx")?;
|
||||
let script = tag(get(a, script)).filter(|s| super::writer::is_script(s));
|
||||
Some([Some(language), script, tag(get(a, country))].into_iter().flatten().collect::<Vec<_>>().join("-"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -229,7 +258,7 @@ struct StyleInfo {
|
||||
outline: Option<u8>,
|
||||
border_bottom: bool,
|
||||
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
||||
cell_background: Option<Rgb>,
|
||||
cell_background: Option<Option<Rgb>>,
|
||||
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
|
||||
/// path, `style:repeat` and `style:position`.
|
||||
cell_picture: Option<(String, String, String)>,
|
||||
@@ -242,6 +271,8 @@ struct StyleInfo {
|
||||
cell_padding: [Option<f32>; 4],
|
||||
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
|
||||
column_width: Option<f32>,
|
||||
/// `style:rel-column-width` of the same: its share of the table (`1*`).
|
||||
column_share: Option<f32>,
|
||||
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
|
||||
/// points, or `style:rel-width`, percent of the text width).
|
||||
table_align: Option<Align>,
|
||||
@@ -252,6 +283,9 @@ struct StyleInfo {
|
||||
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
|
||||
/// or least height (`style:min-row-height`), points.
|
||||
row_height: Option<(f32, bool)>,
|
||||
/// `fo:background-color` of a table or table-row style (§20.175): what shows behind cells
|
||||
/// with none of their own. `Some(None)`: `transparent`, which clears a parent style's.
|
||||
table_background: Option<Option<Rgb>>,
|
||||
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
|
||||
row_keep: Option<bool>,
|
||||
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
|
||||
@@ -455,6 +489,8 @@ pub(super) struct NamedStyle {
|
||||
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
|
||||
/// strike-through (`fo:font-weight="normal"`…).
|
||||
pub(super) off: [bool; 4],
|
||||
/// Its outline level, 1–10 (`style:default-outline-level`).
|
||||
pub(super) outline: Option<u8>,
|
||||
}
|
||||
|
||||
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
|
||||
@@ -571,8 +607,28 @@ pub(super) fn parse_border(v: &str) -> Option<Border> {
|
||||
any.then_some(b)
|
||||
}
|
||||
|
||||
/// The properties element a table column's or row's `style:default-style` (of `family`) holds.
|
||||
fn table_default_props(family: Option<&str>) -> Option<&'static str> {
|
||||
match family {
|
||||
Some("table-column") => Some("table-column-properties"),
|
||||
Some("table-row") => Some("table-row-properties"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A table column's share of its table (`style:rel-column-width`, `1*`).
|
||||
fn column_share_of(a: &Attrs) -> Option<f32> {
|
||||
get(a, "style:rel-column-width").and_then(|v| v.trim().strip_suffix('*')?.trim().parse::<f32>().ok()).filter(|r| r.is_finite() && *r > 0.0)
|
||||
}
|
||||
|
||||
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
|
||||
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
|
||||
/// What `fo:background-color` says of a table or row: a colour, or none (`transparent`).
|
||||
fn table_background_of(a: &Attrs) -> Option<Option<Rgb>> {
|
||||
let bg = get(a, "fo:background-color")?;
|
||||
if bg.trim() == "transparent" { Some(None) } else { model::parse_color(bg).map(Some) }
|
||||
}
|
||||
|
||||
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
match name {
|
||||
"paragraph-properties" => paragraph_properties(a, s),
|
||||
@@ -599,6 +655,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
||||
}
|
||||
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
|
||||
s.table_background = table_background_of(a).or(s.table_background);
|
||||
s.table_align = match get(a, "table:align").map(str::trim) {
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
@@ -620,6 +677,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
}
|
||||
}
|
||||
"table-row-properties" => {
|
||||
s.table_background = table_background_of(a).or(s.table_background);
|
||||
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
s.row_height = Some((h.min(MAX_LEN), true));
|
||||
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
@@ -633,6 +691,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||
if let Some(w) = get(a, "style:column-width").and_then(length) {
|
||||
s.column_width = Some(w);
|
||||
}
|
||||
if let Some(r) = column_share_of(a) {
|
||||
s.column_share = Some(r);
|
||||
}
|
||||
}
|
||||
"table-cell-properties" => table_cell_properties(a, s),
|
||||
"section-properties" => {
|
||||
@@ -677,7 +738,7 @@ pub(super) fn writing_direction(m: &str) -> wordcraft_doc::props::TextDirection
|
||||
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
|
||||
/// padding and vertical alignment.
|
||||
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
|
||||
if let Some(c) = get(a, "fo:background-color").and_then(model::parse_color) {
|
||||
if let Some(c) = table_background_of(a) {
|
||||
s.cell_background = Some(c);
|
||||
}
|
||||
cell_borders_and_padding(a, s);
|
||||
@@ -792,13 +853,18 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo, faces: &HashMap<String, Str
|
||||
let weight = weight.trim();
|
||||
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
|
||||
};
|
||||
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks.
|
||||
if let Some(weight) = get(a, "fo:font-weight").or_else(|| get(a, "style:font-weight-asian")).or_else(|| get(a, "style:font-weight-complex")) {
|
||||
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks, to turn
|
||||
// it on: one of those saying normal says nothing of Latin text.
|
||||
if let Some(weight) = get(a, "fo:font-weight") {
|
||||
fmt.bold = Some(is_bold(weight));
|
||||
} else if get(a, "style:font-weight-asian").or_else(|| get(a, "style:font-weight-complex")).is_some_and(is_bold) {
|
||||
fmt.bold = Some(true);
|
||||
}
|
||||
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
|
||||
if let Some(value) = get(a, "fo:font-style").or_else(|| get(a, "style:font-style-asian")).or_else(|| get(a, "style:font-style-complex")) {
|
||||
if let Some(value) = get(a, "fo:font-style") {
|
||||
fmt.italic = Some(is_italic(value));
|
||||
} else if get(a, "style:font-style-asian").or_else(|| get(a, "style:font-style-complex")).is_some_and(is_italic) {
|
||||
fmt.italic = Some(true);
|
||||
}
|
||||
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
|
||||
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
|
||||
@@ -1039,6 +1105,8 @@ pub(super) struct Styles {
|
||||
pub(super) default_tab: Option<f32>,
|
||||
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
|
||||
pub(super) default_scripts: wordcraft_doc::props::CharProps,
|
||||
/// The language the default paragraph style states.
|
||||
pub(super) default_lang: Option<String>,
|
||||
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
|
||||
/// off unless it says so).
|
||||
pub(super) default_kern: bool,
|
||||
@@ -1048,6 +1116,8 @@ pub(super) struct Styles {
|
||||
default_color: Option<wordcraft_doc::props::Rgb>,
|
||||
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
|
||||
default_graphic: super::frames::Graphic,
|
||||
/// `style:default-style` for table columns: the share of a column whose style gives none.
|
||||
default_column_share: Option<f32>,
|
||||
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
|
||||
default_row_keep: Option<bool>,
|
||||
/// The named dashes and gradients graphic styles use.
|
||||
@@ -1057,12 +1127,23 @@ pub(super) struct Styles {
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
/// A table column's or row's `style:default-style` properties (`props` names which): what a
|
||||
/// column or row whose own style doesn't say falls back on.
|
||||
fn read_table_default(&mut self, props: &str, a: &Attrs) {
|
||||
if props == "table-row-properties" {
|
||||
self.default_row_keep = get(a, "fo:keep-together").map(|k| k.trim() == "always");
|
||||
} else {
|
||||
self.default_column_share = column_share_of(a);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
|
||||
let mut in_auto = false;
|
||||
let mut cur: Option<(String, StyleInfo)> = None;
|
||||
let mut in_default = false;
|
||||
let mut in_graphic_default = false;
|
||||
let mut in_row_default = false;
|
||||
// In a table column's or row's `style:default-style`: the properties element it reads.
|
||||
let mut in_table_default: Option<&str> = None;
|
||||
let mut dates = DateStyles::default();
|
||||
let mut list: Option<(String, Vec<ListLevel>)> = None;
|
||||
// The list or outline level being read.
|
||||
@@ -1091,12 +1172,10 @@ impl Styles {
|
||||
"default-style" if q.starts_with("style:") => {
|
||||
in_default = get(&a, "style:family") == Some("paragraph");
|
||||
in_graphic_default = get(&a, "style:family") == Some("graphic");
|
||||
in_row_default = get(&a, "style:family") == Some("table-row");
|
||||
in_table_default = table_default_props(get(&a, "style:family"));
|
||||
}
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||
"table-row-properties" if in_row_default && cur.is_none() => {
|
||||
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
|
||||
}
|
||||
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||
"text-properties" if in_default && cur.is_none() => {
|
||||
self.default_font = default_font(&a, &self.faces);
|
||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||
@@ -1105,6 +1184,7 @@ impl Styles {
|
||||
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
|
||||
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
|
||||
script_properties(&a, &mut self.default_scripts, &self.faces);
|
||||
self.default_lang = language(&a);
|
||||
}
|
||||
"paragraph-properties" if in_default && cur.is_none() => {
|
||||
paragraph_spacing(&a, &mut self.default_spacing);
|
||||
@@ -1163,7 +1243,7 @@ impl Styles {
|
||||
},
|
||||
Ev::End(q) => match local(&q) {
|
||||
"automatic-styles" => in_auto = false,
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
|
||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_table_default) = (false, false, None),
|
||||
"style" if q.starts_with("style:") => {
|
||||
if let Some((n, s)) = cur.take()
|
||||
&& self.map.len() < 100_000
|
||||
@@ -1202,6 +1282,12 @@ impl Styles {
|
||||
.map(|v| v.min(1638.0))
|
||||
}
|
||||
|
||||
/// The document's language: its Standard style's, else its default paragraph style's. (Written
|
||||
/// from the model, Standard is empty and the default style says it for both.)
|
||||
pub(super) fn document_lang(&self) -> Option<String> {
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.extra.lang.clone()).or_else(|| self.default_lang.clone())
|
||||
}
|
||||
|
||||
/// The document's default font: the Standard paragraph style's, else the default style's.
|
||||
pub(super) fn doc_font(&self) -> Option<String> {
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
|
||||
@@ -1297,6 +1383,32 @@ impl Styles {
|
||||
let chain = self.chain(name);
|
||||
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
|
||||
let mut f = base.clone();
|
||||
// What the span's character style says is its own: its paragraph turning that off doesn't
|
||||
// reach past it (ODF: the span's style wins).
|
||||
if let Some(x) = f.extra.as_deref_mut() {
|
||||
use wordcraft_doc::props::Underline;
|
||||
for (_, s) in chain.iter().skip(at) {
|
||||
let (d, e) = (&s.fmt, &s.fmt.extra);
|
||||
if d.bold.is_some() && x.bold == Some(false) {
|
||||
x.bold = None;
|
||||
}
|
||||
if d.italic.is_some() && x.italic == Some(false) {
|
||||
x.italic = None;
|
||||
}
|
||||
if d.underline.is_some() && x.underline == Some(Underline::None) {
|
||||
x.underline = None;
|
||||
}
|
||||
if (d.strike.is_some() || e.double_strike.is_some()) && x.strike == Some(false) {
|
||||
(x.strike, x.double_strike) = (None, None);
|
||||
}
|
||||
if e.caps.is_some() && x.caps == Some(false) {
|
||||
x.caps = None;
|
||||
}
|
||||
if e.small_caps.is_some() && x.small_caps == Some(false) {
|
||||
x.small_caps = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, s) in chain.iter().take(at).rev() {
|
||||
s.fmt.apply(&mut f);
|
||||
}
|
||||
@@ -1340,6 +1452,12 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.column_width)
|
||||
}
|
||||
|
||||
/// A table column's share of its table (`style:rel-column-width`): its style's, whole chain,
|
||||
/// else the default style's.
|
||||
pub(super) fn column_share(&self, name: Option<&str>) -> Option<f32> {
|
||||
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.column_share)).or(self.default_column_share)
|
||||
}
|
||||
|
||||
/// A table style's alignment, width (points) and relative width (percent), whole chain.
|
||||
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
|
||||
pub(super) fn table_breaks(&self, name: &str) -> bool {
|
||||
@@ -1379,8 +1497,13 @@ impl Styles {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
|
||||
}
|
||||
|
||||
pub(super) fn table_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.table_background).flatten()
|
||||
}
|
||||
|
||||
/// Background of a table-cell style (whole chain).
|
||||
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background).flatten()
|
||||
}
|
||||
|
||||
/// Borders of a table-cell style (whole chain).
|
||||
@@ -1507,6 +1630,13 @@ impl Styles {
|
||||
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
|
||||
fmt.size = self.default_size;
|
||||
}
|
||||
// And its language, likewise.
|
||||
if s.parent.is_none()
|
||||
&& let Some(lang) = self.default_lang.as_ref().filter(|l| self.document_lang().as_ref() != Some(*l))
|
||||
&& fmt.extra.as_ref().is_none_or(|x| x.lang.is_none())
|
||||
{
|
||||
fmt.extra.get_or_insert_with(Default::default).lang = Some(lang.clone());
|
||||
}
|
||||
let look = ParaLook {
|
||||
align: s.align,
|
||||
keep_next: s.keep_next == Some(true),
|
||||
@@ -1521,13 +1651,16 @@ impl Styles {
|
||||
};
|
||||
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
|
||||
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
|
||||
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off })
|
||||
let outline = s.outline.filter(|o| (1..=10).contains(o));
|
||||
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off, outline })
|
||||
}
|
||||
|
||||
pub(super) fn para(&self, name: &str) -> ParaLook {
|
||||
let chain = self.chain(name);
|
||||
let mut kind = Kind::Normal;
|
||||
let mut kind_at = chain.len();
|
||||
// The nearest outline level; a style with one is no kind's style: its look is its own.
|
||||
let mut outline = None;
|
||||
for (i, (n, s)) in chain.iter().enumerate() {
|
||||
kind_at = i;
|
||||
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
|
||||
@@ -1546,12 +1679,12 @@ impl Styles {
|
||||
if kind != Kind::Normal {
|
||||
break;
|
||||
}
|
||||
if let Some(o) = s.outline.filter(|o| (1..=10).contains(o)) {
|
||||
kind = Kind::Heading(o.min(6));
|
||||
break;
|
||||
}
|
||||
outline = outline.or(s.outline.filter(|o| (1..=10).contains(o)));
|
||||
kind_at = chain.len();
|
||||
}
|
||||
if let Some(o) = outline {
|
||||
kind = Kind::Heading(o.min(6));
|
||||
}
|
||||
// The nearest custom style: its formatting stays in it; the automatic styles over it give
|
||||
// the paragraph's own.
|
||||
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
|
||||
|
||||
@@ -205,6 +205,81 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
|
||||
assert!(imgs[0].data.as_slice() == png.as_slice());
|
||||
}
|
||||
|
||||
/// A picture of `w` by `h` pixels, as a PNG.
|
||||
fn png_of(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
png.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
|
||||
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame =
|
||||
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Object 1/content.xml", b"<office:spreadsheet/>"),
|
||||
("Pictures/a.png", &png_of(1000, 500)),
|
||||
]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.object.is_some(), "still the object");
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
|
||||
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
|
||||
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const NS_T: &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:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
|
||||
/// A package with `content.xml` and, when given, `styles.xml`.
|
||||
@@ -1269,7 +1344,7 @@ fn the_default_styles_font_size_is_the_documents() {
|
||||
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
|
||||
assert!(
|
||||
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|
||||
|| s.contains(r#"fo:font-size="11pt" style:letter-kerning"#),
|
||||
|| s.contains(r#"fo:font-size="11pt" style:font-size-asian="11pt""#),
|
||||
"{s}"
|
||||
);
|
||||
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -285,6 +285,431 @@ fn objects_in_hidden_text_are_kept() {
|
||||
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
|
||||
}
|
||||
|
||||
/// A paragraph in style `style` holding `span`, with `auto` among its automatic styles.
|
||||
fn off_doc(styles: &str, style: &str, auto: &str, span: &str) -> Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text><text:p text:style-name="{style}">{span}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let styles = format!("<office:document-styles {NS_T}><office:styles>{styles}</office:styles></office:document-styles>");
|
||||
import(&crate::odt::tests::package(&content, Some(&styles))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_turning_off_what_its_style_turns_on_stays_off() {
|
||||
// A word not bold in a bold heading: it showed bold, on screen and after a save.
|
||||
let on = r#"fo:font-weight="bold" fo:font-style="italic" style:text-underline-style="solid" style:text-line-through-style="solid" fo:font-variant="small-caps" fo:text-transform="uppercase""#;
|
||||
let off = r#"fo:font-weight="normal" fo:font-style="normal" style:text-underline-style="none" style:text-line-through-style="none" fo:font-variant="normal" fo:text-transform="none""#;
|
||||
let styles = format!(r#"<style:style style:name="Laut" style:family="paragraph"><style:text-properties {on}/></style:style>"#);
|
||||
let auto = format!(r#"<style:style style:name="T1" style:family="text"><style:text-properties {off}/></style:style>"#);
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let shown = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props);
|
||||
assert_eq!(p.text, "onoff", "{what}");
|
||||
let (a, b) = (shown(0), shown(1));
|
||||
assert_eq!((a.bold, a.italic, a.strike, a.small_caps, a.caps), (true, true, true, true, true), "{what}: the style's");
|
||||
assert!(a.underline != Underline::None, "{what}");
|
||||
assert_eq!((b.bold, b.italic, b.strike, b.small_caps, b.caps), (false, false, false, false, false), "{what}: turned off");
|
||||
assert_eq!(b.underline, Underline::None, "{what}");
|
||||
};
|
||||
let d = off_doc(&styles, "Laut", &auto, r#"on<text:span text:style-name="T1">off</text:span>"#);
|
||||
check(&d, "read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||
check(&import(&export(&d).unwrap()).unwrap(), "written as read");
|
||||
// Turned back on inside it: on.
|
||||
let auto = format!(
|
||||
r#"{auto}<style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#
|
||||
);
|
||||
let d = off_doc(&styles, "Laut", &auto, r#"<text:span text:style-name="T1"><text:span text:style-name="T2">x</text:span></text:span>"#);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert!(x.bold);
|
||||
assert!(x.underline != Underline::None, "{x:?}");
|
||||
assert!(!x.italic, "still off");
|
||||
// A character style's: off in the automatic style based on it.
|
||||
let styles = r#"<style:style style:name="Zeichen" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#;
|
||||
let auto = r#"<style:style style:name="T3" style:family="text" style:parent-style-name="Zeichen"><style:text-properties fo:font-weight="normal" style:text-underline-style="none"/></style:style>"#;
|
||||
let d = off_doc(styles, "Standard", auto, r#"<text:span text:style-name="T3">x</text:span>"#);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!((x.bold, x.underline), (false, Underline::None), "{x:?}");
|
||||
// Text nothing turned on says nothing.
|
||||
let d = off_doc("", "Standard", "", "plain");
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!c.contains(r#"fo:font-weight="normal""#) && !c.contains(r#"style:text-underline-style="none""#), "{c}");
|
||||
}
|
||||
|
||||
/// A weight or slant only for Asian or complex-script text says nothing of Latin text when it is
|
||||
/// normal: the heading's bold stays, on screen and saved. Latin text's own "normal" still counts.
|
||||
#[test]
|
||||
fn an_asian_or_complex_normal_doesnt_turn_latin_text_off() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let open = |attr: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {attr}/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">Bold <text:span text:style-name="T1">word</text:span></text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
};
|
||||
let run = |d: &Document| d.body[0].as_para().unwrap().runs.last().unwrap().props.clone();
|
||||
for attr in [
|
||||
r#"style:font-weight-complex="normal""#,
|
||||
r#"style:font-weight-asian="normal""#,
|
||||
r#"style:font-style-complex="normal""#,
|
||||
r#"style:font-style-asian="normal""#,
|
||||
] {
|
||||
let d = open(attr);
|
||||
assert_eq!((run(&d).bold, run(&d).italic), (None, None), "{attr}");
|
||||
let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!saved.contains(r#"fo:font-weight="normal""#) && !saved.contains(r#"fo:font-style="normal""#), "{attr}: {saved}");
|
||||
}
|
||||
assert_eq!(run(&open(r#"fo:font-weight="normal""#)).bold, Some(false));
|
||||
assert_eq!(run(&open(r#"fo:font-style="normal""#)).italic, Some(false));
|
||||
}
|
||||
|
||||
/// Strike-through turned off over a double one is off, not the double one again.
|
||||
#[test]
|
||||
fn strike_turned_off_over_a_double_strike_stays_off() {
|
||||
let double = r#"<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="double"/>"#;
|
||||
let none = r#"<style:text-properties style:text-line-through-style="none"/>"#;
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.strike, c.double_strike), (false, false), "{what}");
|
||||
};
|
||||
// A style over its parent.
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent">{none}</style:style>"#
|
||||
);
|
||||
let d = off_doc(&styles, "Kid", "", "One");
|
||||
check(&d, "a style, read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a style, written from the model");
|
||||
// A span over its paragraph's style.
|
||||
let styles = format!(r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style>"#);
|
||||
let auto = format!(r#"<style:style style:name="T1" style:family="text">{none}</style:style>"#);
|
||||
let d = off_doc(&styles, "Parent", &auto, r#"<text:span text:style-name="T1">One</text:span>"#);
|
||||
check(&d, "a span, read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a span, written from the model");
|
||||
}
|
||||
|
||||
/// A span's character style is its own: a paragraph turning bold or italic off doesn't reach past it.
|
||||
#[test]
|
||||
fn a_spans_character_style_wins_over_its_paragraph_turning_off() {
|
||||
let styles = r#"<style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>"#;
|
||||
let auto = r#"<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-weight="normal" fo:font-style="normal"/></style:style>"#;
|
||||
let d = off_doc(styles, "P1", auto, r#"<text:span text:style-name="Strong_20_Emphasis">One</text:span>"#);
|
||||
let check = |d: &Document, what: &str| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.bold, c.italic), (true, true), "{what}: {:?}", p.runs.last().unwrap().props);
|
||||
};
|
||||
check(&d, "read");
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||
}
|
||||
|
||||
/// `default` as a document's default paragraph style's text properties, with `standard` as its
|
||||
/// Standard style's.
|
||||
fn with_default_text(default: &str, standard: &str) -> Vec<u8> {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties {default}/></style:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties {standard}/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::tests::package(&content, Some(&styles))
|
||||
}
|
||||
|
||||
/// `styles`' default paragraph style.
|
||||
fn default_style(styles: &str) -> &str {
|
||||
let at = styles.find(r#"<style:default-style style:family="paragraph">"#).expect("a default paragraph style");
|
||||
styles[at..].split("</style:default-style>").next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A document's language is the one its file states, and a model-written save says it.
|
||||
#[test]
|
||||
fn the_default_language_is_the_files_and_written_back_from_the_model() {
|
||||
let d = import(&with_default_text(r#"fo:language="de" fo:country="DE""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
assert_eq!(d.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("de-DE"));
|
||||
// Written from the model (a style or list definition changed), it is still the file's: without
|
||||
// it LibreOffice shows the document in its own default language.
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="de" fo:country="DE""#), "{styles}");
|
||||
assert!(!styles.contains(r#"fo:language="en""#), "no style says English: {styles}");
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
// Saved as read, the styles WordCraft adds beside the file's don't say English either.
|
||||
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
|
||||
assert!(!styles.contains(r#"fo:language="en""#), "{styles}");
|
||||
}
|
||||
|
||||
/// The document's language as WordCraft has it is Normal's (a Word file's Normal may state
|
||||
/// another than its defaults): that is what the default style says when written from the model.
|
||||
#[test]
|
||||
fn the_documents_language_written_is_normals() {
|
||||
use std::sync::Arc;
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang = Some("en-US".into());
|
||||
Arc::make_mut(&mut d.styles).get_mut("Normal").unwrap().chr.lang = Some("fr-FR".into());
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
let def = s.split(r#"<style:default-style style:family="paragraph""#).nth(1).and_then(|t| t.split("</style:default-style>").next()).unwrap();
|
||||
assert!(def.contains(r#"fo:language="fr""#) && def.contains(r#"fo:country="FR""#), "{def}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(back.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// A file stating no language, or `none` or `zxx`, has none: not the English of new documents.
|
||||
#[test]
|
||||
fn a_file_stating_no_language_has_none_and_writes_none() {
|
||||
let d = import(&with_default_text("", "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang, None, "not the English of WordCraft's new documents");
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
assert!(!crate::odt::tests::entry(&saved, "styles.xml").contains("fo:language"));
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang, None);
|
||||
// `none` and `zxx` are no language.
|
||||
for none in ["none", "zxx"] {
|
||||
let d = import(&with_default_text(&format!(r#"fo:language="{none}" fo:country="none""#), "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang, None, "{none}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A language only Standard states is the document's, and a model-written save says it in the
|
||||
/// default style.
|
||||
#[test]
|
||||
fn a_language_only_the_standard_style_states_survives_a_rewrite() {
|
||||
let d = import(&with_default_text("", r#"fo:language="nb" fo:country="NO""#)).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="nb" fo:country="NO""#), "{styles}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||
let p = back.body[0].as_para().unwrap();
|
||||
let lang =
|
||||
p.runs.first().and_then(|r| r.props.lang.clone()).or_else(|| back.styles.resolve_char(p.props.style.as_deref(), &Default::default()).lang);
|
||||
assert_eq!(lang.as_deref(), Some("nb-NO"), "{}", crate::odt::tests::entry(&saved, "styles.xml"));
|
||||
}
|
||||
|
||||
/// A language with a script keeps it, read and written: the document's, a style's for each kind
|
||||
/// of text, and a run's.
|
||||
#[test]
|
||||
fn a_language_with_a_script_keeps_it() {
|
||||
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#), "{styles}");
|
||||
assert!(!styles.contains(r#"fo:country="Latn""#), "{styles}");
|
||||
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||
|
||||
let (latin, asian, complex) = (
|
||||
r#"fo:language="sr" fo:script="Cyrl" fo:country="BA""#,
|
||||
r#"style:language-asian="zh" style:script-asian="Hant" style:country-asian="TW""#,
|
||||
r#"style:language-complex="az" style:script-complex="Arab" style:country-complex="IR""#,
|
||||
);
|
||||
let run = r#"fo:language="bs" fo:script="Latn" fo:country="BA""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties {latin} {asian} {complex}/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {run}/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Eigen">Own <text:span text:style-name="T1">span</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let check = |d: &wordcraft_doc::Document| {
|
||||
let chr = &d.styles.get("Eigen").unwrap().chr;
|
||||
assert_eq!(
|
||||
(chr.lang.as_deref(), chr.lang_ea.as_deref(), chr.lang_bidi.as_deref()),
|
||||
(Some("sr-Cyrl-BA"), Some("zh-Hant-TW"), Some("az-Arab-IR"))
|
||||
);
|
||||
assert_eq!(d.body[0].as_para().unwrap().runs.iter().find_map(|r| r.props.lang.as_deref()), Some("bs-Latn-BA"));
|
||||
};
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||
check(&d);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(written.contains(latin) && written.contains(asian) && written.contains(complex), "{written}");
|
||||
assert!(crate::odt::tests::entry(&saved, "content.xml").contains(run));
|
||||
check(&import(&saved).unwrap());
|
||||
|
||||
// No country, a region of digits, and what isn't a script.
|
||||
let attrs = crate::odt::writer::lang_attrs;
|
||||
assert_eq!(attrs("zh-Hant", ""), r#" fo:language="zh" fo:script="Hant""#);
|
||||
assert_eq!(attrs("es-419", ""), r#" fo:language="es" fo:country="419""#);
|
||||
assert_eq!(attrs("de-DE", "-asian"), r#" style:language-asian="de" style:country-asian="DE""#);
|
||||
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latin1" fo:country="RS""#, "")).unwrap();
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-RS"));
|
||||
}
|
||||
|
||||
/// A style with no parent inherits the default style's language, not Standard's (the document's
|
||||
/// here), and one based on Standard takes Standard's.
|
||||
#[test]
|
||||
fn a_style_with_no_parent_has_the_default_styles_language() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:language="de" fo:country="DE"/></style:style><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph"/><style:style style:name="Gesagt" style:display-name="Gesagt" style:family="paragraph"><style:text-properties fo:language="fr" fo:country="FR"/></style:style><style:style style:name="Kind" style:display-name="Kind" style:family="paragraph" style:parent-style-name="Standard"/></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Eigen">a</text:p><text:p text:style-name="Gesagt">b</text:p><text:p text:style-name="Kind">c</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let check = |d: &wordcraft_doc::Document| {
|
||||
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
let lang = |id: &str| d.styles.resolve_char(Some(id), &Default::default()).lang;
|
||||
assert_eq!(lang("Eigen").as_deref(), Some("en-US"), "no parent: the default style's");
|
||||
assert_eq!(lang("Gesagt").as_deref(), Some("fr-FR"), "its own");
|
||||
assert_eq!(lang("Kind").as_deref(), Some("de-DE"), "based on Standard");
|
||||
};
|
||||
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||
check(&d);
|
||||
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// WordCraft's own documents say their language, so a reader doesn't use its own default.
|
||||
#[test]
|
||||
fn a_new_documents_language_is_stated() {
|
||||
let styles = crate::odt::tests::entry(&export(&wordcraft_doc::Document::new()).unwrap(), "styles.xml");
|
||||
assert!(default_style(&styles).contains(r#"fo:language="en" fo:country="US""#), "{styles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_is_every_scripts_size() {
|
||||
// Chinese text set to 24 pt stayed at the default size in LibreOffice: only the Latin size was
|
||||
// written.
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("漢字", CharProps { size: Some(24.0), ..Default::default() });
|
||||
p.props = Default::default();
|
||||
d.body = vec![para_block(p)];
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"fo:font-size="24pt" style:font-size-asian="24pt" style:font-size-complex="24pt""#), "{c}");
|
||||
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
assert!(styles.contains(r#"fo:font-size="12pt" style:font-size-asian="12pt" style:font-size-complex="12pt""#), "{styles}");
|
||||
// Read back every script has it.
|
||||
let back = import(&saved).unwrap();
|
||||
let run = &back.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(back.styles.resolve_char(None, run).size_cs, 24.0);
|
||||
// A complex-script size of its own stays its own, written once.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text("x", CharProps { size: Some(24.0), size_cs: Some(30.0), ..Default::default() }))];
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert_eq!(c.matches("style:font-size-complex").count(), 1, "{c}");
|
||||
assert!(c.contains(r#"style:font-size-complex="30pt""#), "{c}");
|
||||
}
|
||||
|
||||
/// Text and its complex-script size stated alike, over a default style with a smaller one (as
|
||||
/// LibreOffice's files have): that size stays, on screen and saved, for a run and for a heading.
|
||||
#[test]
|
||||
fn a_complex_size_like_the_texts_stays_over_the_default() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="14pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="18pt" style:font-size-complex="18pt"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style></office:automatic-styles><office:body><office:text><text:p>a <text:span text:style-name="T1">big</text:span></text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">h</text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let check = |d: &Document| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
assert_eq!((c.size, c.size_cs), (24.0, 24.0));
|
||||
let h = d.body[1].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(h.props.style.as_deref(), &h.runs[0].props);
|
||||
assert_eq!((c.size, c.size_cs), (18.0, 18.0));
|
||||
};
|
||||
check(&d);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||
let heading = written.split(r#"style:name="Heading_20_1""#).nth(1).and_then(|t| t.split("</style:style>").next()).unwrap();
|
||||
assert!(heading.contains(r#"style:font-size-complex="18pt""#) && !heading.contains(r#"style:font-size-complex="14pt""#), "{heading}");
|
||||
check(&import(&saved).unwrap());
|
||||
|
||||
// A default stating one size for all goes with that size: Normal changed takes it along.
|
||||
let same = styles.replace(r#"style:font-size-complex="14pt""#, r#"style:font-size-complex="12pt""#);
|
||||
let d = import(&package(&content, Some(&same))).unwrap();
|
||||
assert_eq!(d.styles.default_chr.size_cs, None);
|
||||
}
|
||||
|
||||
/// A complex size equal to the text's goes only where no run's complex size changes without it:
|
||||
/// not under a character style over another size, nor under a size stated alone.
|
||||
#[test]
|
||||
fn a_complex_size_something_shows_stays() {
|
||||
let open = |s: &str, auto: &str, body: &str| {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/>{s}</office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
};
|
||||
let sizes = |d: &Document| {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
(c.size, c.size_cs)
|
||||
};
|
||||
// A character style's, over a paragraph style with another complex size.
|
||||
let d = open(
|
||||
r#"<style:style style:name="P" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="12pt" style:font-size-complex="18pt"/></style:style><style:style style:name="C" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style>"#,
|
||||
"",
|
||||
r#"<text:p text:style-name="P"><text:span text:style-name="C">One</text:span></text:p>"#,
|
||||
);
|
||||
assert_eq!(sizes(&d), (24.0, 24.0));
|
||||
let parent = r#"<style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style>"#;
|
||||
// A parent's, under a child stating only a size.
|
||||
let kid = format!(
|
||||
r#"{parent}<style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style>"#
|
||||
);
|
||||
assert_eq!(sizes(&open(&kid, "", r#"<text:p text:style-name="Kid">One</text:p>"#)), (20.0, 16.0));
|
||||
// A style's, under a run stating only a size.
|
||||
let d = open(
|
||||
parent,
|
||||
r#"<style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style>"#,
|
||||
r#"<text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p>"#,
|
||||
);
|
||||
assert_eq!(sizes(&d), (30.0, 16.0));
|
||||
// Nothing shows it: one size for all, so a change of size takes it along.
|
||||
let d = open(parent, "", r#"<text:p text:style-name="Parent">One</text:p>"#);
|
||||
assert_eq!(sizes(&d), (16.0, 16.0));
|
||||
assert_eq!(d.styles.get("Parent").unwrap().chr.size_cs, None);
|
||||
}
|
||||
|
||||
/// A size stated alone, by a run or a style, under a different complex size above it: saved from the
|
||||
/// model, and saved again, the complex size is still the one above, not the stated size.
|
||||
#[test]
|
||||
fn a_size_stated_alone_keeps_the_complex_size_above_it_when_saved() {
|
||||
let sizes = |d: &Document, i: usize| {
|
||||
let p = d.body[i].as_para().unwrap();
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||
(c.size, c.size_cs)
|
||||
};
|
||||
let twice = |d: Document| {
|
||||
let once = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
let again = import(&crate::odt::tests::export_model(&once).unwrap()).unwrap();
|
||||
(once, again)
|
||||
};
|
||||
// From a file: a run stating only a size, and a style stating only a size, under a parent
|
||||
// style with its own complex size.
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p><text:p text:style-name="Kid">Two</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
assert_eq!((sizes(&d, 0), sizes(&d, 1)), ((30.0, 16.0), (20.0, 16.0)));
|
||||
let (once, again) = twice(d);
|
||||
assert_eq!((sizes(&once, 0), sizes(&once, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved once");
|
||||
assert_eq!((sizes(&again, 0), sizes(&again, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved twice");
|
||||
// Built in the model (a DOCX run with `w:sz` and no `w:szCs`): the default's complex size.
|
||||
let mut d = Document::new();
|
||||
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||
styles.default_chr.size = Some(12.0);
|
||||
styles.default_chr.size_cs = Some(16.0);
|
||||
let mut p = Paragraph::with_text("x", CharProps { size: Some(30.0), ..Default::default() });
|
||||
p.props = Default::default();
|
||||
d.body = vec![para_block(p)];
|
||||
assert_eq!(sizes(&d, 0), (30.0, 16.0));
|
||||
let (once, again) = twice(d);
|
||||
assert_eq!((sizes(&once, 0), sizes(&again, 0)), ((30.0, 16.0), (30.0, 16.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_headings_text_of_another_size_keeps_it() {
|
||||
// Only the size its style gives goes with the style: a smaller word in a heading came back
|
||||
|
||||
@@ -368,6 +368,65 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
|
||||
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_anchored_to_a_page_stays_on_that_page() {
|
||||
// LibreOffice's page-anchored frames name their page. Written without it, a frame on page 3
|
||||
// came back on page 1 in LibreOffice.
|
||||
let frame = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&crate::odt::tests::package(&content, None)).unwrap()
|
||||
};
|
||||
let d = frame(r#"text:anchor-type="page" text:anchor-page-number="3""#);
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||
let back =
|
||||
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()).unwrap());
|
||||
assert!(back.contains(r#"text:anchor-page-number="3""#), "again: {back}");
|
||||
// A frame anchored to its paragraph names no page, and junk is none.
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&frame(r#"text:anchor-type="paragraph""#)).unwrap());
|
||||
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||
for junk in ["-1", "abc", "", "99999999999999"] {
|
||||
let d = frame(&format!(r#"text:anchor-type="page" text:anchor-page-number="{junk}""#));
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(!c.contains("anchor-page-number"), "{junk}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page number only says something on a page anchor. On another LibreOffice ignores it, so it
|
||||
/// doesn't make a drawing anchored to a page when saved.
|
||||
#[test]
|
||||
fn a_page_number_on_another_anchor_is_no_page_anchor() {
|
||||
let saved = |styles: &str, body: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::package(&content, None)).unwrap()).unwrap())
|
||||
};
|
||||
let frame = |attrs: &str| {
|
||||
format!(
|
||||
r#"<text:p>One<draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame></text:p>"#
|
||||
)
|
||||
};
|
||||
for anchor in ["paragraph", "char"] {
|
||||
let c = saved("", &frame(&format!(r#"text:anchor-type="{anchor}" text:anchor-page-number="3""#)));
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{anchor}: {c}");
|
||||
}
|
||||
let c = saved(
|
||||
"",
|
||||
r#"<text:p>One<draw:line text:anchor-type="paragraph" text:anchor-page-number="3" svg:x1="1cm" svg:y1="1cm" svg:x2="3cm" svg:y2="3cm"/></text:p>"#,
|
||||
);
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "a line: {c}");
|
||||
// An anchor its style gives counts as one the frame states.
|
||||
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="page"/></style:style>"#;
|
||||
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph"/></style:style>"#;
|
||||
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
|
||||
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
|
||||
|
||||
@@ -160,6 +160,243 @@ fn a_table_starting_a_page_starts_it() {
|
||||
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_stating_only_their_shares_keep_their_proportions() {
|
||||
// LibreOffice's CV template: a table filling the margins whose columns give only
|
||||
// `style:rel-column-width`. They were read as equal.
|
||||
let column = |name: &str, share: &str| {
|
||||
format!(
|
||||
r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties style:rel-column-width="{share}"/></style:style>"#
|
||||
)
|
||||
};
|
||||
let table = 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 grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", "3000*")), table);
|
||||
let g = grid(&d);
|
||||
assert_eq!(g.len(), 2, "{g:?}");
|
||||
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||
// Rewritten from the model, the proportions stay.
|
||||
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||
let g = grid(&back);
|
||||
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||
// A column without a share leaves the equal split, and junk shares are none.
|
||||
for junk in ["0*", "-1*", "NaN*", "1e39*", "abc"] {
|
||||
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", junk)), table);
|
||||
let g = grid(&d);
|
||||
assert_eq!(g.len(), 2, "{junk}: {g:?}");
|
||||
assert!((g[0] - g[1]).abs() < 0.01, "{junk}: {g:?}");
|
||||
}
|
||||
// A table filling the margins with a width stated beside each share keeps the widths: files
|
||||
// give them in proportion, and the table is fitted to the margins.
|
||||
let stated = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="2*"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="1in" style:rel-column-width="1*"/></style:style>"#;
|
||||
let g = grid(&doc(stated, table));
|
||||
assert!((g[0] / g[1] - 2.0).abs() < 0.01, "{g:?}");
|
||||
}
|
||||
|
||||
/// A share-only table wider than the text keeps its stated width, as in LibreOffice; a column with
|
||||
/// only a width takes it first, and the shares divide the rest; a column with no share of its own
|
||||
/// takes the default style's.
|
||||
#[test]
|
||||
fn columns_by_shares_divide_the_right_width() {
|
||||
let column = |name: &str, props: &str| {
|
||||
format!(r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties {props}/></style:style>"#)
|
||||
};
|
||||
let table_style = |props: &str| format!(r#"<style:style style:name="T" style:family="table"><style:table-properties {props}/></style:style>"#);
|
||||
let table = |names: &[&str]| {
|
||||
let columns: String = names
|
||||
.iter()
|
||||
.map(|n| if n.is_empty() { "<table:table-column/>".to_string() } else { format!(r#"<table:table-column table:style-name="{n}"/>"#) })
|
||||
.collect();
|
||||
let cells: String = names.iter().map(|_| "<table:table-cell><text:p>x</text:p></table:table-cell>").collect();
|
||||
format!(r#"<table:table table:style-name="T">{columns}<table:table-row>{cells}</table:table-row></table:table>"#)
|
||||
};
|
||||
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||
let near = |got: &[f32], want: &[f32]| {
|
||||
assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.5), "{got:?}, not {want:?}")
|
||||
};
|
||||
// 576 pt, aligned, in a narrower text area.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="8in" table:align="left""#),
|
||||
column("A", r#"style:rel-column-width="1000*""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[144.0, 432.0]);
|
||||
near(&grid(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), &[144.0, 432.0]);
|
||||
// 72 pt stated, the other 300 by shares.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}{}",
|
||||
table_style(r#"style:width="372pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="72pt""#),
|
||||
column("B", r#"style:rel-column-width="1000*""#),
|
||||
column("C", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B", "C"]),
|
||||
);
|
||||
near(&grid(&d), &[72.0, 75.0, 225.0]);
|
||||
// A share on every column goes before a stated width, as in LibreOffice.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="72pt" style:rel-column-width="1000*""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[100.0, 300.0]);
|
||||
// An aligned table whose every column states both: the shares (measured in LibreOffice 26.8).
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:column-width="300pt" style:rel-column-width="1*""#),
|
||||
column("B", r#"style:column-width="100pt" style:rel-column-width="1*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[200.0, 200.0]);
|
||||
// A share too small for a column is the narrowest one, not the grid lost to the equal split.
|
||||
let d = doc(
|
||||
&format!(
|
||||
"{}{}{}",
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("A", r#"style:rel-column-width="1*""#),
|
||||
column("B", r#"style:rel-column-width="65535*""#)
|
||||
),
|
||||
&table(&["A", "B"]),
|
||||
);
|
||||
near(&grid(&d), &[6.0, 400.0]);
|
||||
// The default style's share, for a column with none and one whose style says none.
|
||||
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"><office:styles><style:default-style style:family="table-column"><style:table-column-properties style:rel-column-width="1000*"/></style:default-style></office:styles></office:document-styles>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}</office:automatic-styles><office:body><office:text>{}</office:text></office:body></office:document-content>"#,
|
||||
table_style(r#"style:width="400pt" table:align="left""#),
|
||||
column("B", r#"style:rel-column-width="3000*""#),
|
||||
column("E", r#"style:use-optimal-column-width="false""#),
|
||||
table(&["", "B"])
|
||||
);
|
||||
near(&grid(&import(&package(&content, Some(styles))).unwrap()), &[100.0, 300.0]);
|
||||
}
|
||||
|
||||
/// A table inside a cell doesn't know its width from the page: with only shares and no width of
|
||||
/// its own to divide, or with some columns stating widths, its columns split it equally, as before.
|
||||
#[test]
|
||||
fn a_table_in_a_cell_shares_out_no_page_width() {
|
||||
let styles = r#"<style:style style:name="W" style:family="table-column"><style:table-column-properties style:column-width="100pt"/></style:style><style:style style:name="S" style:family="table-column"><style:table-column-properties style:rel-column-width="1*"/></style:style><style:style style:name="L" style:family="table"><style:table-properties table:align="left"/></style:style>"#;
|
||||
for (style, columns) in [
|
||||
(r#" table:style-name="L""#, r#"<table:table-column table:style-name="S"/><table:table-column table:style-name="S"/>"#),
|
||||
("", r#"<table:table-column table:style-name="W"/><table:table-column table:style-name="S"/>"#),
|
||||
] {
|
||||
let inner = format!(
|
||||
r#"<table:table{style}>{columns}<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 body = format!(
|
||||
r#"<table:table><table:table-column table:style-name="W"/><table:table-column table:style-name="W"/><table:table-row><table:table-cell>{inner}</table:table-cell><table:table-cell><text:p>c</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc(styles, &body);
|
||||
let outer = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let inner = outer.rows[0].cells[0].blocks.iter().find_map(|b| b.as_table()).unwrap();
|
||||
// The equal split main gives, which layout fits to the cell (not two page-wide columns).
|
||||
let equal = inner.grid.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
|
||||
assert!(equal && inner.grid.first().is_some_and(|w| *w < 300.0), "{style}: {:?}", inner.grid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tables_and_rows_background_show_behind_their_cells() {
|
||||
// Table Properties › Background: it was dropped, on screen and in LibreOffice after a save.
|
||||
let styles = r##"<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ddffdd"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#ffdddd"/></style:style><style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#ddddff"/></style:style>"##;
|
||||
let table = r#"<table:table table:style-name="T"><table:table-row><table:table-cell><text:p>table</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row><table:table-row table:style-name="R"><table:table-cell><text:p>row</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||
let colours = |d: &wordcraft_doc::Document| {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let want = |t: &str, r: &str, c: &str| vec![Some(t.to_string()), Some(c.to_string()), Some(r.to_string()), Some(c.to_string())];
|
||||
let d = doc(styles, table);
|
||||
assert_eq!(colours(&d), want("ddffdd", "ffdddd", "ddddff"));
|
||||
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want("ddffdd", "ffdddd", "ddddff"));
|
||||
}
|
||||
|
||||
/// As LibreOffice shows them: a cell's own style, else its column's default cell style (a row's
|
||||
/// isn't applied), else the row's background, else the table's. A row or table clearing its
|
||||
/// background (`transparent`) doesn't take its parent's; the table's shows behind that row.
|
||||
#[test]
|
||||
fn a_cells_background_comes_from_its_column_then_row_then_table() {
|
||||
let cell = |n: &str, c: &str| {
|
||||
format!(
|
||||
r##"<style:style style:name="{n}" style:family="table-cell"><style:table-cell-properties fo:background-color="#{c}"/></style:style>"##
|
||||
)
|
||||
};
|
||||
let styles = format!(
|
||||
r##"{}{}<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ffff00"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style><style:style style:name="Clear" style:family="table-row" style:parent-style-name="R"><style:table-row-properties fo:background-color="transparent"/></style:style>"##,
|
||||
cell("InRow", "ff0000"),
|
||||
cell("InColumn", "00ff00"),
|
||||
);
|
||||
let c = "<table:table-cell><text:p>x</text:p></table:table-cell>";
|
||||
let table = format!(
|
||||
r#"<table:table table:style-name="T"><table:table-column table:default-cell-style-name="InColumn"/><table:table-column/><table:table-row table:style-name="R" table:default-cell-style-name="InRow">{c}{c}</table:table-row><table:table-row>{c}{c}</table:table-row><table:table-row table:style-name="Clear">{c}{c}</table:table-row></table:table>"#
|
||||
);
|
||||
let colours = |d: &wordcraft_doc::Document| {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
let want = vec![s("00ff00"), s("0000ff"), s("00ff00"), s("ffff00"), s("00ff00"), s("ffff00")];
|
||||
let d = doc(&styles, &table);
|
||||
assert_eq!(colours(&d), want);
|
||||
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A column default after a spanning cell belongs to the column the cell is in.
|
||||
#[test]
|
||||
fn a_column_default_counts_spanned_columns() {
|
||||
let styles =
|
||||
r##"<style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#00ff00"/></style:style>"##;
|
||||
let table = r#"<table:table><table:table-column/><table:table-column/><table:table-column table:default-cell-style-name="C"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>a</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||
let d = doc(styles, table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let last = t.rows[0].cells.iter().rev().find(|c| !c.blocks.is_empty()).unwrap();
|
||||
assert_eq!(last.props.shading.map(|s| s.hex().to_ascii_lowercase()).as_deref(), Some("00ff00"));
|
||||
}
|
||||
|
||||
/// A column's default cell style that names nothing there is no background, however long its name.
|
||||
#[test]
|
||||
fn a_column_default_naming_no_style_is_no_background() {
|
||||
let long = "x".repeat(100_000);
|
||||
let table = format!(
|
||||
r#"<table:table><table:table-column table:number-columns-repeated="63" table:default-cell-style-name="{long}"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc("", &table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||
}
|
||||
|
||||
/// A cell style saying `transparent` over a coloured parent has no background of its own: what's
|
||||
/// behind it shows, in LibreOffice too, from a column's default cell style or the cell's own.
|
||||
#[test]
|
||||
fn a_transparent_cell_style_stops_its_parents_colour() {
|
||||
let styles = r##"<style:style style:name="PC" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style><style:style style:name="KC" style:family="table-cell" style:parent-style-name="PC"><style:table-cell-properties fo:background-color="transparent"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style>"##;
|
||||
for (column, cell) in [(r#" table:default-cell-style-name="KC""#, ""), ("", r#" table:style-name="KC""#)] {
|
||||
let table = format!(
|
||||
r#"<table:table><table:table-column{column}/><table:table-row table:style-name="R"><table:table-cell{cell}><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||
);
|
||||
let d = doc(styles, &table);
|
||||
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, crate::model::parse_color("#0000ff"), "{column}{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_keeps_together_says_so() {
|
||||
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
|
||||
|
||||
@@ -152,3 +152,23 @@ fn a_diagram_is_saved_as_a_group_of_its_drawing() {
|
||||
assert!(kinds.contains(&ShapeKind::RoundedRectangle) && kinds.contains(&ShapeKind::Arrow), "{kinds:?}");
|
||||
assert!(members.iter().any(|m| m.4 == "Plan"), "its text: {members:?}");
|
||||
}
|
||||
|
||||
/// A group anchored to a page stays on that page, and a number on another anchor says nothing.
|
||||
#[test]
|
||||
fn a_group_anchored_to_a_page_stays_on_that_page() {
|
||||
let saved = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:g {attrs}><draw:rect svg:x="3cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/><draw:rect svg:x="6cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/></draw:g><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, None)).unwrap();
|
||||
crate::odt::tests::export_model(&d).unwrap()
|
||||
};
|
||||
let on_two = saved(r#"text:anchor-type="page" text:anchor-page-number="2""#);
|
||||
let c = content_of(&on_two);
|
||||
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="2""#), "{c}");
|
||||
let back = import(&on_two).unwrap();
|
||||
let again = content_of(&crate::odt::tests::export_model(&back).unwrap());
|
||||
assert!(again.contains(r#"text:anchor-page-number="2""#), "again: {again}");
|
||||
let c = content_of(&saved(r#"text:anchor-type="paragraph" text:anchor-page-number="2""#));
|
||||
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{c}");
|
||||
}
|
||||
|
||||
@@ -168,6 +168,38 @@ fn an_object_in_a_header_is_kept_too() {
|
||||
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
|
||||
}
|
||||
|
||||
/// A frame's stated size goes before fitting a column: on either side alone the other follows the
|
||||
/// picture's proportions, and only past what a document holds does it shrink, in proportion.
|
||||
#[test]
|
||||
fn a_picture_keeps_the_size_its_frame_states_on_either_side() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let size = |w: Option<f32>, h: Option<f32>| {
|
||||
let img = crate::odt::body::drawing::picture(png(200, 100), w, h, "").unwrap();
|
||||
(img.w, img.h)
|
||||
};
|
||||
let near = |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{got:?}, not {want:?}");
|
||||
// Both, one or the other, past a column's 468 pt.
|
||||
near(size(Some(600.0), Some(100.0)), (600.0, 100.0));
|
||||
near(size(Some(600.0), None), (600.0, 300.0));
|
||||
near(size(None, Some(400.0)), (800.0, 400.0));
|
||||
// Past what a document holds: in proportion, whichever side is stated.
|
||||
near(size(Some(2000.0), Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(2000.0), None), (1584.0, 792.0));
|
||||
near(size(None, Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(f32::MAX), None), (1584.0, 792.0));
|
||||
// None stated, or nothing a size could be: its own, fitted to a column.
|
||||
near(size(None, None), (150.0, 75.0));
|
||||
for junk in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] {
|
||||
near(size(Some(junk), Some(junk)), (150.0, 75.0));
|
||||
}
|
||||
let wide = crate::odt::body::drawing::picture(png(1000, 500), None, None, "").unwrap();
|
||||
near((wide.w, wide.h), (468.0, 234.0));
|
||||
}
|
||||
|
||||
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
|
||||
/// can't show it, and a save from the model writes it back.
|
||||
#[test]
|
||||
|
||||
@@ -112,3 +112,327 @@ fn a_heading_looks_as_its_style_and_has_the_level_its_element_gives() {
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!((props(&back, 0).style.as_deref(), props(&back, 0).outline_level), (Some("Heading2"), Some(0)));
|
||||
}
|
||||
|
||||
/// A document with these common `styles` and this `body`.
|
||||
fn in_custom_style(styles: &str, body: &str) -> Document {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/>{styles}</office:styles></office:document-styles>"#
|
||||
);
|
||||
let content =
|
||||
format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
|
||||
import(&package(&content, Some(&styles))).unwrap()
|
||||
}
|
||||
|
||||
const HEAD: &str = r#"<style:style style:name="Head" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1" style:class="text"><style:text-properties fo:font-size="26pt" fo:font-weight="bold"/></style:style>"#;
|
||||
|
||||
fn saved_after_an_edit(d: &mut Document) -> Vec<u8> {
|
||||
d.insert_text(&wordcraft_doc::Pos::body(0, 0), "Yearly ", &Default::default()).unwrap();
|
||||
export(d).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_style_with_an_outline_level_stays_the_paragraphs_style() {
|
||||
// The outline level is the style's; the look stays the file's own, not Heading 1's.
|
||||
let mut d = in_custom_style(HEAD, r#"<text:p text:style-name="Head">Annual report edition</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!(p.style.as_deref(), Some("Head"));
|
||||
assert_eq!(d.styles.resolve_para(&p).outline_level, Some(0), "still a heading in WordCraft");
|
||||
let look = d.styles.resolve_char(p.style.as_deref(), &Default::default());
|
||||
assert_eq!((look.size, look.bold), (26.0, true));
|
||||
let r = d.body[0].as_para().unwrap().runs.first().unwrap().props.clone();
|
||||
assert!(r.size.is_none() && r.bold.is_none(), "the look stays in the style: {r:?}");
|
||||
|
||||
// Edited, it is written from the model, under its own name, at its level.
|
||||
let saved = saved_after_an_edit(&mut d);
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">Yearly Annual"#), "{c}");
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
let head = s.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).unwrap_or("");
|
||||
assert!(head.contains(r#"style:default-outline-level="1""#) && head.contains(r#"fo:font-size="26pt""#), "{head}");
|
||||
|
||||
// And read back, it is the same heading.
|
||||
let back = import(&saved).unwrap();
|
||||
let p = back.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), back.styles.resolve_para(&p).outline_level), (Some("Head"), Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unedited_paragraph_in_such_a_style_is_written_as_read() {
|
||||
let body = r#"<text:p text:style-name="Head">Annual report edition</text:p>"#;
|
||||
let saved = export(&in_custom_style(HEAD, body)).unwrap();
|
||||
assert!(crate::odt::tests::content_of(&saved).contains(body));
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
assert_eq!(s.matches(r#"style:name="Head""#).count(), 1, "{s}");
|
||||
assert!(s.contains(r#"style:default-outline-level="1""#), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_level_of_such_a_style_is_written_as_it_is() {
|
||||
// The paragraph kinds stop at Heading 6; the outline levels go on, and a style based on another
|
||||
// heading has its own.
|
||||
let styles = r#"<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Deep" style:family="paragraph" style:default-outline-level="8"/><style:style style:name="Under" style:family="paragraph" style:parent-style-name="Heading_20_2" style:default-outline-level="3"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Deep"/>"#;
|
||||
let body =
|
||||
r#"<text:p text:style-name="Deep">deep</text:p><text:p text:style-name="Under">under</text:p><text:p text:style-name="Child">child</text:p>"#;
|
||||
let d = in_custom_style(styles, body);
|
||||
let level = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).outline_level;
|
||||
assert_eq!([level(&d, 0), level(&d, 1), level(&d, 2)], [Some(7), Some(2), Some(7)]);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Deep" text:outline-level="8">deep"#), "{c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Under" text:outline-level="3">under"#), "{c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Child" text:outline-level="8">child"#), "{c}");
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!([level(&back, 0), level(&back, 1), level(&back, 2)], [Some(7), Some(2), Some(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraphs_own_level_and_formatting_stay_over_such_a_style() {
|
||||
let styles = format!(r#"<office:document-styles {NS_T}><office:styles>{HEAD}</office:styles></office:document-styles>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Head"><style:paragraph-properties fo:text-align="center"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="P1" text:outline-level="2">Moved</text:h></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), p.outline_level, p.align), (Some("Head"), Some(1), Some(wordcraft_doc::Align::Center)));
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"text:outline-level="2">Moved"#), "{c}");
|
||||
assert!(c.contains(r#"style:parent-style-name="Head""#) && c.contains(r#"fo:text-align="center""#), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_outline_level_on_a_custom_style_is_no_heading() {
|
||||
for bad in ["0", "11", "255", "999", "-1", "x", ""] {
|
||||
let styles = format!(r#"<style:style style:name="Head" style:family="paragraph" style:default-outline-level="{bad}"/>"#);
|
||||
let d = in_custom_style(&styles, r#"<text:p text:style-name="Head">Plain</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Head"), None), "{bad}");
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains(r#"<text:p text:style-name="Head">Plain"#), "{bad}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn such_a_style_is_numbered_with_its_level() {
|
||||
// LibreOffice numbers it through the outline style, one list with the level's headings.
|
||||
let d = in_custom_style(
|
||||
&format!(
|
||||
r#"{HEAD}<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{NUMBERED}"#
|
||||
),
|
||||
r#"<text:p text:style-name="Head">First</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Second</text:h>"#,
|
||||
);
|
||||
let num = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).numbering;
|
||||
assert!(num(&d, 0).is_some());
|
||||
assert_eq!(num(&d, 0), num(&d, 1));
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(!c.contains("<text:list"), "the outline numbers it: {c}");
|
||||
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">First"#), "{c}");
|
||||
// A numbered level stays attached to the outline; an unnumbered one doesn't take its indent.
|
||||
let head = |xml: String| xml.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).map(String::from).unwrap_or_default();
|
||||
assert!(!head(crate::odt::tests::styles_of(&saved)).contains("style:list-style-name"));
|
||||
let back = import(&saved).unwrap();
|
||||
assert!(num(&back, 0).is_some());
|
||||
assert_eq!(num(&back, 0), num(&back, 1));
|
||||
let plain = in_custom_style(HEAD, r#"<text:p text:style-name="Head">First</text:p>"#);
|
||||
let s = crate::odt::tests::styles_of(&crate::odt::tests::export_model(&plain).unwrap());
|
||||
assert!(head(s).contains(r#"style:list-style-name="""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_styles_based_on_any_kinds_style_keep_their_name_when_edited() {
|
||||
// The kind's style took their place, without their own look.
|
||||
let styles = r##"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Quotations" style:family="paragraph"/><style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text" style:family="paragraph"/><style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title"><style:text-properties fo:color="#cc6600"/></style:style><style:style style:name="Aside" style:family="paragraph" style:parent-style-name="Quotations"/><style:style style:name="Listing" style:family="paragraph" style:parent-style-name="Preformatted_20_Text"/><style:style style:name="Plaque" style:family="paragraph" style:parent-style-name="Heading_20_2"/>"##;
|
||||
let body = r#"<text:p text:style-name="Banner">b</text:p><text:p text:style-name="Aside">a</text:p><text:p text:style-name="Listing">l</text:p><text:p text:style-name="Plaque">p</text:p>"#;
|
||||
let saved = crate::odt::tests::export_model(&in_custom_style(styles, body)).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
for want in [
|
||||
r#"<text:p text:style-name="Banner">b"#,
|
||||
r#"<text:p text:style-name="Aside">a"#,
|
||||
r#"<text:p text:style-name="Listing">l"#,
|
||||
r#"<text:h text:style-name="Plaque" text:outline-level="2">p"#,
|
||||
] {
|
||||
assert!(c.contains(want), "{want} in {c}");
|
||||
}
|
||||
let s = crate::odt::tests::styles_of(&saved);
|
||||
assert!(s.contains(r#"style:name="Banner" style:display-name="Banner" style:family="paragraph" style:parent-style-name="Title""#), "{s}");
|
||||
}
|
||||
|
||||
/// A heading's number is its own outline level's, as in LibreOffice, not its style's: `(level)` of
|
||||
/// each paragraph's number, as layout takes it.
|
||||
fn number_levels(d: &Document) -> Vec<Option<u8>> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).map(|n| n.level))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_headings_own_outline_level_gives_its_number() {
|
||||
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format="1" text:display-levels="2" style:num-suffix="."/><text:outline-level-style text:level="3" style:num-format=""/></text:outline-style>"#;
|
||||
let styles = format!(
|
||||
r#"{HEAD}<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{outline}"#
|
||||
);
|
||||
let h = |style: &str, level: u8| format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}</text:h>"#);
|
||||
let body = [h("Head", 1), h("Head", 2), h("Head", 3), h("Low", 3), h("Low", 1), h("Low", 2), h("Heading_20_1", 2), h("Heading_20_1", 3)].concat();
|
||||
let d = in_custom_style(&styles, &body);
|
||||
let want = vec![Some(0), Some(1), None, None, Some(0), Some(1), Some(1), None];
|
||||
assert_eq!(number_levels(&d), want);
|
||||
let nums: std::collections::BTreeSet<u32> = d
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter_map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering))
|
||||
.map(|n| n.num)
|
||||
.filter(|n| *n != 0)
|
||||
.collect();
|
||||
assert_eq!(nums.len(), 1, "one list with the headings': {nums:?}");
|
||||
// Written from the model the outline still numbers them, by the levels stated, and read back
|
||||
// they are as they were.
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(!c.contains("<text:list"), "{c}");
|
||||
for (style, level) in [("Head", 2), ("Low", 1), ("Heading_20_1", 3)] {
|
||||
assert!(c.contains(&format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}"#)), "{c}");
|
||||
}
|
||||
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A heading in a list is that list's, whatever its outline level would number.
|
||||
#[test]
|
||||
fn a_heading_in_a_list_keeps_the_lists_number() {
|
||||
let styles = format!(
|
||||
r#"{HEAD}{NUMBERED}<text:list-style style:name="L"><text:list-level-style-number text:level="1" style:num-format="a"/></text:list-style>"#
|
||||
);
|
||||
let d = in_custom_style(
|
||||
&styles,
|
||||
r#"<text:list text:style-name="L"><text:list-item><text:h text:style-name="Head" text:outline-level="2">x</text:h></text:list-item></text:list>"#,
|
||||
);
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let n = p.props.numbering.expect("its list");
|
||||
assert_eq!(d.numbering.level(n.num, n.level).map(|l| l.format), Some(NumFormat::LowerLetter));
|
||||
}
|
||||
|
||||
/// A style based on Title with an outline level is a heading at that level, in its own name.
|
||||
#[test]
|
||||
fn a_title_based_style_with_an_outline_level_stays_a_heading() {
|
||||
let styles = r#"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title" style:default-outline-level="1"/>"#;
|
||||
let mut d = in_custom_style(styles, r#"<text:p text:style-name="Banner">report</text:p>"#);
|
||||
let p = d.body[0].as_para().unwrap().props.clone();
|
||||
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Banner"), Some(0)));
|
||||
let saved = saved_after_an_edit(&mut d);
|
||||
let c = crate::odt::tests::content_of(&saved);
|
||||
assert!(c.contains(r#"<text:h text:style-name="Banner" text:outline-level="1">Yearly report"#), "{c}");
|
||||
}
|
||||
|
||||
/// A style based on a numbered heading, at a level the outline doesn't number, has no number: as
|
||||
/// in LibreOffice, not its parent's.
|
||||
#[test]
|
||||
fn a_style_at_an_unnumbered_level_has_no_number_from_its_parent() {
|
||||
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format=""/><text:outline-level-style text:level="3" style:num-format="1" style:num-suffix="."/></text:outline-style>"#;
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="2"/><style:style style:name="Grand" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="3"/>{outline}"#
|
||||
);
|
||||
let body = r#"<text:h text:style-name="Heading_20_1" text:outline-level="1">One</text:h><text:p text:style-name="Child">As p</text:p><text:h text:style-name="Child" text:outline-level="2">As h</text:h><text:p text:style-name="Grand">Third</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Two</text:h>"#;
|
||||
let d = in_custom_style(&styles, body);
|
||||
let want = vec![Some(0), None, None, Some(2), Some(0)];
|
||||
assert_eq!(number_levels(&d), want);
|
||||
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||
let written = crate::odt::tests::styles_of(&saved);
|
||||
let child = written.split("<style:style ").find(|x| x.starts_with(r#"style:name="Child""#)).unwrap_or_default();
|
||||
assert!(child.contains(r#"style:list-style-name="""#), "LibreOffice gives it no number: {child}");
|
||||
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
/// A heading in a list of its own stays in it, whatever the list's levels are linked to: only the
|
||||
/// number the outline itself gives a heading goes with the outline.
|
||||
#[test]
|
||||
fn a_heading_in_a_list_linked_to_a_style_stays_in_it() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let mut d = Document::new();
|
||||
let numbering = Arc::make_mut(&mut d.numbering);
|
||||
let num = numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
let first = numbering.abstracts.last_mut().and_then(|a| a.levels.first_mut()).unwrap();
|
||||
first.style = Some("Heading1".into());
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||
p.props.style = Some("Heading2".into());
|
||||
p.props.numbering = Some(NumRef { num, level: 1 });
|
||||
let has_list = |d: &Document| crate::odt::tests::content_of(&crate::odt::tests::export_model(d).unwrap()).contains("<text:list");
|
||||
// At a deeper level than the link, no level of its own stated.
|
||||
d.body = vec![wordcraft_doc::para_block(p.clone())];
|
||||
assert!(has_list(&d), "a list of its own");
|
||||
// At its own level too, but its heading style isn't numbered: the outline wouldn't number it.
|
||||
p.props.outline_level = Some(1);
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
assert!(has_list(&d), "the outline has no number for it");
|
||||
// Its heading style numbered there by this list: the outline's number.
|
||||
Arc::make_mut(&mut d.styles).get_mut("Heading2").unwrap().para.numbering = Some(NumRef { num, level: 1 });
|
||||
assert!(!has_list(&d), "the outline's");
|
||||
}
|
||||
|
||||
/// Past Heading 6 the outline written has no number to give, so a heading there keeps its list.
|
||||
#[test]
|
||||
fn a_heading_past_level_six_keeps_its_list() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let mut d = Document::new();
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
Arc::make_mut(&mut d.styles).get_mut("Heading1").unwrap().para.numbering = Some(NumRef { num, level: 0 });
|
||||
for level in 6..=8 {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||
p.props.style = Some("Heading6".into());
|
||||
p.props.outline_level = Some(level);
|
||||
p.props.numbering = Some(NumRef { num, level });
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||
assert!(c.contains("<text:list"), "level {level}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A heading past level six numbered through its style keeps its list too, as the outline written
|
||||
/// numbers only six: read, written from the model and with its style edited.
|
||||
#[test]
|
||||
fn a_heading_past_level_six_numbered_by_its_style_keeps_its_list() {
|
||||
use std::sync::Arc;
|
||||
for level in 7..=9u8 {
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Deep" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="{level}"/><text:outline-style style:name="Outline"><text:outline-level-style text:level="{level}" style:num-format="1" style:num-suffix="."/></text:outline-style>"#
|
||||
);
|
||||
let body = format!(r#"<text:h text:style-name="Deep" text:outline-level="{level}">x</text:h>"#);
|
||||
let mut d = in_custom_style(&styles, &body);
|
||||
let check = |saved: &[u8], what: &str| {
|
||||
let c = crate::odt::tests::content_of(saved);
|
||||
assert!(c.contains("<text:list"), "level {level}, {what}: {c}");
|
||||
};
|
||||
check(&crate::odt::tests::export_model(&d).unwrap(), "from the model");
|
||||
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
|
||||
Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.italic = Some(true);
|
||||
let saved = export(&d).unwrap();
|
||||
check(&saved, "style edited");
|
||||
let back = import(&saved).unwrap();
|
||||
let p = back.body[0].as_para().unwrap();
|
||||
let n = p.props.numbering.or_else(|| back.styles.para_style_props(p.props.style.as_deref().unwrap_or("Normal")).0.numbering);
|
||||
assert_eq!(n.map(|n| n.level), Some(level - 1), "level {level}, reopened");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style at an unnumbered level with a heading numbered by its own level stays on the outline,
|
||||
/// or LibreOffice shows that heading no number; with none, it comes off, for the level's indent.
|
||||
#[test]
|
||||
fn a_style_with_a_heading_numbered_by_its_own_level_stays_on_the_outline() {
|
||||
let styles = format!(
|
||||
r#"<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph" style:default-outline-level="3"/>{NUMBERED}"#
|
||||
);
|
||||
let saved = |body: &str| crate::odt::tests::styles_of(&crate::odt::tests::export_model(&in_custom_style(&styles, body)).unwrap());
|
||||
let style_of = |xml: &str, name: &str| {
|
||||
xml.split("<style:style ").find(|x| x.starts_with(&format!(r#"style:name="{name}""#))).map(String::from).unwrap_or_default()
|
||||
};
|
||||
let xml = saved(
|
||||
r#"<text:h text:style-name="Low" text:outline-level="1">a</text:h><text:h text:style-name="Heading_20_3" text:outline-level="2">b</text:h>"#,
|
||||
);
|
||||
assert!(!style_of(&xml, "Low").contains("style:list-style-name"), "{xml}");
|
||||
assert!(!style_of(&xml, "Heading_20_3").contains("style:list-style-name"), "{xml}");
|
||||
let xml = saved(r#"<text:h text:style-name="Low" text:outline-level="3">a</text:h>"#);
|
||||
assert!(style_of(&xml, "Low").contains(r#"style:list-style-name="""#), "{xml}");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ use indexes::index_at;
|
||||
pub(super) use indexes::{IndexKind, index_instr};
|
||||
use notes::annotation_name;
|
||||
pub(super) use props::{
|
||||
align_attr, border_attrs, generated_style, generating_style, odf_border, odf_name, props_text, script_attrs, spacing_props, tab_stops,
|
||||
align_attr, border_attrs, generated_style, generating_style, is_script, lang_attrs, odf_border, odf_name, props_text, script_attrs,
|
||||
spacing_props, tab_stops,
|
||||
};
|
||||
use props::{parent_style, text_props};
|
||||
use tracked::{Item, join_deleted_marks};
|
||||
@@ -321,7 +322,7 @@ impl Writer {
|
||||
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
|
||||
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
|
||||
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
|
||||
let named = p.extra.as_ref().and_then(|x| x.style.as_deref()).filter(|_| matches!(p.kind, Kind::Normal | Kind::Rule));
|
||||
let named = p.extra.as_ref().and_then(|x| x.style.as_deref());
|
||||
let parent = match (p.kind, self.note_parent, named) {
|
||||
// A custom style: written as a common style of the same name (see `custom_styles`).
|
||||
(_, _, Some(id)) => {
|
||||
|
||||
@@ -114,7 +114,8 @@ pub(in crate::odt) fn placement(place: Place<'_>, w: f32, h: f32) -> (String, St
|
||||
match place {
|
||||
Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => {
|
||||
let (a, props, xy) = crate::odt::frames::frame_of(f, w, h);
|
||||
(anchor(a), props, xy)
|
||||
let page = if a == "page" && f.page > 0 { format!(" text:anchor-page-number=\"{}\"", f.page) } else { String::new() };
|
||||
(format!("{}{page}", anchor(a)), props, xy)
|
||||
}
|
||||
Place::Text(f) => {
|
||||
let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot));
|
||||
|
||||
@@ -10,9 +10,13 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
||||
let on = |v: Option<bool>| v == Some(true);
|
||||
if on(x.caps) {
|
||||
p.push_str(" fo:text-transform=\"uppercase\"");
|
||||
} else if x.caps == Some(false) {
|
||||
p.push_str(" fo:text-transform=\"none\"");
|
||||
}
|
||||
if on(x.small_caps) {
|
||||
p.push_str(" fo:font-variant=\"small-caps\"");
|
||||
} else if x.small_caps == Some(false) {
|
||||
p.push_str(" fo:font-variant=\"normal\"");
|
||||
}
|
||||
if on(x.hidden) {
|
||||
p.push_str(" text:display=\"none\"");
|
||||
@@ -54,14 +58,7 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
||||
}
|
||||
p.push_str(&script_attrs(x));
|
||||
if let Some(l) = x.lang.as_deref() {
|
||||
let mut parts = l.split(['-', '_']);
|
||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
if let Some(lang) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" fo:language=\"{lang}\""));
|
||||
if let Some(country) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" fo:country=\"{country}\""));
|
||||
}
|
||||
}
|
||||
p.push_str(&lang_attrs(l, ""));
|
||||
}
|
||||
p
|
||||
}
|
||||
@@ -85,28 +82,7 @@ pub(in crate::odt) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String
|
||||
extra: Some(Box::new(cp.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = text_props(&f);
|
||||
// What a style turns off that the style it is based on may turn on: said so, or the parent's
|
||||
// bold, italic, underline or strike-through would show through.
|
||||
if cp.bold == Some(false) {
|
||||
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
||||
if cp.bold_cs.is_none() {
|
||||
p.push_str(" style:font-weight-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if cp.italic == Some(false) {
|
||||
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
||||
if cp.italic_cs.is_none() {
|
||||
p.push_str(" style:font-style-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if cp.underline == Some(Underline::None) {
|
||||
p.push_str(" style:text-underline-style=\"none\"");
|
||||
}
|
||||
if cp.strike == Some(false) && cp.double_strike != Some(true) {
|
||||
p.push_str(" style:text-line-through-style=\"none\"");
|
||||
}
|
||||
p
|
||||
text_props(&f)
|
||||
}
|
||||
|
||||
/// `fo:text-align` for `a` (ODF 1.3 §20.223).
|
||||
@@ -120,8 +96,8 @@ pub(in crate::odt) fn align_attr(a: Option<Align>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag
|
||||
/// `tag`.
|
||||
/// `fo:language`, `fo:script` and `fo:country` (with `suffix`: `-asian`, `-complex` on `style:`)
|
||||
/// for BCP 47 tag `tag`. A script is the four-letter subtag after the language.
|
||||
pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
||||
let mut parts = tag.split(['-', '_']);
|
||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
@@ -129,13 +105,23 @@ pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
||||
let mut p = String::new();
|
||||
if let Some(lang) = parts.next().filter(ok) {
|
||||
p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\""));
|
||||
if let Some(country) = parts.next().filter(ok) {
|
||||
let mut next = parts.next().filter(ok);
|
||||
if let Some(script) = next.filter(|s| is_script(s)) {
|
||||
p.push_str(&format!(" {ns}:script{suffix}=\"{script}\""));
|
||||
next = parts.next().filter(ok);
|
||||
}
|
||||
if let Some(country) = next {
|
||||
p.push_str(&format!(" {ns}:country{suffix}=\"{country}\""));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether a language tag's subtag is a script (BCP 47: four letters).
|
||||
pub(in crate::odt) fn is_script(subtag: &str) -> bool {
|
||||
subtag.len() == 4 && subtag.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the
|
||||
/// two languages.
|
||||
pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String {
|
||||
@@ -168,19 +154,32 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
let mut p = String::new();
|
||||
// Complex-script weight and posture of their own are written with the other script extras.
|
||||
let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| f.extra.as_deref().and_then(pick).is_some();
|
||||
let extra = f.extra.as_deref();
|
||||
// What it turns off that its style, or the style that is based on, may turn on: said so, or
|
||||
// that bold, italic, underline or strike-through would show through.
|
||||
let off = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| extra.and_then(pick) == Some(false);
|
||||
if f.bold {
|
||||
p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\"");
|
||||
if !own_cs(|x| x.bold_cs) {
|
||||
p.push_str(" style:font-weight-complex=\"bold\"");
|
||||
}
|
||||
} else if off(|x| x.bold) {
|
||||
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
||||
if !own_cs(|x| x.bold_cs) {
|
||||
p.push_str(" style:font-weight-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
if f.italic {
|
||||
p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\"");
|
||||
if !own_cs(|x| x.italic_cs) {
|
||||
p.push_str(" style:font-style-complex=\"italic\"");
|
||||
}
|
||||
} else if off(|x| x.italic) {
|
||||
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
||||
if !own_cs(|x| x.italic_cs) {
|
||||
p.push_str(" style:font-style-complex=\"normal\"");
|
||||
}
|
||||
}
|
||||
let extra = f.extra.as_deref();
|
||||
if f.underline {
|
||||
use wordcraft_doc::props::Underline as U;
|
||||
let (style, kind, width, mode) = match extra.and_then(|e| e.underline) {
|
||||
@@ -201,11 +200,16 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
" style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}"
|
||||
));
|
||||
}
|
||||
if !f.underline && extra.is_some_and(|e| e.underline == Some(wordcraft_doc::props::Underline::None)) {
|
||||
p.push_str(" style:text-underline-style=\"none\"");
|
||||
}
|
||||
if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||
p.push_str(" style:text-line-through-style=\"solid\"");
|
||||
if extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||
p.push_str(" style:text-line-through-type=\"double\"");
|
||||
}
|
||||
} else if off(|x| x.strike) {
|
||||
p.push_str(" style:text-line-through-style=\"none\"");
|
||||
}
|
||||
if let Some(e) = extra {
|
||||
if (f.sup || f.sub) && e.position.is_some() {
|
||||
@@ -233,8 +237,13 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
||||
if let Some(c) = f.background {
|
||||
p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase()));
|
||||
}
|
||||
// One size in WordCraft's model is every script's in ODF's, as bold is: without these,
|
||||
// LibreOffice shows Asian and complex-script text at the default size whatever was chosen.
|
||||
if let Some(s) = f.size {
|
||||
p.push_str(&format!(" fo:font-size=\"{}\"", pt(s)));
|
||||
p.push_str(&format!(" fo:font-size=\"{0}\" style:font-size-asian=\"{0}\"", pt(s)));
|
||||
if f.extra.as_deref().and_then(|x| x.size_cs).is_none() {
|
||||
p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s)));
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT));
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# How WordCraft interprets OpenDocument files
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-05 · **Change:** trivial (a document's language: kept on any save, and stated when written from the model); previously major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case)
|
||||
|
||||
OpenDocument (ODF 1.3, OASIS) leaves some of a document's look to the application. Part 3 §16.2
|
||||
says that when no style sets a property and the standard gives no default, "an
|
||||
@@ -68,6 +68,7 @@ These are documented so they aren't mistaken for law:
|
||||
| `style:wrap` unstated on a frame | §20.400: no default | text wraps on both sides (LibreOffice too) |
|
||||
| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are listed above |
|
||||
| A document's default font and size when no default style gives them | not defined | WordCraft's defaults |
|
||||
| A document's language when neither its default style nor Standard states one | `fo:language`: no default | none: WordCraft proofs it in the editing language and writes none, and each reader uses its own default |
|
||||
|
||||
## How WordCraft writes ODF
|
||||
|
||||
@@ -106,7 +107,8 @@ states what the standard leaves open instead of relying on any application's def
|
||||
- drawings not held in the text's area (`style:flow-with-text="false"`, §20.267 gives no default);
|
||||
- tab stops measured as the file it was opened from measured them (`TabsRelativeToIndent` in
|
||||
`settings.xml`), from the margin for WordCraft's own documents;
|
||||
- widow and orphan control, on or off, in the default paragraph style.
|
||||
- widow and orphan control, on or off, in the default paragraph style;
|
||||
- the document's language, in the default paragraph style, when it has one.
|
||||
|
||||
## Deviations from the standard, out of necessity
|
||||
|
||||
@@ -162,6 +164,7 @@ from public bug reports and ONLYOFFICE's converter test snapshots):
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-11-05 | A document's language is read from its default style or Standard and written back from the model. It was lost when a style or list definition changed (numbering, a new style), and readers then showed the document in their own default language. Styles written from the model no longer say English for a document that isn't. A language's script (`fo:script`) is kept with it, and a style with no parent takes the default style's language when Standard's differs. |
|
||||
| 2026-11-04 | The settings removed: File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values. WordCraft makes one choice for each case the standard leaves open, the one the default made, so nothing changes for a document opened with the default. What a file states is still followed, its `TabsRelativeToIndent` and `AddParaTableSpacing` among it. LibreOffice's choices stay listed beside WordCraft's. |
|
||||
| 2026-11-04 | How WordCraft writes ODF: unchanged paragraphs and tables with drawings, comments, tracked changes, notes or marks are written as read too (the text said from the model); what is edited, and the cases left in the element audit, from the model. |
|
||||
| 2026-11-04 | The two presets are the two styles a document can be shown in: Microsoft Word style (`word`, the default; `wordcraft`, its earlier id, still taken) and LibreOffice style (`libreoffice`). The CLI's values listed one by one. |
|
||||
|
||||
Reference in New Issue
Block a user