Paragraph rules: gap in Text Color, IDML overrides from the new fields alone, Text Color in the Color menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
paaspaas00
2026-10-21 13:33:24 +02:00
co-authored by Claude Opus 5.5
parent 85932f18ab
commit 46d830cb3b
5 changed files with 115 additions and 8 deletions
+7 -4
View File
@@ -1876,7 +1876,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0);
let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y };
let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y);
ft.decos.extend(rule_decos(doc, r, rect, color, tint));
ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above));
}
}
if pp.rule_below.on
@@ -1887,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let y = l.baseline + r.offset;
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
let (color, tint) = rule_color(r, &below);
ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint));
ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), &below));
}
// The end of a split block: text after it continues below its deepest sub-column, which
// are balanced first. A block that ends the story fills its sub-columns in turn.
@@ -4064,13 +4064,16 @@ fn hyphenation_points(
/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under
/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid
/// out along the rule draws solid.
fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec<Deco> {
/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text
/// the rule goes with, for a gap in Text Color.
fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec<Deco> {
use designcraft_doc::StrokeType;
let kind = doc.stroke_kind(&r.kind);
let (w, len) = (rect.height(), rect.width());
let mut out = Vec::new();
if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 {
out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() });
let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() };
out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() });
}
let y = rect.center().y;
let mut line = designcraft_geom::BezPath::new();
+3
View File
@@ -3719,6 +3719,9 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() {
let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) });
assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots");
assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps");
// A gap in Text Color takes the text's colour, whatever swatch the rule itself has.
let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) });
assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]"));
// Keep In Frame: a rule above raised out of the frame comes back to its top.
let above = |keep: bool| {
let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() };
+6 -2
View File
@@ -1939,7 +1939,12 @@ impl<'r> Importer<'r> {
// A style or range can set some of a rule's settings (such as only its colour); the others
// come from its paragraph style chain, so the rule is whole.
let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"];
if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) {
// Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the
// rest then come from the style chain, or the defaults: off, 1 pt).
let more = ["Type", "GapColor", "GapTint"];
let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some())
|| (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some());
if !own {
return None;
}
let v: Vec<Option<String>> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect();
@@ -1947,7 +1952,6 @@ impl<'r> Importer<'r> {
let num = |i: usize| get(i).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
let on = get(0).map(|v| v.trim() == "true");
let weight = num(1);
on.or(weight.map(|_| false))?;
let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() };
if let Some(w) = weight {
r.weight = w;
+27
View File
@@ -3705,6 +3705,33 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() {
assert_ne!(black.color, white.color, "only the colour is overridden");
}
#[test]
fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() {
let styles = STYLES.replace(
r#" </RootParagraphStyleGroup>"#,
r#" <ParagraphStyle Self="ParagraphStyle/Base" Name="Base" RuleBelow="true" RuleBelowLineWeight="3">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Wavy" Name="Wavy" RuleBelowType="StrokeStyle/$ID/Wavy">
<Properties><BasedOn type="object">ParagraphStyle/Base</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Gap" Name="Gap" RuleBelowGapColor="Color/Black">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Kept" Name="Kept" KeepRuleAboveInFrame="true">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>"#,
);
let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap();
let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone();
let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule");
assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style");
let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain");
assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}");
assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame);
}
#[test]
fn missing_linked_graphics_show_their_preview_and_keep_their_link() {
// InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3).
+72 -2
View File
@@ -4500,8 +4500,30 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s
/// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch
/// picked.
fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option<String> {
swatch_menu_with(ui, doc, id, current, width, None)
}
/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's
/// colour: the text's own), as InDesign's Paragraph Rules › Color.
fn swatch_menu_with(
ui: &mut egui::Ui,
doc: &designcraft_doc::Document,
id: impl egui::AsIdSalt,
current: &str,
width: f32,
text_color: Option<&str>,
) -> Option<String> {
let mut picked = None;
egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| {
let shown = match text_color {
Some(label) if current == designcraft_doc::TEXT_COLOR => label,
_ => current,
};
egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| {
if let Some(label) = text_color
&& ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked()
{
picked = Some(designcraft_doc::TEXT_COLOR.to_string());
}
for w in doc.swatches.iter().filter(|w| !w.hidden) {
let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0);
ui.horizontal(|ui| {
@@ -5914,7 +5936,8 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: &
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Color:"));
let color = rule["color"].as_str().unwrap_or("").to_string();
if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) {
let text_color = crate::i18n::tr(lang, "Text Color");
if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) {
d.fields.insert(key("color"), json!(w));
}
crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:"));
@@ -7562,6 +7585,53 @@ mod tests {
assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule);
}
/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can
/// go back to it; Gap Color lists only swatches.
#[test]
fn the_rule_colour_menu_offers_text_color() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let doc = app.session.doc().unwrap().doc.clone();
let menu = |ctx: &egui::Context, text: Option<&str>, click: Option<egui::Pos2>| {
let mut picked = None;
let mut labels = Vec::new();
let mut events = Vec::new();
if let Some(pos) = click {
events.push(egui::Event::PointerMoved(pos));
events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text);
});
out.textures_delta.clear();
for s in out.shapes {
if let egui::Shape::Text(t) = s.shape {
labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center()));
}
}
(picked, labels)
};
for text in [Some("Text Color"), None] {
let ctx = egui::Context::default();
let (_, labels) = menu(&ctx, text, None);
let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1;
menu(&ctx, text, Some(combo));
let (_, labels) = menu(&ctx, text, None);
let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1);
assert_eq!(item.is_some(), text.is_some(), "{labels:?}");
if let Some(pos) = item {
let (picked, _) = menu(&ctx, text, Some(pos));
assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR));
}
}
}
/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap
/// colour, from Paragraph Rules › Type and Gap Color.
#[test]