Merge #298: Lists: numbering formats and expressions from IDML; an empty paragraph gets no bullet or number

Conflict: crates/compose/src/lib.rs: next's list_label match with its list_label glyph flag; arms take #298's empty-paragraph rule, number_label/bullet_label and saturating counter
Conflict: crates/compose/src/tests.rs: next's file with #298's appended tests
This commit is contained in:
aiongg
2026-10-10 07:15:49 +00:00
9 changed files with 305 additions and 18 deletions
+10 -7
View File
@@ -514,13 +514,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
story
.paras
.iter()
.map(|p| {
.zip(&para_ranges)
.map(|(p, r)| {
let (pp, _) = doc.styles.resolve_para(p);
if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() {
if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() {
return None;
}
let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name));
*c = pp.start_at.map_or(*c + 1, |s| s.max(1));
*c = pp.start_at.map_or(c.saturating_add(1), |s| s.max(1));
Some(*c)
})
.collect()
@@ -772,16 +773,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// List labels take the default super/subscript settings.
let label_env = shape::TypeEnv { adv: Default::default(), ..env };
let list_label = match pp.list_type {
// An empty paragraph has no bullet or number, and the numbering carries on past it.
designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None,
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
// A named list: carries on past other paragraphs (and from earlier stories).
let n = named_numbers.get(pi).copied().flatten().unwrap_or(1);
Some(format!("{}.{}", pp.number_style.format(n), pp.list_separator))
Some(pp.number_label(n))
}
designcraft_doc::ListType::Numbers => {
list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1));
Some(format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator))
list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1));
Some(pp.number_label(list_counter))
}
designcraft_doc::ListType::Bullets => Some(format!("{}{}", pp.bullet_char, pp.list_separator)),
designcraft_doc::ListType::Bullets => Some(pp.bullet_label()),
designcraft_doc::ListType::None => {
list_counter = 0;
None
+71
View File
@@ -4285,3 +4285,74 @@ fn split_block_flows_into_the_next_column() {
let top = ft.lines.iter().find(|l| l.column == 1).unwrap();
assert!((s..s + 6).contains(&top.para) && top.baseline < 20.0);
}
/// Each paragraph's list label as text: its label glyphs (laid before the paragraph's own text)
/// matched back to characters of the label font; tabs and other invisible glyphs are skipped.
fn list_labels(cs: &ComposedStory, alphabet: &str) -> Vec<String> {
all_lines(cs)
.iter()
.filter(|l| l.first_in_para)
.map(|l| {
l.glyphs
.iter()
.take_while(|g| g.len == 0)
.filter(|g| g.visible)
.map(|g| alphabet.chars().find(|c| g.face.glyph_for(*c) == g.gid).unwrap_or('?'))
.collect()
})
.collect()
}
const LABEL_CHARS: &str = "0123456789ABCDIVXabcdivx.\u{2022} Tabel";
fn numbered(style: designcraft_doc::NumberStyle, expression: &str) -> ParaAttrs {
ParaAttrs {
list_type: Some(designcraft_doc::ListType::Numbers),
number_style: Some(style),
number_expression: Some(expression.into()),
..Default::default()
}
}
#[test]
fn numbered_lists_follow_their_format_and_expression() {
use designcraft_doc::NumberStyle as N;
let cases: [(N, &str, [&str; 3]); 4] = [
(N::UpperLetters, "^#.^t", ["A.", "B.", "C."]),
(N::LowerRoman, "^#.^t", ["i.", "ii.", "iii."]),
(N::ArabicThreeDigits, "^#.^t", ["001.", "002.", "003."]),
(N::Arabic, "Tabel ^#^t", ["Tabel 1", "Tabel 2", "Tabel 3"]),
];
for (style, expression, want) in cases {
let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(style, expression));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(list_labels(&cs, LABEL_CHARS), want, "{style:?} {expression}");
}
}
#[test]
fn numbering_from_the_largest_start_number_does_not_overflow() {
let (mut d, sid, _) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(designcraft_doc::NumberStyle::Arabic, "^#."));
d.story_mut(sid).unwrap().paras[0].para.start_at = Some(Some(u32::MAX));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let max = format!("{}.", u32::MAX);
assert_eq!(list_labels(&cs, LABEL_CHARS), [max.clone(), max]);
}
#[test]
fn empty_paragraphs_get_no_bullet_or_number() {
let bullets = ParaAttrs { list_type: Some(designcraft_doc::ListType::Bullets), ..Default::default() };
let (d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), bullets);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(list_labels(&cs, LABEL_CHARS), ["\u{2022}", "", "\u{2022}", ""]);
// An empty paragraph doesn't use up a number, in a story's own list or a named one.
for name in ["", "Steps"] {
let attrs = ParaAttrs { list_name: Some(name.into()), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^t") };
let (mut d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), attrs);
if !name.is_empty() {
d.settings.lists.push(designcraft_doc::NumberedList { name: name.into(), continue_across_stories: false });
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`");
}
}
+105 -1
View File
@@ -577,15 +577,33 @@ pub enum NumberStyle {
LowerLetters,
/// 01, 02, …
ArabicLeadingZero,
/// 001, 002, …
ArabicThreeDigits,
/// 0001, 0002, …
ArabicFourDigits,
/// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes.
Symbols,
}
/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman
/// numerals, letters, symbols), so a hostile start number can't build a huge label.
const MAX_REPEATED_FORM: u32 = 3999;
impl NumberStyle {
pub fn format(self, n: u32) -> String {
if n > MAX_REPEATED_FORM
&& matches!(
self,
NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols
)
{
return n.to_string();
}
match self {
NumberStyle::Arabic => n.to_string(),
NumberStyle::ArabicLeadingZero => format!("{n:02}"),
NumberStyle::ArabicThreeDigits => format!("{n:03}"),
NumberStyle::ArabicFourDigits => format!("{n:04}"),
NumberStyle::UpperRoman => roman(n),
NumberStyle::LowerRoman => roman(n).to_lowercase(),
NumberStyle::UpperLetters => letters(n),
@@ -599,6 +617,58 @@ impl NumberStyle {
}
}
/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …),
/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no
/// meaning in a label are dropped; a label never holds a paragraph break.
pub fn expand_list_text(s: &str, number: &str) -> String {
let mut out = String::with_capacity(s.len() + number.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c != '^' {
if c != '\n' && c != '\r' {
out.push(c);
}
continue;
}
let Some(m) = it.next() else {
out.push('^');
break;
};
let r = match m {
'#' => {
out.push_str(number);
continue;
}
'^' => '^',
't' => '\t',
'y' => crate::story::RIGHT_INDENT_TAB,
'i' => crate::story::INDENT_HERE,
'n' => crate::story::FORCED_LINE_BREAK,
'm' => '\u{2003}',
'>' => '\u{2002}',
'f' => '\u{2001}',
'|' => '\u{200A}',
'<' => '\u{2009}',
'/' => '\u{2007}',
'.' => '\u{2008}',
's' => '\u{A0}',
'S' => '\u{202F}',
'_' => '\u{2014}',
'=' => '\u{2013}',
'~' => '\u{2011}',
'{' => '\u{201C}',
'}' => '\u{201D}',
'[' => '\u{2018}',
']' => '\u{2019}',
'"' => '"',
'\'' => '\'',
_ => continue,
};
out.push(r);
}
out
}
fn roman(mut n: u32) -> String {
if n == 0 {
return "0".into();
@@ -807,7 +877,10 @@ attr_set! {
start_at: Option<u32> = None,
bullet_char: String = "\u{2022}".into(),
number_style: NumberStyle = NumberStyle::Arabic,
/// Text after the number/bullet (`^t` = tab).
/// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in
/// [`expand_list_text`].
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
balance_ragged: bool = false,
optical_margin: bool = false,
@@ -859,6 +932,20 @@ impl ParaAttrs {
}
}
impl ParaProps {
/// The label of a numbered paragraph with number `n`.
pub fn number_label(&self, n: u32) -> String {
expand_list_text(&self.number_expression, &self.number_style.format(n))
}
/// The label of a bulleted paragraph.
pub fn bullet_label(&self) -> String {
let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect();
s.push_str(&expand_list_text(&self.list_separator, ""));
s
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1042,5 +1129,22 @@ mod tests {
assert_eq!(NumberStyle::UpperLetters.format(27), "AA");
assert_eq!(NumberStyle::LowerLetters.format(28), "bb");
assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03");
assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007");
assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012");
// Repeating forms stay small for huge numbers.
assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string());
}
#[test]
fn list_labels_expand_metacharacters() {
let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() };
assert_eq!(p.number_label(2), "B.\t");
let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() };
assert_eq!(p.number_label(3), "Tabel 3\t");
assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^");
assert_eq!(expand_list_text("a\nb^p", "1"), "ab");
assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t");
}
}
+3 -3
View File
@@ -235,10 +235,10 @@ impl crate::Document {
before.sort_by_key(|st| key(st));
let mut n = 0u32;
for st in before {
for p in &st.paras {
for (p, r) in st.paras.iter().zip(st.para_ranges()) {
let (pp, _) = self.styles.resolve_para(p);
if pp.list_type == crate::ListType::Numbers && pp.list_name == name {
n = pp.start_at.map_or(n + 1, |s| s.max(1));
if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() {
n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1));
}
}
}
+10 -5
View File
@@ -347,13 +347,18 @@ impl Document {
let (sid, pos) = self.find_anchor(id)?;
let st = self.story(sid)?;
let pi = st.para_at(pos);
let r = st.para_ranges()[pi].clone();
let ranges = st.para_ranges();
let r = ranges[pi].clone();
let (pp, _) = self.styles.resolve_para(&st.paras[pi]);
let para_num = match pp.list_type {
crate::ListType::Numbers => {
// Count the run of numbered paragraphs before this one.
let n = (0..=pi).rev().take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers).count();
format!("{}.", pp.number_style.format(n as u32))
crate::ListType::Numbers if !r.is_empty() => {
// Count the run of numbered paragraphs before this one (empty ones have no number).
let n = (0..=pi)
.rev()
.take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers)
.filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty()))
.count();
pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string()
}
_ => String::new(),
};
+12
View File
@@ -1461,6 +1461,18 @@ impl<'a> Ex<'a> {
},
);
}
if let Some(n) = a.number_style {
props.push(p("NumberingFormat", "string", names::numbering_format_out(n)));
}
if let Some(x) = &a.number_expression {
el.set("NumberingExpression", names::list_text_out(x));
}
if let Some(x) = &a.list_separator {
el.set("BulletsTextAfter", names::list_text_out(x));
}
if let Some(c) = a.bullet_char.as_deref().and_then(|s| s.chars().next()) {
props.push(El::new("BulletChar").attr("BulletCharacterType", "UnicodeOnly").attr("BulletCharacterValue", u32::from(c).to_string()));
}
if let Some(b) = a.balance_ragged {
props.push(p("BalanceRaggedLines", "enumeration", if b { "FullyBalanced" } else { "NoBalancing" }));
}
+11
View File
@@ -1390,6 +1390,17 @@ impl<'r> Importer<'r> {
"NumberedList" => ListType::Numbers,
_ => ListType::None,
});
// A format with no counterpart (CJK, Arabic, Hebrew numbering) is left unset: decimal.
a.number_style = e.prop("NumberingFormat").and_then(|v| names::numbering_format_in(&v));
a.number_expression = e.prop("NumberingExpression");
a.list_separator = e.prop("BulletsTextAfter");
if let Some(b) = e.prop_el("BulletChar") {
// A glyph of a named font (`GlyphWithFont`) has no character to map to.
let unicode = matches!(b.get("BulletCharacterType"), Some("UnicodeOnly" | "UnicodeWithFont"));
if let Some(c) = b.get("BulletCharacterValue").and_then(|v| v.trim().parse::<u32>().ok()).and_then(char::from_u32).filter(|_| unicode) {
a.bullet_char = Some(c.to_string());
}
}
a.balance_ragged = e.prop("BalanceRaggedLines").map(|v| v.trim() != "NoBalancing" && v.trim() != "false");
a.shading_on = e.boolean("ParagraphShadingOn");
if let Some(c) = e.prop("ParagraphShadingColor") {
+40 -2
View File
@@ -463,7 +463,7 @@ pub const REF_POINTS: [&str; 9] = [
pub fn number_style_out(n: NumberStyle) -> &'static str {
match n {
NumberStyle::Arabic | NumberStyle::ArabicLeadingZero => "Arabic",
NumberStyle::Arabic | NumberStyle::ArabicLeadingZero | NumberStyle::ArabicThreeDigits | NumberStyle::ArabicFourDigits => "Arabic",
NumberStyle::UpperRoman => "UpperRoman",
NumberStyle::LowerRoman => "LowerRoman",
NumberStyle::UpperLetters => "UpperLetters",
@@ -542,17 +542,55 @@ pub fn pt(x: f64, y: f64) -> String {
format!("{} {}", num(x), num(y))
}
// ---------- list numbering ----------
/// A paragraph's `NumberingFormat` ("A, B, C, D...", "001, 002, 003...", …), told apart by its
/// first item. None for formats with no counterpart (CJK, Arabic, Hebrew, …).
pub fn numbering_format_in(s: &str) -> Option<NumberStyle> {
Some(match s.split(',').next().unwrap_or("").trim() {
"1" => NumberStyle::Arabic,
"01" => NumberStyle::ArabicLeadingZero,
"001" => NumberStyle::ArabicThreeDigits,
"0001" => NumberStyle::ArabicFourDigits,
"I" => NumberStyle::UpperRoman,
"i" => NumberStyle::LowerRoman,
"A" => NumberStyle::UpperLetters,
"a" => NumberStyle::LowerLetters,
_ => return None,
})
}
pub fn numbering_format_out(n: NumberStyle) -> &'static str {
match n {
NumberStyle::Arabic | NumberStyle::Symbols => "1, 2, 3, 4...",
NumberStyle::ArabicLeadingZero => "01, 02, 03...",
NumberStyle::ArabicThreeDigits => "001, 002, 003...",
NumberStyle::ArabicFourDigits => "0001, 0002, 0003...",
NumberStyle::UpperRoman => "I, II, III, IV...",
NumberStyle::LowerRoman => "i, ii, iii, iv...",
NumberStyle::UpperLetters => "A, B, C, D...",
NumberStyle::LowerLetters => "a, b, c, d...",
}
}
/// Label text in IDML metacharacters (a tab is `^t`).
pub fn list_text_out(s: &str) -> String {
s.replace('\t', "^t")
}
// ---------- footnote options ----------
pub fn note_style_out(n: NumberStyle) -> &'static str {
match n {
NumberStyle::ArabicLeadingZero => "SingleLeadingZeros",
NumberStyle::ArabicThreeDigits => "DoubleLeadingZeros",
NumberStyle::ArabicFourDigits => "TripleLeadingZeros",
n => number_style_out(n),
}
}
pub fn note_style_in(s: &str) -> NumberStyle {
match s {
"SingleLeadingZeros" | "DoubleLeadingZeros" => NumberStyle::ArabicLeadingZero,
"SingleLeadingZeros" => NumberStyle::ArabicLeadingZero,
"DoubleLeadingZeros" => NumberStyle::ArabicThreeDigits,
"TripleLeadingZeros" => NumberStyle::ArabicFourDigits,
"Asterisks" => NumberStyle::Symbols,
s => number_style_in(s),
}
+43
View File
@@ -1967,3 +1967,46 @@ fn split_columns_import_and_round_trip() {
let b = para(&import_idml(&export_idml(&doc)).unwrap());
assert_eq!((b.span_columns, b.split_inside_gutter, b.split_outside_gutter), (a.span_columns, a.split_inside_gutter, a.split_outside_gutter));
}
#[test]
fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
let styles = STYLES.replace(
" </ParagraphStyleGroup>",
r#" <ParagraphStyle Self="ParagraphStyle/Text%3aHead" Name="Text:Head" BulletsAndNumberingListType="NumberedList" NumberingExpression="^#.^t">
<Properties><NumberingFormat type="string">A, B, C, D...</NumberingFormat></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Text%3aTable" Name="Text:Table" BulletsAndNumberingListType="NumberedList" NumberingExpression="Tabel ^#^t">
<Properties><NumberingFormat type="string">001, 002, 003...</NumberingFormat></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Text%3aKanji" Name="Text:Kanji" BulletsAndNumberingListType="NumberedList">
<Properties><NumberingFormat type="string">一, 二, 三, 四...</NumberingFormat></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Text%3aPoint" Name="Text:Point" BulletsAndNumberingListType="BulletList" BulletsTextAfter="^&gt;">
<Properties><BulletChar BulletCharacterType="UnicodeOnly" BulletCharacterValue="9632"/></Properties>
</ParagraphStyle>
</ParagraphStyleGroup>"#,
);
let bytes = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", &styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
]);
let check = |d: &Document| {
let (head, _) = d.styles.resolve_para_style("Text/Head");
assert_eq!(head.number_style, designcraft_doc::NumberStyle::UpperLetters);
assert_eq!(head.number_label(2), "B.\t");
let (table, _) = d.styles.resolve_para_style("Text/Table");
assert_eq!(table.number_label(7), "Tabel 007\t");
let (kanji, _) = d.styles.resolve_para_style("Text/Kanji");
assert_eq!(kanji.number_style, designcraft_doc::NumberStyle::Arabic);
let (point, _) = d.styles.resolve_para_style("Text/Point");
assert_eq!(point.bullet_label(), "\u{25A0}\u{2002}");
};
let d = import_idml(&bytes).unwrap();
check(&d);
check(&import_idml(&export_idml(&d)).unwrap());
}