T: nested line styles (+ dialog, IDML)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:01:48 -04:00
co-authored by Claude Opus 5.5
parent d357a0068d
commit ee89bf187f
9 changed files with 183 additions and 5 deletions
+1
View File
@@ -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.
+59 -1
View File
@@ -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<usize>, 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<Glyph> = shaped
.glyphs
+6 -1
View File
@@ -154,10 +154,15 @@ pub(crate) fn shape_para(
table: &mut StyleTable<'_>,
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
lines: &[(std::ops::Range<usize>, 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<usize>, 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() {
+16
View File
@@ -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<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
grep_styles: Vec<GrepStyle> = Vec::new(),
/// Nested line styles: a character style for the paragraph's first lines, in order.
nested_line_styles: Vec<NestedLineStyle> = Vec::new(),
grid_align: GridAlign = GridAlign::None,
composer: Composer = Composer::Paragraph,
// Hyphenation
+34
View File
@@ -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");
}
}
+12
View File
@@ -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 {
+13
View File
@@ -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<designcraft_doc::NestedLineStyle> = 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<designcraft_doc::GrepStyle> = l
.find_all("ListItem")
+39
View File
@@ -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<Value> = 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)));
+3 -3
View File
@@ -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 |