diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89f5646e9..2486093ed 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -514,13 +514,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C story .paras .iter() - .map(|p| { + .zip(¶_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 diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f34ae4cd2..d3079f714 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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 { + 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}`"); + } +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index eccfa427c..ab697858e 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -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 = 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"); } } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index 701275d52..94f6b8425 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -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)); } } } diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index a76a6473a..a2301b1c8 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -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(), }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 7bd799fc8..38f205697 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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" })); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 21f837f75..ff036c16f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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::().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") { diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 77d194dd5..cdf8294ce 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -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 { + 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), } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 51c9a071b..6c9e7d91f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -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( + " ", + r#" + A, B, C, D... + + + 001, 002, 003... + + + 一, 二, 三, 四... + + + + + "#, + ); + 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()); +}