From 7636af73e4306b13a185995cb627507372a8d2e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:48:58 +0000 Subject: [PATCH 1/8] Drop caps: the cap style goes through IDML as a Dropcap nested style IDML has no drop cap style attribute. Import reads the nested style list's leading "through 1 Dropcap" entry as the drop cap style; export writes the drop cap style as that entry. The drop cap style counts as that entry when composing, so nested styles after it start after the drop cap. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 28 ++-------------------------- crates/compose/src/shape.rs | 17 +++++++++++++++-- crates/compose/src/tests.rs | 27 +++++++++++++++++++++++++-- crates/doc/src/attrs.rs | 12 ++++++++++++ crates/engine/src/tests_idml.rs | 5 +++-- crates/idml/src/export.rs | 17 ++++++++++++----- crates/idml/src/import.rs | 4 +++- crates/idml/src/tests.rs | 27 +++++++++++++++++---------- 9 files changed, 90 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 201dd6031..b0a3c2f29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. -- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down to the baseline of line N (up to 25), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 97dbb9e97..d29528529 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -916,9 +916,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; - // The drop cap's line (frame, index) and the baseline it sits on, once that line is set. - let mut drop_line: Option<(usize, usize)> = None; - let mut drop_baseline: Option = None; loop { if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { @@ -1151,15 +1148,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_violation: false, drop_cap: drop_box, }); - if drop_box.is_some() { - drop_line = Some((cur.fi, ft.lines.len() - 1)); - } - if let Some(dc) = &drop_cap - && line_no + 1 == dc.lines - && col_first_line == 0 - { - drop_baseline = Some(baseline); - } for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); } @@ -1197,19 +1185,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C break; } } - // The drop cap sits on its last line's baseline as set (it was placed at the leading's). - if let (Some((fi, li)), Some(b), Some(n)) = (drop_line, drop_baseline, drop_cap.as_ref().map(|dc| dc.glyphs.len())) - && let Some(l) = out.frames.get_mut(fi).and_then(|f| f.lines.get_mut(li)) - && let Some(dc) = l.drop_cap.as_mut() - { - let d = b - dc.baseline; - dc.baseline = b; - dc.rect.y1 += d; - // Its glyphs start the line. - for g in l.glyphs.iter_mut().take(n) { - g.y += d; - } - } info.push(ParaInfo { start: start_at, at_top, @@ -2884,7 +2859,8 @@ struct DropCap { /// How far its origin sits left of the indent: its first letter's left side bearing with /// Align Left Edge, else 0. lsb: f64, - /// Its baseline below the first line's, at the paragraph's leading. + /// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps + /// on a baseline grid), wherever line `lines` is set. drop: f64, } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 748de241e..06cf04fc0 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -215,11 +215,24 @@ pub(crate) fn shape_para( // Nested line styles lie under nested and GREP styles, and the drop cap's style over them all // (later overlays win). let mut overlays: Vec<(std::ops::Range, String)> = lines.to_vec(); + // The drop cap's style counts as a leading "through 1 Dropcap" nested style (the form IDML + // stores it in), so the nested styles after it start after the drop cap. + let cap_style = Some(drop.1).filter(|s| !s.is_empty() && *s != designcraft_doc::NO_CHAR_STYLE); + let with_cap: Vec; + let nested = match cap_style { + Some(s) if !nested.is_empty() && !nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect(); + &with_cap[..] + } + _ => nested, + }; if !nested.is_empty() || !grep.is_empty() { overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0)); } - if drop.0 > range.start && !drop.1.is_empty() && drop.1 != designcraft_doc::NO_CHAR_STYLE { - overlays.push((range.start..drop.0, drop.1.to_string())); + if let Some(s) = cap_style + && drop.0 > range.start + { + overlays.push((range.start..drop.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3d9b7c977..57c5516dd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2124,8 +2124,9 @@ fn drop_cap_caret_and_hit_testing() { } #[test] -fn drop_cap_sits_on_its_line_as_set() { - // Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap follows line 3. +fn drop_cap_on_a_baseline_grid_reaches_down_by_grid_steps() { + // Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap reaches down two grid + // steps, to line 3. let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { grid_align: Some(designcraft_doc::GridAlign::AllLines), ..drop_cap(3, 1) }); d.settings.baseline_grid.increment = 15.0; d.settings.baseline_grid.start = 0.0; @@ -2141,6 +2142,28 @@ fn drop_cap_sits_on_its_line_as_set() { assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01); } +/// A line with a larger auto leading doesn't move the drop cap: it reaches down `lines − 1` +/// leadings of the text after it, not to where line N is set. +#[test] +fn drop_cap_reaches_down_by_the_text_leading_past_a_line_with_larger_leading() { + use designcraft_doc::{CharAttrs, Composer, Leading}; + let text = [LOREM; 2].join(" "); + let para = ParaAttrs { composer: Some(Composer::SingleLine), ..drop_cap(3, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.story_mut(sid).unwrap().paras[0].chars = CharAttrs { size: Some(12.0), leading: Some(Leading::Auto), ..Default::default() }; + // A larger word starts line 2, so lines 2 and 3 are set further down than 2 × 14.4 pt. + let start2 = all_lines(&compose_story(&d, sid, &ComposeOptions::default()))[1].range.start; + d.story_mut(sid).unwrap().format_chars(start2..start2 + 3, |f| f.over.size = Some(20.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines[1].range.start, start2); + assert!(lines[2].baseline - lines[0].baseline > 2.0 * 14.4 + 5.0, "{} {}", lines[0].baseline, lines[2].baseline); + let dc = &lines[0].glyphs[0]; + assert!((dc.y - 2.0 * 14.4).abs() < 1e-6, "drop cap baseline {} below line 1", dc.y); + let b = lines[0].drop_cap.unwrap(); + assert!((b.baseline - (lines[0].baseline + 2.0 * 14.4)).abs() < 1e-6, "{}", b.baseline); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 5f0d518ff..7215c156e 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -469,6 +469,18 @@ impl Default for NestedStyle { } } +impl NestedStyle { + /// The drop cap's character style as a nested style: `style` through 1 drop cap. + pub fn drop_cap(style: &str) -> Self { + NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } + } + + /// Is this the form a drop cap's character style takes at the start of a nested style list? + pub fn is_drop_cap(&self) -> bool { + self.until == NestedUntil::Dropcap && self.through && self.count == 1 + } +} + /// A character style applied to every match of a regular expression in the paragraph. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index b3d1ef566..f16d3efc2 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -359,6 +359,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap(); let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para; assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false))); - // IDML has no attribute for the drop cap's character style. - assert_eq!(p.drop_cap_style, None); + // The drop cap's character style goes through IDML as a leading Dropcap nested style. + assert_eq!(p.drop_cap_style.as_deref(), Some("Initial")); + assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0bbf2db93..67ab22f28 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1285,10 +1285,19 @@ impl<'a> Ex<'a> { None => el.set("NumberingContinue", "true"), } } - // Nested and GREP styles: property lists of records. - if let Some(list) = &a.nested_styles { + // Nested and GREP styles: property lists of records. The drop cap's character style is the + // nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it). + let cap_style = a.drop_cap_style.as_deref().filter(|s| !s.is_empty() && *s != st::NO_CHAR_STYLE); + if a.nested_styles.is_some() || cap_style.is_some() { + let mut list = a.nested_styles.clone().unwrap_or_default(); + if let Some(s) = cap_style { + match list.first_mut() { + Some(ns) if ns.is_drop_cap() => ns.style = s.to_string(), + _ => list.insert(0, designcraft_doc::NestedStyle::drop_cap(s)), + } + } let mut l = El::new("AllNestedStyles").attr("type", "list"); - for ns in list { + for ns in &list { let (ty, delim) = names::nested_until_out(&ns.until); l = l.child( El::new("ListItem") @@ -1370,8 +1379,6 @@ impl<'a> Ex<'a> { n!(space_after, "SpaceAfter"); n!(drop_cap_lines, "DropCapLines"); n!(drop_cap_chars, "DropCapCharacters"); - // The drop cap's character style isn't written: IDML has no attribute for it (InDesign - // applies one through a Dropcap nested style). if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() { let d = designcraft_doc::ParaProps::default(); let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 34cdeb836..4dbcbb705 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1255,6 +1255,9 @@ impl<'r> Importer<'r> { } }) .collect(); + // IDML has no drop cap style attribute: the drop cap's character style is the list's + // leading "through 1 Dropcap" nested style. + a.drop_cap_style = v.first().filter(|ns| ns.is_drop_cap()).map(|ns| ns.style.clone()); a.nested_styles = Some(v); } if let Some(l) = e.prop_el("AllNestedLineStyles") { @@ -1283,7 +1286,6 @@ impl<'r> Importer<'r> { a.space_after = e.num("SpaceAfter"); a.drop_cap_lines = u("DropCapLines"); a.drop_cap_chars = u("DropCapCharacters"); - a.drop_cap_style = e.prop("DropCapStyle").map(|r| self.char_style_ref(r.trim())); // DropcapDetail bits: 1 = Align Left Edge, 2 = Scale for Descenders. if let Some(v) = e.num("DropcapDetail").filter(|v| v.is_finite() && *v >= 0.0) { let bits = v as u32; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 4afe19ca4..88a7d7bc3 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1784,12 +1784,13 @@ fn absent_item_and_document_attributes_take_indesign_defaults() { } } +/// IDML has no attribute for a drop cap's character style: it is the nested style list's leading +/// "through 1 Dropcap" entry, read into the drop cap style and written back from it. #[test] fn drop_caps_import_and_round_trip() { - // A style with a drop cap, its character style and a nested style through it; a paragraph - // overriding it locally. + // A style with a drop cap and its character style; a paragraph overriding both locally. let opener = r#" - CharacterStyle/Strong + CharacterStyle/StrongDropcap 1true @@ -1798,7 +1799,10 @@ fn drop_caps_import_and_round_trip() { let story = r#" - + + + CharacterStyle/$ID/[No character style]Dropcap + 1true Once upon a time @@ -1816,18 +1820,21 @@ fn drop_caps_import_and_round_trip() { let st = d.styles.para("Opener").unwrap(); assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines), (Some(1), Some(3))); assert_eq!(st.para.drop_cap_style.as_deref(), Some("Strong")); - let ns = st.para.nested_styles.clone().unwrap(); - assert_eq!((ns[0].style.as_str(), &ns[0].until), ("Strong", &designcraft_doc::NestedUntil::Dropcap)); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")]), "one entry, not duplicated"); let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0]; assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2))); assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); }; let d = import_idml_with(&bytes, &|_| None).unwrap(); check(&d); - // The drop cap's character style isn't written to IDML (InDesign sets it through a Dropcap - // nested style); Align Left Edge and Scale for Descenders are, as DropcapDetail. + let bytes = export_idml(&d); + assert!(!zip_text(&bytes, "").contains("DropCapStyle"), "not an IDML attribute"); + check(&import_idml(&bytes).unwrap()); + // A drop cap style set without nested styles is written as one; Align Left Edge and Scale for + // Descenders as DropcapDetail. let mut d = d; if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; s.para.drop_cap_align_left = Some(false); s.para.drop_cap_scale_descenders = Some(true); } @@ -1836,9 +1843,9 @@ fn drop_caps_import_and_round_trip() { assert!(zip_text(&bytes, "Resources/Styles.xml").contains(r#"DropcapDetail="2""#)); let back = import_idml(&bytes).unwrap(); let st = back.styles.para("Opener").unwrap(); - assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), None)); + assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), Some("Strong"))); assert_eq!((st.para.drop_cap_align_left, st.para.drop_cap_scale_descenders), (Some(false), Some(true))); - assert_eq!(st.para.nested_styles.as_ref().map(|n| n[0].until.clone()), Some(designcraft_doc::NestedUntil::Dropcap)); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")])); } /// Frames take the corners and Text Frame Options they don't write from their object style chain, From 21863563f307ab7d4044e94299e44a824f55a432 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:49:48 +0000 Subject: [PATCH 2/8] Drop caps: a space after the cap doesn't widen the indent Spaces right after the drop cap are set at its size on its baseline and leave the lines beside it starting where its characters end. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 11 ++++++++--- crates/compose/src/tests.rs | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b0a3c2f29..d391ea828 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. -- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index d29528529..ab1066f77 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2868,7 +2868,8 @@ struct DropCap { /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, /// measured in the leading and cap height of the text after it. /// On a baseline grid of increment `grid`, lines are that many grid steps apart. With -/// `align_left` its ink starts at the indent (see [`DropCap::lsb`]). +/// `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Spaces right after it join it +/// at its size without widening it. #[allow(clippy::too_many_arguments)] fn split_drop_cap( db: &ScopedFonts<'_>, @@ -2886,6 +2887,9 @@ fn split_drop_cap( } let lines = lines.clamp(1, MAX_DROP_CAP_LINES) as usize; let mut dc: Vec = glyphs.drain(..n).collect(); + let spaces = glyphs.iter().take_while(|g| g.is_space()).count(); + let mut after: Vec = glyphs.drain(..spaces).collect(); + let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end)); let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0); let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap)); let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) { @@ -2896,7 +2900,7 @@ fn split_drop_cap( let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; - for g in &mut dc { + for g in dc.iter_mut().chain(after.iter_mut()) { g.adv *= k; g.dx *= k; g.dy *= k; @@ -2910,7 +2914,6 @@ fn split_drop_cap( g.size *= k; g.space *= k; } - let width = dc.iter().map(|g| g.adv).sum(); let lsb = match dc.first() { Some(g) if align_left => { let ink = designcraft_geom::Shape::bounding_box(&*db.outline(g.face.get(), g.gid)); @@ -2919,6 +2922,8 @@ fn split_drop_cap( } _ => 0.0, }; + let width = dc.iter().map(|g| g.adv).sum(); + dc.append(&mut after); Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 57c5516dd..5b06adfcc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2164,6 +2164,24 @@ fn drop_cap_reaches_down_by_the_text_leading_past_a_line_with_larger_leading() { assert!((b.baseline - (lines[0].baseline + 2.0 * 14.4)).abs() < 1e-6, "{}", b.baseline); } +/// A space after the drop cap is set at its size on its baseline and doesn't widen the indent: the +/// lines beside it start where its characters end. +#[test] +fn space_after_a_drop_cap_does_not_widen_the_indent() { + let (d, sid, _) = drop_doc(&format!("In 2003 {LOREM}"), drop_cap(2, 2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (i, n) = (&lines[0].glyphs[0], &lines[0].glyphs[1]); + assert_eq!((i.byte, n.byte), (0, 1)); + let end = n.x + n.adv; + for l in &lines[..2] { + assert!((text_x(l, 3) - end).abs() < 1e-6, "{} vs {end}", text_x(l, 3)); + } + let space = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!((space.sy - n.sy).abs() < 1e-9 && (space.y - n.y).abs() < 1e-9, "the space is set like the drop cap"); + assert_eq!(lines[0].drop_cap.unwrap().end, 3); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] From bcdbb68eee40608f08a33b7709b8ef1f77739e69 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:50:05 +0000 Subject: [PATCH 3/8] Drop caps: one-line caps keep a smaller size and set their tabs A one-line drop cap keeps its characters' own point size when it is smaller than the text (verse numbers), and tabs inside a left-to-right drop cap advance to the paragraph's tab stops. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 30 ++++++++++++++++++++++++------ crates/compose/src/tests.rs | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d391ea828..e99d8a51e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. -- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ab1066f77..96a0bd997 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -695,6 +695,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); // Align Left Edge (left-to-right paragraphs). let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; + let drop_tabs = (pp.direction == designcraft_doc::TextDirection::LeftToRight).then_some((&pp.tabs[..], pp.left_indent)); if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. @@ -706,7 +707,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C for _ in 0..3 { // The same drop cap, spacing, hyphenation and breaker as the layout below. let mut gl = sp.glyphs.clone(); - let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align); + let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); let width = |j: usize| { let ind = match &dc { Some(dc) if j < dc.lines => dc.indent(), @@ -774,7 +775,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| { if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) } }; - let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align); + let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; let list_label = match pp.list_type { @@ -2866,10 +2867,11 @@ struct DropCap { /// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, -/// measured in the leading and cap height of the text after it. -/// On a baseline grid of increment `grid`, lines are that many grid steps apart. With -/// `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Spaces right after it join it -/// at its size without widening it. +/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own +/// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid +/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). `tabs` +/// (stops and left indent, left-to-right paragraphs) sets the tabs inside it. Spaces right after +/// it join it at its size without widening it. #[allow(clippy::too_many_arguments)] fn split_drop_cap( db: &ScopedFonts<'_>, @@ -2880,6 +2882,7 @@ fn split_drop_cap( base_cap: f64, grid: Option, align_left: bool, + tabs: Option<(&[designcraft_doc::TabStop], f64)>, ) -> Option { let n = glyphs.iter().take_while(|g| g.byte < end).count(); if n == 0 { @@ -2899,6 +2902,7 @@ fn split_drop_cap( let drop = (lines - 1) as f64 * lead; let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; + let k = if lines == 1 { k.min(1.0) } else { k }; let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; for g in dc.iter_mut().chain(after.iter_mut()) { g.adv *= k; @@ -2922,6 +2926,20 @@ fn split_drop_cap( } _ => 0.0, }; + // Tabs advance to the paragraph's stops, measured from the column edge as in its lines. + if let Some((stops, left_indent)) = tabs { + let mut x = left_indent - lsb; + for i in 0..dc.len() { + let tab = match dc.get(i) { + Some(g) if g.ch == '\t' => Some(tab_advance(stops, left_indent, x, dc.get(i + 1..).unwrap_or_default()).0), + _ => None, + }; + if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) { + g.adv = w; + } + x += dc.get(i).map_or(0.0, |g| g.adv); + } + } let width = dc.iter().map(|g| g.adv).sum(); dc.append(&mut after); Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 5b06adfcc..cc0e5abb3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2182,6 +2182,24 @@ fn space_after_a_drop_cap_does_not_widen_the_indent() { assert_eq!(lines[0].drop_cap.unwrap().end, 3); } +/// A one-line drop cap keeps its characters' own size (a smaller verse number stays smaller), and +/// the tabs in it advance to the paragraph's tab stops. +#[test] +fn one_line_drop_cap_keeps_its_size_and_sets_its_tabs() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), ..drop_cap(1, 3) }; + let (mut d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.size = Some(8.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + let (seven, body) = (at(1), at(3)); + assert!((seven.sy / body.sy - 0.8).abs() < 1e-9, "{} vs {}", seven.sy, body.sy); + assert!(seven.y.abs() < 1e-9); + assert!((seven.x + seven.adv - 30.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x + seven.adv); + assert!((text_x(l, 3) - 40.0).abs() < 1e-6, "the text starts at the left tab: {}", text_x(l, 3)); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] From 70e41a297db2b0920afbcf6eb1b566061263e932 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:50:14 +0000 Subject: [PATCH 4/8] Drop caps: nested styles start after the drop cap style Test that the drop cap style counts as a leading "through 1 Dropcap" nested style, and describe the IDML form in ROADMAP. The previous drop cap commit also makes the cap reach down the text's leading rather than to where line N is set. --- ROADMAP.md | 2 +- crates/compose/src/tests.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index e99d8a51e..eea15067e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. -- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index cc0e5abb3..59723e2e2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2088,6 +2088,15 @@ fn drop_cap_takes_its_character_style_and_nested_styles_count_it() { assert_eq!(fill(&cs, at(0)), "Initial Red"); assert_eq!(fill(&cs, at(2)), "Initial Red"); assert_eq!(fill(&cs, at(3)), "[Black]"); + // The drop cap's style is a leading "through 1 drop cap" nested style: the nested styles start + // after the drop cap. + let one = designcraft_doc::NestedStyle { style: "Lead".into(), through: true, count: 1, until: designcraft_doc::NestedUntil::Characters }; + d.story_mut(sid).unwrap().paras[0].para.nested_styles = Some(vec![one]); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Initial Red"); + assert_eq!((fill(&cs, at(3)), fill(&cs, at(4))), ("Lead Blue".to_string(), "[Black]".to_string())); // A nested style "through 1 drop cap" covers the drop cap; the next one starts after it. let ns = |style: &str, until: designcraft_doc::NestedUntil| designcraft_doc::NestedStyle { style: style.into(), through: true, count: 1, until }; d.story_mut(sid).unwrap().paras[0].para = ParaAttrs { From 276b6f31709f6b3dd23040e7ac66d772b2290f60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:57:07 +0000 Subject: [PATCH 5/8] Drop caps: an IDML nested style list of its own clears an inherited drop cap style A style or paragraph whose nested style list has no leading Dropcap entry gets no drop cap style, since its list replaces the inherited one, Dropcap entry included. --- crates/idml/src/import.rs | 44 +++++++++++++++++++++++++++++ crates/idml/src/tests.rs | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 4dbcbb705..4f2fc5fd6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2330,12 +2330,39 @@ impl<'r> Importer<'r> { self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id)) } + /// A nested style list replaces the one a style or paragraph inherits, and with it the drop + /// cap style (the list's leading "through 1 Dropcap" entry, read into the drop cap style). A + /// list of its own without that entry clears an inherited drop cap style. + fn clear_inherited_drop_cap_styles(&mut self) { + let clears = |a: &designcraft_doc::ParaAttrs| a.drop_cap_style.is_none() && a.nested_styles.is_some(); + let cleared: Vec = self + .styles + .paragraph + .iter() + .filter(|s| clears(&s.para) && inherits_drop_cap_style(&self.styles, s.based_on.as_deref())) + .map(|s| s.name.clone()) + .collect(); + for name in cleared { + if let Some(s) = self.styles.para_mut(&name) { + s.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + for story in self.stories.values_mut() { + for p in &mut story.paras { + if clears(&p.para) && inherits_drop_cap_style(&self.styles, Some(&p.style)) { + p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + } + } + fn finish(mut self, root: &El) -> Result { let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into()); if self.spreads.is_empty() { return Err(IdmlError::Invalid("document has no spreads".into())); } self.settings.lists = std::mem::take(&mut self.lists); + self.clear_inherited_drop_cap_styles(); // Make sure built-in paragraph styles exist and are first. let d = Document { title, @@ -2527,6 +2554,23 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { o } +/// Does paragraph style `name` (and its based-on chain) set a drop cap character style? A style +/// with a nested style list of its own and no drop cap style has none. +fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> bool { + let mut seen = HashSet::new(); + while let Some(n) = name { + let Some(s) = styles.para(n).filter(|_| seen.insert(n)) else { break }; + if let Some(c) = s.para.drop_cap_style.as_deref() { + return !c.is_empty() && c != st::NO_CHAR_STYLE; + } + if s.para.nested_styles.is_some() { + return false; + } + name = s.based_on.as_deref(); + } + false +} + /// Attribute lookup over several elements: the first that has it. struct Prefs<'a>(&'a [&'a El]); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 88a7d7bc3..61bdf3374 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1848,6 +1848,65 @@ fn drop_caps_import_and_round_trip() { assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")])); } +/// IDML styles and stories with an `Opener` style whose drop cap style is `Strong`, plus `styles` +/// (more paragraph styles) and `paras` (the story's paragraph ranges). `WORDS` in them stands for +/// a nested style list of `Strong` through 1 word. +fn drop_cap_fixture(styles: &str, paras: &str) -> Vec { + let nested = |style: &str, delim: &str| { + format!( + r#"CharacterStyle/{style}{delim}1true"# + ) + }; + let words = nested("Strong", "AnyWord"); + let opener = format!( + r#"{} + {} + "#, + nested("Strong", "Dropcap"), + styles.replace("WORDS", &words) + ); + let styles = STYLES.replace("", &opener); + let story = format!( + r#" + + {} +"#, + paras.replace("WORDS", &words) + ); + 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), + ]) +} + +/// A nested style list replaces the inherited one, drop cap style entry included: a style or +/// paragraph with a list of its own and no Dropcap entry has no drop cap style. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style() { + let styles = r#"ParagraphStyle/OpenerWORDS + WORDS"#; + let paras = r#"WORDS + Cleared
+
+ + Inherited + "#; + let d = import_idml_with(&drop_cap_fixture(styles, paras), &|_| None).unwrap(); + let cap = |name: &str| d.styles.para(name).unwrap().para.drop_cap_style.clone(); + assert_eq!(cap("Opener").as_deref(), Some("Strong")); + assert_eq!(cap("Child").as_deref(), Some(st::NO_CHAR_STYLE), "its own list replaces Opener's"); + assert_eq!(cap("Plain"), None, "nothing to clear"); + let story = d.stories.values().find(|s| s.text.starts_with("Cleared")).unwrap(); + assert_eq!(story.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); + assert_eq!(story.paras[1].para.drop_cap_style, None); + assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong"); +} + /// Frames take the corners and Text Frame Options they don't write from their object style chain, /// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a /// corner shape without a radius takes the chain's radius. From f9fb64022979fe2797cc235b38eeb931a870ca29 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:57:10 +0000 Subject: [PATCH 6/8] Drop caps: a cleared drop cap style round-trips through IDML A drop cap style set at a level ([None] included) restyles the leading Dropcap entry of the nested style list, in export and when composing. A paragraph or style without nested styles of its own that clears an inherited drop cap style is written with the inherited list, whose Dropcap entry it sets to [None], or with an empty list. --- crates/compose/src/shape.rs | 16 +++++++++----- crates/idml/src/export.rs | 43 ++++++++++++++++++++++++++----------- crates/idml/src/tests.rs | 32 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 06cf04fc0..cfab72107 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -215,12 +215,18 @@ pub(crate) fn shape_para( // Nested line styles lie under nested and GREP styles, and the drop cap's style over them all // (later overlays win). let mut overlays: Vec<(std::ops::Range, String)> = lines.to_vec(); - // The drop cap's style counts as a leading "through 1 Dropcap" nested style (the form IDML - // stores it in), so the nested styles after it start after the drop cap. - let cap_style = Some(drop.1).filter(|s| !s.is_empty() && *s != designcraft_doc::NO_CHAR_STYLE); + // The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML + // stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a + // missing entry. Either way the nested styles after it start after the drop cap. + let set = Some(drop.1).filter(|s| !s.is_empty()); + let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE); let with_cap: Vec; - let nested = match cap_style { - Some(s) if !nested.is_empty() && !nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => { + let nested = match set { + Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect(); + &with_cap[..] + } + Some(s) if cap_style.is_some() && !nested.is_empty() => { with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect(); &with_cap[..] } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 67ab22f28..6fce9c77c 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -859,7 +859,8 @@ impl<'a> Ex<'a> { } (ps.para.clone(), ps.chars.clone()) }; - self.para_attrs(&mut el, &mut props, &pa); + let parent = (ps.name != designcraft_doc::NO_PARA_STYLE).then(|| ps.based_on.as_deref().unwrap_or(designcraft_doc::NO_PARA_STYLE)); + self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); pg.insert(&ps.name, with_props(el, props)); } @@ -1237,7 +1238,9 @@ impl<'a> Ex<'a> { } } - fn para_attrs(&mut self, el: &mut El, props: &mut Vec, a: &ParaAttrs) { + /// Write paragraph attributes `a`, set over the resolved paragraph style `parent` (`None` for the + /// root style). + fn para_attrs(&mut self, el: &mut El, props: &mut Vec, a: &ParaAttrs, parent: Option<&str>) { if let Some(v) = &a.mojikumi { props.push(p("Mojikumi", if v.starts_with("MojikumiTable/") { "object" } else { "enumeration" }, v)); } @@ -1286,18 +1289,32 @@ impl<'a> Ex<'a> { } } // Nested and GREP styles: property lists of records. The drop cap's character style is the - // nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it). - let cap_style = a.drop_cap_style.as_deref().filter(|s| !s.is_empty() && *s != st::NO_CHAR_STYLE); - if a.nested_styles.is_some() || cap_style.is_some() { - let mut list = a.nested_styles.clone().unwrap_or_default(); - if let Some(s) = cap_style { - match list.first_mut() { - Some(ns) if ns.is_drop_cap() => ns.style = s.to_string(), - _ => list.insert(0, designcraft_doc::NestedStyle::drop_cap(s)), - } + // nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it): a + // drop cap style set here (`[None]` included) restyles that entry, and a real one is + // inserted where there is none. Without a list of its own, the inherited list is written + // when that is what carries the drop cap style, since any list replaces the inherited one. + let real = |s: &str| !s.is_empty() && s != st::NO_CHAR_STYLE; + let cap = a.drop_cap_style.as_deref().filter(|s| !s.is_empty()); + let mut list = a.nested_styles.clone(); + if let (Some(cap), None) = (cap, &list) { + let inherited = parent.map(|n| self.d.styles.resolve_para_style(n).0); + let inherited_cap = inherited + .as_ref() + .is_some_and(|pp| real(&pp.drop_cap_style) || pp.nested_styles.first().is_some_and(|ns| ns.is_drop_cap() && real(&ns.style))); + if real(cap) || inherited_cap { + list = Some(inherited.map(|pp| pp.nested_styles).unwrap_or_default()); } + } + if let (Some(cap), Some(list)) = (cap, list.as_mut()) { + match list.first_mut() { + Some(ns) if ns.is_drop_cap() => ns.style = cap.to_string(), + _ if real(cap) => list.insert(0, designcraft_doc::NestedStyle::drop_cap(cap)), + _ => {} + } + } + if let Some(list) = &list { let mut l = El::new("AllNestedStyles").attr("type", "list"); - for ns in &list { + for ns in list { let (ty, delim) = names::nested_until_out(&ns.until); l = l.child( El::new("ListItem") @@ -2414,7 +2431,7 @@ impl<'a> Ex<'a> { fn psr_el(&mut self, pf: &ParaFormat) -> El { let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); let mut props = Vec::new(); - self.para_attrs(&mut el, &mut props, &pf.para); + self.para_attrs(&mut el, &mut props, &pf.para, Some(&pf.style)); self.char_attrs(&mut el, &mut props, &pf.chars); with_props(el, props) } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 61bdf3374..66488d1fc 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1907,6 +1907,38 @@ fn own_nested_styles_clear_an_inherited_drop_cap_style() { assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong"); } +/// A paragraph that clears its style's drop cap style without nested styles of its own round-trips: +/// it is written with the style's list, the Dropcap entry set to [None]. +#[test] +fn a_cleared_drop_cap_style_round_trips() { + let paras = r#" + Once upon a time + "#; + let mut d = import_idml_with(&drop_cap_fixture("", paras), &|_| None).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + let clear = |d: &mut Document| d.story_mut(sid).unwrap().paras[0].para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + let resolved = |d: &Document| { + let st = d.stories.values().find(|s| s.text.starts_with("Once")).unwrap(); + d.styles.resolve_para(&st.paras[0]).0 + }; + clear(&mut d); + let back = import_idml(&export_idml(&d)).unwrap(); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert_eq!(pp.nested_styles, vec![designcraft_doc::NestedStyle::drop_cap(st::NO_CHAR_STYLE)]); + // The style's drop cap style without nested styles: the paragraph is written with an empty + // list, which clears it on import. + if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; + } + assert_eq!(resolved(&d).drop_cap_style, st::NO_CHAR_STYLE); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.styles.para("Opener").unwrap().para.drop_cap_style.as_deref(), Some("Strong")); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert!(pp.nested_styles.iter().all(|ns| ns.style != "Strong"), "{:?}", pp.nested_styles); +} + /// Frames take the corners and Text Frame Options they don't write from their object style chain, /// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a /// corner shape without a radius takes the chain's radius. From 5365d0f7ca2f4e4c131e792a532f0c3021314a9f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:57:10 +0000 Subject: [PATCH 7/8] Drop caps: tabs in right-to-left drop caps advance to the tab stops Measured from the column's right edge, past the right indent. --- crates/compose/src/lib.rs | 36 +++++++++++++++++++----------------- crates/compose/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 96a0bd997..7e2163ddc 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -695,7 +695,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); // Align Left Edge (left-to-right paragraphs). let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; - let drop_tabs = (pp.direction == designcraft_doc::TextDirection::LeftToRight).then_some((&pp.tabs[..], pp.left_indent)); + // Tab stops for the drop cap, with the start-side indent (the right one, right to left). + let drop_tabs = (&pp.tabs[..], if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent }); if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. @@ -2869,9 +2870,10 @@ struct DropCap { /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, /// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own /// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid -/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). `tabs` -/// (stops and left indent, left-to-right paragraphs) sets the tabs inside it. Spaces right after -/// it join it at its size without widening it. +/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside +/// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs +/// both measured from the column's right edge). Spaces right after it join it at its size without +/// widening it. #[allow(clippy::too_many_arguments)] fn split_drop_cap( db: &ScopedFonts<'_>, @@ -2882,7 +2884,7 @@ fn split_drop_cap( base_cap: f64, grid: Option, align_left: bool, - tabs: Option<(&[designcraft_doc::TabStop], f64)>, + tabs: (&[designcraft_doc::TabStop], f64), ) -> Option { let n = glyphs.iter().take_while(|g| g.byte < end).count(); if n == 0 { @@ -2926,19 +2928,19 @@ fn split_drop_cap( } _ => 0.0, }; - // Tabs advance to the paragraph's stops, measured from the column edge as in its lines. - if let Some((stops, left_indent)) = tabs { - let mut x = left_indent - lsb; - for i in 0..dc.len() { - let tab = match dc.get(i) { - Some(g) if g.ch == '\t' => Some(tab_advance(stops, left_indent, x, dc.get(i + 1..).unwrap_or_default()).0), - _ => None, - }; - if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) { - g.adv = w; - } - x += dc.get(i).map_or(0.0, |g| g.adv); + // Tabs advance to the paragraph's stops, measured from the column's start edge. Right to left, + // its glyphs run from that (right) edge in this order (see `place_drop_cap`). + let (stops, indent) = tabs; + let mut x = indent - lsb; + for i in 0..dc.len() { + let tab = match dc.get(i) { + Some(g) if g.ch == '\t' => Some(tab_advance(stops, indent, x, dc.get(i + 1..).unwrap_or_default()).0), + _ => None, + }; + if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) { + g.adv = w; } + x += dc.get(i).map_or(0.0, |g| g.adv); } let width = dc.iter().map(|g| g.adv).sum(); dc.append(&mut after); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 59723e2e2..302374d8e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2209,6 +2209,28 @@ fn one_line_drop_cap_keeps_its_size_and_sets_its_tabs() { assert!((text_x(l, 3) - 40.0).abs() < 1e-6, "the text starts at the left tab: {}", text_x(l, 3)); } +/// Right to left, the tabs in a drop cap advance to tab stops measured from the column's right +/// edge. +#[test] +fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { + direction: Some(designcraft_doc::TextDirection::RightToLeft), + align: Some(Align::Right), + tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), + ..drop_cap(1, 3) + }; + let (d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let seven = l.glyphs.iter().find(|g| g.byte == 1).unwrap(); + // The 300 pt column's right edge is at 300: the number's far (left) end is 30 pt from it. + assert!((seven.x - 270.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x); + // The text beside it starts at the left tab, 40 pt from the edge. + let right = l.glyphs.iter().filter(|g| g.byte >= 3 && g.visible).map(|g| g.x + g.adv).fold(f64::NEG_INFINITY, f64::max); + assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] From a2ff7d03f862d6c56576e18d94948f891d8070cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 16:11:43 +0000 Subject: [PATCH 8/8] Drop caps: nested-style lists in table cells and notes clear an inherited drop cap style too The IDML import pass walks each story's table cells, footnotes and endnotes (nested, depth-bounded) as well as its own paragraphs. --- crates/idml/src/import.rs | 30 +++++++++++++++++++++++++----- crates/idml/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 4f2fc5fd6..01f73ad37 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2348,11 +2348,7 @@ impl<'r> Importer<'r> { } } for story in self.stories.values_mut() { - for p in &mut story.paras { - if clears(&p.para) && inherits_drop_cap_style(&self.styles, Some(&p.style)) { - p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); - } - } + clear_story_drop_cap_styles(&self.styles, story, 0); } } @@ -2554,6 +2550,30 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { o } +/// Deepest table-in-cell or note nesting the drop cap pass walks into. +const MAX_STORY_NESTING: usize = 32; + +/// [`Importer::clear_inherited_drop_cap_styles`] for one story's paragraphs and those of the table +/// cells, footnotes and endnotes in it. +fn clear_story_drop_cap_styles(styles: &Styles, story: &mut Story, depth: usize) { + for p in &mut story.paras { + if p.para.drop_cap_style.is_none() && p.para.nested_styles.is_some() && inherits_drop_cap_style(styles, Some(&p.style)) { + p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + if depth >= MAX_STORY_NESTING { + return; + } + for table in story.tables.values_mut() { + for cell in &mut Arc::make_mut(table).cells { + clear_story_drop_cap_styles(styles, &mut cell.text, depth + 1); + } + } + for note in story.notes.iter_mut().chain(story.endnotes.iter_mut()) { + clear_story_drop_cap_styles(styles, &mut Arc::make_mut(note).text, depth + 1); + } +} + /// Does paragraph style `name` (and its based-on chain) set a drop cap character style? A style /// with a nested style list of its own and no drop cap style has none. fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> bool { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 66488d1fc..630256579 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1907,6 +1907,30 @@ fn own_nested_styles_clear_an_inherited_drop_cap_style() { assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong"); } +/// Paragraphs in table cells and footnotes are cleared like story paragraphs. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style_in_cells_and_notes() { + let opener = |text: &str| { + format!( + r#"WORDS{text}"# + ) + }; + let paras = format!( + r#" +Body{} +{}
+
"#, + opener("Note"), + opener("Cell") + ); + let d = import_idml_with(&drop_cap_fixture("", ¶s), &|_| None).unwrap(); + let story = d.stories.values().find(|s| !s.notes.is_empty()).expect("story with a footnote"); + let note = &story.notes[0].text; + assert_eq!(note.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", note.text); + let cell = &story.tables.values().next().expect("a table").cell(0, 0).unwrap().text; + assert_eq!(cell.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", cell.text); +} + /// A paragraph that clears its style's drop cap style without nested styles of its own round-trips: /// it is written with the style's list, the Dropcap entry set to [None]. #[test]