mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
T: nested line styles (+ dialog, IDML)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d357a0068d
commit
ee89bf187f
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user