Gap 19: ODT heading numbering read and written

`text:outline-style` (ODF 1.3 §16.34) numbers the headings of its
levels. On open its levels (format, prefix, suffix, levels shown, start)
become one list, each level linked to its heading style, and Heading
1-6 number their paragraphs through it, as Word's numbered headings do:
LibreOffice's "1. / 1.1." headings keep their numbers (they were lost).
Levels without a number stay unnumbered.

Export writes a `text:outline-style` from the numbering of the heading
styles; levels without one show no number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-01 01:11:31 +00:00
parent 4a752321d6
commit 6a30527aa4
9 changed files with 161 additions and 8 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+49 -1
View File
@@ -124,6 +124,48 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
p.keep_next = look.keep_next.then_some(true);
}
/// Number the headings as the outline style `levels` does (ODF 1.3 §16.34): one list whose
/// levels carry the outline's formats, each linked to its heading style, which numbers its
/// paragraphs through it, as Word's numbered headings do. Levels without a number are left so.
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();
if !numbered.contains(&true) {
return;
}
let mut out = levels_for(ListKind::Numbered);
for (i, level) in out.iter_mut().enumerate() {
let src = levels.get(i).copied().unwrap_or_default();
// Headings sit at the margin, their number followed by a tab.
(level.indent, level.hanging) = (0.0, 0.0);
match src.label.filter(|_| src.ordered) {
Some(l) => {
level.format = l.format;
level.text = l.text(i as u8);
level.start = src.start;
}
None => {
level.format = wordcraft_doc::section::NumFormat::None;
level.text = String::new();
}
}
if i < 6 {
level.style = Some(format!("Heading{}", i + 1));
}
}
let numbering = &mut doc.numbering;
let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
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() });
for (i, on) in numbered.iter().enumerate() {
if *on && let Some(st) = doc.styles.get_mut(&format!("Heading{}", i + 1)) {
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
}
}
}
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
fn setting(xml: &[u8], name: &str) -> Option<bool> {
let mut inside = false;
@@ -155,6 +197,8 @@ struct Parsed {
track: Option<bool>,
/// The document's default font.
font: Option<String>,
/// The heading numbering (`text:outline-style`), level by level.
outline: Option<Vec<super::styles::ListLevel>>,
/// How the file's heading, title, quotation and preformatted styles look.
looks: Vec<(model::Kind, super::styles::ParaLook)>,
/// The document's default paragraph spacing.
@@ -215,6 +259,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
font: styles.doc_font(),
spacing: styles.doc_spacing(),
looks: styles.kind_looks(),
outline: styles.outline.clone(),
kern: styles.default_kern,
default_tab: styles.default_tab,
parts,
@@ -224,7 +269,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
/// Parse an `.odt` package into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
model::begin_import();
let Parsed { flow, last, track, font, spacing, looks, kern, default_tab, parts } = parse_tracked(bytes)?;
let Parsed { flow, last, track, font, spacing, looks, outline, kern, default_tab, parts } = parse_tracked(bytes)?;
let mut doc = model::to_doc(&flow);
if font.is_some() {
doc.styles.default_chr.font = font;
@@ -239,6 +284,9 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
for (kind, look) in &looks {
apply_look(&mut doc, *kind, look);
}
if let Some(levels) = &outline {
number_headings(&mut doc, levels);
}
doc.last_section = last;
page_parts::add(&mut doc, parts);
if let Some(t) = track {
+35
View File
@@ -50,6 +50,40 @@ fn kind_styles(doc: &Document) -> String {
s
}
/// `text:outline-style` (ODF 1.3 §16.34): the numbering of Heading 1–6, from the list level each
/// heading style numbers its paragraphs with; levels without one show no number.
fn outline_style(doc: &Document) -> String {
let mut s = String::from("<text:outline-style style:name=\"Outline\">");
for n in 1..=10u8 {
let level = (n <= 6)
.then(|| doc.styles.para_style_props(&format!("Heading{n}")).0.numbering)
.flatten()
.and_then(|r| doc.numbering.level(r.num, r.level).cloned().map(|l| (r.level, l)));
let attrs = match level {
Some((lv, l)) if !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet) => {
let label = model::Label::of_level(l.format, &l.text);
let mut a = format!(" style:num-format=\"{}\"", super::writer::odf_num_format(l.format));
for (k, v) in [("style:num-prefix", label.prefix.as_str()), ("style:num-suffix", label.suffix.as_str())] {
if !v.is_empty() {
a.push_str(&format!(" {k}=\"{}\"", x(v)));
}
}
if label.shown > 1 {
a.push_str(&format!(" text:display-levels=\"{}\"", label.shown.min(lv.saturating_add(1))));
}
if l.start != 1 {
a.push_str(&format!(" text:start-value=\"{}\"", l.start));
}
a
}
_ => " style:num-format=\"\"".into(),
};
s.push_str(&format!("<text:outline-level-style text:level=\"{n}\"{attrs}/>"));
}
s.push_str("</text:outline-style>");
s
}
/// A master page's headers and footers (ODF 1.3 §16.10), written.
#[derive(Default)]
struct PageParts {
@@ -127,6 +161,7 @@ fn styles_xml(doc: &Document, pages: &[(String, wordcraft_doc::SectionProps)], p
));
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
s.push_str(&kind_styles(doc));
s.push_str(&outline_style(doc));
// 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!(
+10 -1
View File
@@ -485,7 +485,7 @@ fn paragraph_properties(a: &Attrs, s: &mut StyleInfo) {
}
/// A list style's level (ODF 1.3 §16.31–16.33): numbered or not, its label and start value.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct ListLevel {
pub(super) ordered: bool,
pub(super) label: Option<model::Label>,
@@ -533,6 +533,8 @@ pub(super) struct Styles {
map: HashMap<String, StyleInfo>,
/// Font faces (`style:font-face`): name → family.
faces: HashMap<String, String>,
/// The levels of `text:outline-style` (heading numbering, ODF 1.3 §16.34), when there is one.
pub(super) outline: Option<Vec<ListLevel>>,
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>,
@@ -643,6 +645,13 @@ impl Styles {
}
}
"list-style" => list = get(&a, "style:name").map(|n| (n.to_string(), vec![ListLevel::default(); 10])),
"outline-style" => self.outline = Some(vec![ListLevel::default(); 10]),
"outline-level-style" => {
let lv = get(&a, "text:level").and_then(|l| l.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 10);
if let Some(slot) = self.outline.as_mut().and_then(|o| o.get_mut(lv - 1)) {
*slot = list_level("list-level-style-number", &a);
}
}
"list-level-style-number" | "list-level-style-bullet" | "list-level-style-image" => {
if let Some((_, v)) = &mut list {
let lv = get(&a, "text:level").and_then(|l| l.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 10);
+1
View File
@@ -7,6 +7,7 @@ mod fonts;
mod headers;
mod lists;
mod notes;
mod outline;
mod spacing;
mod style_look;
mod tables;
@@ -0,0 +1,58 @@
//! Heading numbering (ODF 1.3 §16.34 `text:outline-style`): the outline style's levels number the
//! headings of those levels; WordCraft numbers them through the heading styles, as Word does.
use crate::odt::tests::{NS_T, package};
use crate::odt::{export, import};
use wordcraft_doc::Document;
use wordcraft_doc::section::NumFormat;
fn open(outline: &str) -> Document {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/><style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph" style:default-outline-level="2"/>{outline}</office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">One</text:h><text:h text:style-name="Heading_20_2" text:outline-level="2">One one</text:h><text:p>Body</text:p></office:text></office:body></office:document-content>"#
);
import(&package(&content, Some(&styles))).unwrap()
}
const NUMBERED: &str = 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" style:num-suffix="." text:display-levels="2"/><text:outline-level-style text:level="3" style:num-format=""/></text:outline-style>"#;
/// Each heading style's numbering: (its level's format and text).
fn heading_numbers(d: &Document) -> Vec<Option<(NumFormat, String)>> {
(1..=3)
.map(|n| {
let (pp, _) = d.styles.para_style_props(&format!("Heading{n}"));
let r = pp.numbering?;
let lv = d.numbering.level(r.num, r.level)?;
(lv.format != NumFormat::None).then(|| (lv.format, lv.text.clone()))
})
.collect()
}
#[test]
fn an_outline_style_numbers_the_headings() {
let d = open(NUMBERED);
assert_eq!(heading_numbers(&d), [Some((NumFormat::Decimal, "%1.".into())), Some((NumFormat::Decimal, "%1.%2.".into())), None]);
// Both levels are one list, so 1.1 follows 1.
let num = |n: u32| d.styles.para_style_props(&format!("Heading{n}")).0.numbering.map(|r| (r.num, r.level));
assert_eq!(num(1).map(|n| n.0), num(2).map(|n| n.0));
assert_eq!((num(1).map(|n| n.1), num(2).map(|n| n.1)), (Some(0), Some(1)));
}
#[test]
fn an_unnumbered_outline_numbers_nothing() {
let none = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format=""/></text:outline-style>"#;
assert_eq!(heading_numbers(&open(none)), [None, None, None]);
assert_eq!(heading_numbers(&open("")), [None, None, None]);
}
#[test]
fn heading_numbering_round_trips() {
let d = open(NUMBERED);
let back = import(&export(&d).unwrap()).unwrap();
assert_eq!(heading_numbers(&back), heading_numbers(&d));
// A document without numbered headings stays without.
let plain = open("");
assert_eq!(heading_numbers(&import(&export(&plain).unwrap()).unwrap()), [None, None, None]);
}
+1 -1
View File
@@ -14,7 +14,7 @@ struct ListOut {
/// WordCraft number format → ODF's `style:num-format` (ODF 1.3 §19.500); formats ODF lacks are
/// written as decimal.
fn odf_num_format(f: wordcraft_doc::section::NumFormat) -> &'static str {
pub(super) fn odf_num_format(f: wordcraft_doc::section::NumFormat) -> &'static str {
use wordcraft_doc::section::NumFormat as F;
match f {
F::LowerRoman => "i",
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long