mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 09:49:55 -06:00
Gap 19: ODT default font size; dates in the locale's order
- The document's default font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) was ignored, leaving 12 pt; LibreOffice's letters are 10 pt. It is now the document's default. A named style with no parent keeps the default style's size, as ODF inherits it. - A date style ordered by the locale (`number:automatic-order`) showed its parts as listed: "2026November2". It now shows in US English order: "November 2, 2026", or "11/2/2026" with a numeric month, as LibreOffice shows it here. WordCraft's own rendering of LibreOffice's letter templates comes much closer: one letter goes from 11,377 to 6,237 differing pixels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -182,3 +182,43 @@ pub(super) fn picture_of(instr: &str) -> Option<String> {
|
||||
};
|
||||
(!pic.is_empty()).then(|| pic.chars().take(MAX_PICTURE).collect())
|
||||
}
|
||||
|
||||
/// A date's parts in the order and with the separators the locale gives them, for a date style
|
||||
/// with `number:automatic-order` (§19.341): its weekday, month, day and year as US English
|
||||
/// orders them (LibreOffice's "November 2, 2026", or "11/2/2026" with a numeric month). Its own
|
||||
/// text between them is left out; a time's parts stay as they are.
|
||||
pub(super) fn auto_ordered(parts: &[Part]) -> Vec<Part> {
|
||||
let find = |f: fn(&Part) -> bool| parts.iter().find(|p| f(p)).cloned();
|
||||
let weekday = find(|p| matches!(p, Part::Weekday(_)));
|
||||
let month = find(|p| matches!(p, Part::Month(..)));
|
||||
let day = find(|p| matches!(p, Part::Day(_)));
|
||||
let year = find(|p| matches!(p, Part::Year(_)));
|
||||
let Some(month) = month else { return parts.to_vec() };
|
||||
let textual = matches!(month, Part::Month(_, true));
|
||||
let text = |s: &str| Part::Text(s.to_string());
|
||||
let mut out = Vec::new();
|
||||
if let Some(w) = weekday {
|
||||
out.extend([w, text(", ")]);
|
||||
}
|
||||
out.push(month);
|
||||
if let Some(d) = day {
|
||||
out.extend([text(if textual { " " } else { "/" }), d]);
|
||||
}
|
||||
if let Some(y) = year {
|
||||
out.extend([text(if textual { ", " } else { "/" }), y]);
|
||||
}
|
||||
// A time with it keeps its own parts, after.
|
||||
let time: Vec<Part> = parts.iter().filter(|p| matches!(p, Part::Hours(_) | Part::Minutes(_) | Part::Seconds(_) | Part::AmPm)).cloned().collect();
|
||||
if !time.is_empty() {
|
||||
out.push(text(" "));
|
||||
for (i, p) in time.into_iter().enumerate() {
|
||||
if i > 0 && !matches!(p, Part::AmPm) {
|
||||
out.push(text(":"));
|
||||
} else if matches!(p, Part::AmPm) {
|
||||
out.push(text(" "));
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -395,6 +395,8 @@ struct Parsed {
|
||||
track: Option<bool>,
|
||||
/// The document's default font.
|
||||
font: Option<String>,
|
||||
/// The document's default font size, points.
|
||||
size: Option<f32>,
|
||||
/// The custom paragraph styles the paragraphs use, parents included, parents first.
|
||||
named: Vec<super::styles::NamedStyle>,
|
||||
/// Their ODF names, alongside.
|
||||
@@ -552,6 +554,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
||||
last,
|
||||
track,
|
||||
font: styles.doc_font(),
|
||||
size: styles.doc_size(),
|
||||
spacing: styles.doc_spacing(),
|
||||
looks: styles.kind_looks(),
|
||||
named,
|
||||
@@ -589,6 +592,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
last,
|
||||
track,
|
||||
font,
|
||||
size,
|
||||
spacing,
|
||||
looks,
|
||||
named,
|
||||
@@ -622,6 +626,9 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
||||
if font.is_some() {
|
||||
doc.styles.default_chr.font = font;
|
||||
}
|
||||
if size.is_some() {
|
||||
doc.styles.default_chr.size = size;
|
||||
}
|
||||
doc.styles.default_chr.kern = kern.then_some(1.0);
|
||||
doc.styles.default_chr.overlay(&scripts);
|
||||
if let Some(t) = default_tab {
|
||||
|
||||
@@ -443,6 +443,8 @@ pub(super) struct NamedStyle {
|
||||
struct DateStyles {
|
||||
/// The style being read: its name and parts.
|
||||
data: Option<(String, Vec<super::datastyle::Part>)>,
|
||||
/// It orders its parts as the locale does (§19.341 `number:automatic-order`).
|
||||
auto_order: bool,
|
||||
/// In its `number:text`.
|
||||
in_text: bool,
|
||||
}
|
||||
@@ -454,7 +456,10 @@ impl DateStyles {
|
||||
use super::datastyle::{Part, part, picture};
|
||||
match ev {
|
||||
Ev::Start(q, a) => match local(q) {
|
||||
"date-style" | "time-style" => self.data = get(a, "style:name").map(|n| (n.to_string(), Vec::new())),
|
||||
"date-style" | "time-style" => {
|
||||
self.data = get(a, "style:name").map(|n| (n.to_string(), Vec::new()));
|
||||
self.auto_order = local(q) == "date-style" && get(a, "number:automatic-order").is_some_and(|v| v.trim() == "true");
|
||||
}
|
||||
"text" => self.in_text = self.data.is_some(),
|
||||
name => {
|
||||
let on = |k: &str, v: &str| get(a, k).is_some_and(|x| x.trim() == v);
|
||||
@@ -471,6 +476,7 @@ impl DateStyles {
|
||||
if let Some((name, parts)) = self.data.take()
|
||||
&& out.len() < MAX_DATA_STYLES
|
||||
{
|
||||
let parts = if std::mem::take(&mut self.auto_order) { super::datastyle::auto_ordered(&parts) } else { parts };
|
||||
out.insert(name, picture(&parts));
|
||||
}
|
||||
}
|
||||
@@ -920,6 +926,8 @@ pub(super) struct Styles {
|
||||
pub(super) lists: HashMap<String, Vec<ListLevel>>,
|
||||
/// Font of `style:default-style` for paragraphs (the document default, ODF 1.3 §16.4).
|
||||
default_font: Option<String>,
|
||||
/// The default paragraph style's font size (`fo:font-size` in `style:default-style`), points.
|
||||
default_size: Option<f32>,
|
||||
/// Spacing of `style:default-style` for paragraphs.
|
||||
default_spacing: Spacing,
|
||||
/// Tab stops are measured from the paragraph's indent (LibreOffice's `TabsRelativeToIndent`
|
||||
@@ -978,6 +986,9 @@ impl Styles {
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&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) {
|
||||
self.default_size = Some(sz.min(1638.0));
|
||||
}
|
||||
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
|
||||
script_properties(&a, &mut self.default_scripts, &self.faces);
|
||||
}
|
||||
@@ -1058,6 +1069,18 @@ impl Styles {
|
||||
}
|
||||
|
||||
/// The document's default font: the Standard paragraph style's, else the default style's.
|
||||
/// The document's default font size: the Standard paragraph style's (the document's
|
||||
/// defaults, as for [`Styles::doc_font`]), else the default style's.
|
||||
pub(super) fn doc_size(&self) -> Option<f32> {
|
||||
self.map
|
||||
.get("Standard")
|
||||
.filter(|s| !s.auto)
|
||||
.and_then(|s| s.fmt.size)
|
||||
.or(self.default_size)
|
||||
.filter(|v| v.is_finite() && *v >= 1.0)
|
||||
.map(|v| v.min(1638.0))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1302,6 +1325,11 @@ impl Styles {
|
||||
};
|
||||
let mut fmt = Fmt::default();
|
||||
s.fmt.apply(&mut fmt);
|
||||
// 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 {
|
||||
fmt.size = self.default_size;
|
||||
}
|
||||
let look = ParaLook {
|
||||
align: s.align,
|
||||
keep_next: s.keep_next == Some(true),
|
||||
|
||||
@@ -1159,3 +1159,46 @@ pub(super) fn styles_of(bytes: &[u8]) -> String {
|
||||
let mut z = archive(bytes);
|
||||
String::from_utf8(read_entry(&mut z, "styles.xml").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_styles_font_size_is_the_documents() {
|
||||
// LibreOffice's letter templates: 10 pt in `style:default-style`; the Standard style's own
|
||||
// size (the document's defaults) wins over it.
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hi</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let default_only = 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></office:styles></office:document-styles>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&default_only))).unwrap();
|
||||
assert_eq!(doc.styles.default_chr.size, Some(10.0));
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 10.0);
|
||||
let standard = default_only.replace(
|
||||
"</office:styles>",
|
||||
r#"<style:style style:name="Standard" style:family="paragraph"><style:text-properties fo:font-size="11pt"/></style:style></office:styles>"#,
|
||||
);
|
||||
let d = import(&package(&content, Some(&standard))).unwrap();
|
||||
assert_eq!(d.styles.default_chr.size, Some(11.0));
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
assert_eq!(d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 11.0);
|
||||
// Written back as the default style's.
|
||||
let s = entry(&export(&doc).unwrap(), "styles.xml");
|
||||
assert!(s.contains(r#"fo:font-size="10pt""#), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_with_no_parent_takes_the_default_styles_size() {
|
||||
// The default style says 12 pt, Standard 11 pt (the document's defaults); a style with no
|
||||
// parent inherits the default style's 12 pt, as LibreOffice shows it.
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Loose">Hi</text:p><text:p>Plain</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
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:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties fo:font-size="11pt"/></style:style><style:style style:name="Loose" style:family="paragraph"/></office:styles></office:document-styles>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let sizes: Vec<f32> =
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size).collect();
|
||||
assert_eq!(sizes, [12.0, 11.0]);
|
||||
}
|
||||
|
||||
@@ -284,3 +284,18 @@ fn hostile_variables_never_panic() {
|
||||
let _ = export(&d).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_in_the_locales_order_shows_so() {
|
||||
// LibreOffice's letters: year, month by name and day, `number:automatic-order`: shown in
|
||||
// the locale's order, "November 2, 2026" in US English.
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T} xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"><office:styles><number:date-style style:name="N112" number:automatic-order="true"><number:year number:style="long"/><number:month number:style="long" number:textual="true"/><number:day/></number:date-style><number:date-style style:name="N2" number:automatic-order="true"><number:day/><number:month/><number:year number:style="long"/></number:date-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p><text:date style:data-style-name="N112" text:date-value="2026-11-02">2026November2</text:date> <text:date style:data-style-name="N2" text:date-value="2026-11-02">2112026</text:date></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let codes: Vec<String> = fields(&d).into_iter().map(|(c, _)| c).collect();
|
||||
assert_eq!(codes, [r#"DATE \@ "MMMM d, yyyy""#, r#"DATE \@ "M/d/yyyy""#]);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user