diff --git a/ROADMAP.md b/ROADMAP.md index 0cddd05f9..45a6d22e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,6 +56,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. - 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. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1a20dadd2..dad5d9fa8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -485,6 +485,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo } let sub_objects = sub.objects.clone(); let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts }; + let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type }; let mut sp = shape::shape_para( db, &doc.styles, @@ -492,12 +493,68 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo pi, prange.clone(), &base_chars, - shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type }, + env, &sub, &mut table, &pp.nested_styles, &pp.grep_styles, + &[], ); + 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. + let cols_here = &cols[cur.fi]; + let col = cols_here[cur.col.min(cols_here.len() - 1)]; + let spacing = spacing_for(&pp, base_chars.size); + let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); + for _ in 0..3 { + let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0); + // The same spacing, hyphenation and breaker as the layout below. + let mut gl = sp.glyphs.clone(); + apply_desired_spacing(&mut gl, &pp); + let hy = hyphenation_points(&story.text, &gl, &pp); + let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { + breaker::greedy(&gl, &hy, &spacing, &width) + } else if pp.balance_ragged && !spacing.justify { + breaker::balanced(&gl, &hy, &spacing, &width) + } else { + breaker::knuth_plass(&gl, &hy, &spacing, &width) + }; + // Byte where line `k` starts (the paragraph end past the last line). + let byte_at = + |k: usize| if k == 0 { prange.start } else { breaks.get(k - 1).and_then(|b| gl.get(b.next)).map_or(prange.end, |g| g.byte) }; + let mut want = Vec::new(); + let mut line = 0usize; + for nl in &pp.nested_line_styles { + if line >= breaks.len() || nl.lines == 0 { + break; + } + let end = line + nl.lines as usize; + if !nl.style.is_empty() && nl.style != designcraft_doc::NO_CHAR_STYLE { + want.push((byte_at(line)..byte_at(end).max(byte_at(line)), nl.style.clone())); + } + line = end; + } + if want == lines { + break; + } + lines = want; + sp = shape::shape_para( + db, + &doc.styles, + story, + pi, + prange.clone(), + &base_chars, + env, + &sub, + &mut table, + &pp.nested_styles, + &pp.grep_styles, + &lines, + ); + } + } match pp.list_type { designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). @@ -1553,6 +1610,7 @@ fn prepend_label( table, &[], &[], + &[], ); let mut pre: Vec = shaped .glyphs diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 80238b259..a512b3bac 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -154,10 +154,15 @@ pub(crate) fn shape_para( table: &mut StyleTable<'_>, nested: &[designcraft_doc::NestedStyle], grep: &[designcraft_doc::GrepStyle], + lines: &[(std::ops::Range, String)], ) -> ShapedPara { let _ = pi; let mut glyphs = Vec::with_capacity(range.len()); - let overlays = if nested.is_empty() && grep.is_empty() { Vec::new() } else { crate::overlay::overlays(&story.text, range.clone(), nested, grep) }; + // Nested line styles lie under nested and GREP styles (later overlays win). + let mut overlays: Vec<(std::ops::Range, String)> = lines.to_vec(); + if !nested.is_empty() || !grep.is_empty() { + overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep)); + } // Each run, cut where nested / GREP styles start and end. let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); for (rr, fmt) in story.runs() { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 5945745a1..e9392eea4 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -225,6 +225,20 @@ pub enum NestedUntil { /// A character style applied from the start of the paragraph through (or up to) the `count`-th /// delimiter; nested styles follow each other. +/// A nested line style: `style` for the next `lines` lines. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedLineStyle { + pub style: String, + pub lines: u32, +} + +impl Default for NestedLineStyle { + fn default() -> Self { + NestedLineStyle { style: String::new(), lines: 1 } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NestedStyle { @@ -469,6 +483,8 @@ attr_set! { nested_styles: Vec = Vec::new(), /// GREP styles: a character style for every match of a pattern (applied after nested). grep_styles: Vec = Vec::new(), + /// Nested line styles: a character style for the paragraph's first lines, in order. + nested_line_styles: Vec = Vec::new(), grid_align: GridAlign = GridAlign::None, composer: Composer = Composer::Paragraph, // Hyphenation diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b37f07ca9..91d559577 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -943,3 +943,37 @@ mod drag_text_tests { assert_eq!(t.anchor, t.focus); } } + +#[cfg(test)] +mod nested_line_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn first_line_takes_the_nested_line_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap(); + let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let lines = &cs.frames[0].lines; + assert!(lines.len() >= 3); + let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size; + assert_eq!(size_of(0), 18.0, "the first line"); + assert!( + lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0), + "the whole first line" + ); + assert_eq!(size_of(1), 12.0, "the next lines"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap(); + let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default(); + assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles"); + assert_eq!(nl[0].style, "Lead"); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 68b39fd52..d09193ae7 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1059,6 +1059,18 @@ impl<'a> Ex<'a> { } props.push(l); } + if let Some(list) = &a.nested_line_styles { + let mut l = El::new("AllNestedLineStyles").attr("type", "list"); + for ns in list { + l = l.child( + El::new("ListItem") + .attr("type", "record") + .child(p("AppliedCharacterStyle", "object", names::style_self("CharacterStyle", CHAR_BUILTINS, &ns.style))) + .child(p("LineCount", "long", ns.lines.to_string())), + ); + } + props.push(l); + } if let Some(list) = &a.grep_styles { let mut l = El::new("AllGREPStyles").attr("type", "list"); for g in list { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 30d6663d9..91231523a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1093,6 +1093,19 @@ impl<'r> Importer<'r> { .collect(); a.nested_styles = Some(v); } + if let Some(l) = e.prop_el("AllNestedLineStyles") { + let v: Vec = l + .find_all("ListItem") + .map(|it| { + let t = |k: &str| it.find(k).map(|x| x.text_content()).unwrap_or_default(); + designcraft_doc::NestedLineStyle { + style: self.char_style_ref(t("AppliedCharacterStyle").trim()), + lines: t("LineCount").trim().parse().unwrap_or(1), + } + }) + .collect(); + a.nested_line_styles = Some(v); + } if let Some(l) = e.prop_el("AllGREPStyles") { let v: Vec = l .find_all("ListItem") diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f7fb14d21..f1af5ec45 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1246,6 +1246,45 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if changed { d.fields.insert("p.nestedStyles".into(), Value::Array(list)); } + ui.add_space(8.0); + ui.label(egui::RichText::new("Nested Line Styles").font(semibold(12.0))); + let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, l) in lines.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = l["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("nls_style", i)).selected_text(&style).width(130.0).show_ui(ui, |ui| { + for c in &cnames { + if ui.selectable_label(*c == style, c).clicked() { + l["style"] = json!(c); + changed = true; + } + } + }); + ui.label("for"); + let mut n = l["lines"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + l["lines"] = json!(n); + changed = true; + } + ui.label(if n == 1 { "line" } else { "lines" }); + if ui.small_button("×").on_hover_text("Delete").clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + lines.remove(i); + changed = true; + } + if ui.button("New Line Style").clicked() { + lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); + changed = true; + } + if changed { + d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); + } } "grep" => { ui.label(egui::RichText::new("GREP Styles").font(semibold(12.0))); diff --git a/docs/parity.md b/docs/parity.md index f6cc39614..f60fef19c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 78 | 18 | 1 | 90% | +| P1 | 97 | 79 | 17 | 1 | 90% | | P2 | 76 | 23 | 13 | 40 | 39% | | Area | Parity | @@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Effects & transparency | 71% | | Placing & links | 82% | | Type & text frames | 92% | -| Typography | 88% | +| Typography | 90% | | Styles | 92% | | Tables | 92% | | Long documents | 88% | @@ -223,7 +223,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Typography | Paragraph Composer (total-fit) | P0 | D | | Typography | Single-line Composer | P0 | D | | Typography | Tabs, leaders, right-indent tab | P0 | D | -| Typography | Nested / line / GREP styles | P1 | P | +| Typography | Nested / line / GREP styles | P1 | D | | Typography | Paragraph rules | P1 | D | | Typography | Paragraph borders & shading | P1 | P | | Typography | Balance ragged lines | P1 | D |