Merge remote-tracking branch 'upstream/main' into m1056

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
vangel76
2026-11-08 00:30:11 +01:00
5 changed files with 179 additions and 4 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+29 -2
View File
@@ -79,6 +79,10 @@ pub(super) struct Body<'a> {
/// drawings were waiting before it.
loose_open: Option<(usize, usize, usize)>,
fmt: Vec<Fmt>,
/// For each paragraph being read: the size its heading, title or code style gives its text.
para_sizes: Vec<(Option<f32>, bool)>,
/// That size for the paragraph being finished.
para_size: Option<f32>,
para: Option<Para>,
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
xml_space_end: bool,
@@ -240,6 +244,8 @@ impl<'a> Body<'a> {
xml_space_end: false,
spaces_left: MAX_EXTRA_SPACES,
para_cont: false,
para_sizes: Vec::new(),
para_size: None,
lists: Vec::new(),
lists_saved: Vec::new(),
item_used: Vec::new(),
@@ -956,6 +962,8 @@ impl Body<'_> {
} else {
self.para_cont = false;
}
let kind_size = matches!(kind, Kind::Code | Kind::Heading(_) | Kind::Title).then(|| style.and_then(|s| self.styles.named_size(s))).flatten();
self.para_sizes.push((kind_size, style.is_some_and(|s| self.styles.states_size(s))));
self.fmt.push(base);
// Drawings waiting outside a paragraph come first in it, when it is the text's own (not
// a text box's, a note's or a cell's).
@@ -974,6 +982,7 @@ impl Body<'_> {
self.index_marks.clear();
let Some(mut p) = self.para.take() else { return };
self.glue_connectors(&mut p);
self.para_size = self.para_sizes.pop().and_then(|(size, stated)| size.filter(|_| stated));
// A paragraph without text has its own text formatting as its mark's: its size is its
// height, and its drawings' runs have it.
if let Some(base) = self.fmt.pop()
@@ -1024,13 +1033,18 @@ impl Body<'_> {
p.kind = Kind::Normal;
}
if matches!(p.kind, Kind::Code | Kind::Heading(_) | Kind::Title) {
// The style says the size of its text, stated again or not: text of another size keeps
// its own.
let style_size = self.para_size;
for i in &mut p.inlines {
if let Inline::Text(_, f) = i {
if p.kind == Kind::Code {
f.code = false;
f.font = None;
}
f.size = None;
if f.size == style_size {
f.size = None;
}
}
}
}
@@ -1544,7 +1558,20 @@ impl Body<'_> {
match local(q) {
"span" => {
let base = self.cur_fmt();
let fmt = get(a, "text:style-name").map_or_else(|| base.clone(), |s| self.styles.span_fmt(s, &base));
// A size in percent is of the size it is in: its heading's, where nothing nearer
// states one.
let sized = Fmt { size: base.size.or(self.para_sizes.last().and_then(|(size, _)| *size)), ..base.clone() };
let fmt = get(a, "text:style-name").map_or_else(
|| base.clone(),
|s| {
let mut fmt = self.styles.span_fmt(s, &sized);
// That size is only for working out a percent: saying nothing of size, it has none.
if base.size.is_none() && self.styles.span_fmt(s, &base).size.is_none() {
fmt.size = None;
}
fmt
},
);
self.fmt.push(fmt);
}
"a" => {
+22
View File
@@ -1263,6 +1263,24 @@ impl Styles {
/// The formatting of a span in text style `name` over `base`: the automatic styles' over the
/// nearest custom character style, which the span then names ([`Fmt::extra`]'s style, by ODF
/// name until import makes it a document style id); without one, the whole chain's.
/// The size of text in paragraph style `name` by its named styles, not its automatic ones,
/// which are the paragraph's own: what they say on what the document says.
pub(super) fn named_size(&self, name: &str) -> Option<f32> {
// The default style's: Standard's counts only for a style based on it, in the chain.
let size = self.default_size.filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1638.0));
let mut f = Fmt { size, ..Fmt::default() };
for (_, s) in self.chain(name).iter().rev().filter(|(_, s)| !s.auto) {
s.fmt.apply(&mut f);
}
f.size
}
/// Whether paragraph style `name`'s named styles state a size. Those that don't leave the size
/// to WordCraft's style for its kind, so text of any size isn't known to be the style's.
pub(super) fn states_size(&self, name: &str) -> bool {
self.chain(name).iter().any(|(_, s)| !s.auto && (s.fmt.size.is_some() || s.fmt.size_pct.is_some()))
}
pub(super) fn span_fmt(&self, name: &str, base: &Fmt) -> Fmt {
let chain = self.chain(name);
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
@@ -1462,6 +1480,10 @@ impl Styles {
};
let mut fmt = Fmt::default();
s.fmt.apply(&mut fmt);
// A percent is of what's under it: the parent's size, or with none the default style's.
if s.fmt.size_pct.is_some() {
fmt.size = self.named_size(odt);
}
// A style with no parent inherits the default style, not Standard (the document's
// defaults here): its size, when Standard's differs.
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
+123
View File
@@ -284,3 +284,126 @@ fn objects_in_hidden_text_are_kept() {
assert!(p.text.contains("Hidden"), "{:?}", p.text);
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
}
#[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
// from a save at the heading's size.
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-size="16pt"/></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="10pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">big<text:span text:style-name="T1">small</text:span></text:h></office:text></office:body></office:document-content>"#
);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let size = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props).size;
assert_eq!((size(0), size(1)), (16.0, 10.0), "{what}");
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, None, "the style's stays the style's");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
/// In a heading style with no parent, where Standard's size and the default's differ, text stating
/// a size keeps that size.
#[test]
fn a_parentless_headings_text_keeps_its_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></office:styles></office:document-styles>"#
);
for size in ["10pt", "14pt"] {
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="{size}"/></style:style><style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span><text:span text:style-name="T2">Two</text:span></text:h></office:text></office:body></office:document-content>"#
);
let want: f32 = size.trim_end_matches("pt").parse().unwrap();
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
assert_eq!(p.runs[1].props.size, None, "{size} {what}: a span stating no size has none");
assert_eq!(
d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size,
want,
"{size} {what}: {:?} {:?}",
p.props.style,
p.runs[0].props.size
);
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
}
/// A percent in a heading style with no parent is of the default style's size, not Standard's.
#[test]
fn a_percent_in_a_parentless_heading_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></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="50%"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span></text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, Some(5.0));
}
/// A heading style with no parent and a percent size is that percent of the default's size, so text
/// stating that size is at the style's size.
#[test]
fn a_parentless_heading_style_in_percent_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="H" style:family="paragraph"><style:text-properties fo:font-size="50%"/></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="5pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span>Two</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();
for r in &p.runs {
assert_eq!(d.styles.resolve_char(p.props.style.as_deref(), &r.props).size, 5.0, "{:?} {:?}", p.props.style, r.props.size);
}
}
/// Text at its heading's size goes with the heading style, stated or not, and a size in percent
/// is of the heading's size; text of another size keeps it, and so does a paragraph's own.
#[test]
fn a_headings_text_sizes_are_measured_against_the_heading() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading" style:family="paragraph"><style:text-properties fo:font-size="20pt"/></style:style><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="1"><style:text-properties fo:font-size="80%"/></style:style></office:styles></office:document-styles>"#
);
let span = |name: &str, size: &str| {
format!(r#"<style:style style:name="{name}" style:family="text"><style:text-properties fo:font-size="{size}"/></style:style>"#)
};
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Heading_20_1"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">plain <text:span text:style-name="Same">same</text:span> <text:span text:style-name="Pct">pct</text:span> <text:span text:style-name="Other">other</text:span></text:h><text:h text:style-name="P1" text:outline-level="1">own</text:h></office:text></office:body></office:document-content>"#,
span("Same", "16pt"),
span("Pct", "50%"),
span("Other", "10pt"),
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
let sizes = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let of = |word: &str| {
let at = p.text.find(word).unwrap();
let mut from = 0;
let run = p.runs.iter().find(|r| {
from += r.len;
at < from
});
d.styles.resolve_char(p.props.style.as_deref(), &run.unwrap().props).size
};
[of("plain"), of("same"), of("pct"), of("other")]
};
let want = |t: f32| [t, t, 8.0, 10.0];
let plain = |d: &Document| d.body[0].as_para().unwrap().runs.iter().filter(|r| r.props.size.is_some()).count();
assert_eq!(sizes(&d), want(16.0));
assert_eq!(plain(&d), 2, "only the two of another size state one");
// The paragraph's own size stays with its text.
let own = d.body[1].as_para().unwrap();
assert_eq!(d.styles.resolve_char(own.props.style.as_deref(), &own.runs[0].props).size, 30.0);
// A change to the style reaches what goes with it.
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
std::sync::Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.size = Some(24.0);
assert_eq!(sizes(&d), want(24.0));
}
File diff suppressed because one or more lines are too long