From 5c1aea75330540a99ffa37c4c97a26461420770d Mon Sep 17 00:00:00 2001
From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
Date: Sat, 10 Oct 2026 09:17:30 -0600
Subject: [PATCH 001/135] Place: an explicit x, y, spread or width puts the
image in a new frame
Explicit image placement leaves selected frames untouched; an explicit frame still wins. Null placement parameters behave like omitted values.
Reject nonnumeric, nonfinite and nonpositive image widths before editing. Cover rejection without document changes, valid sizing and null selection behavior with regression tests.
---
crates/engine/src/cmd/file.rs | 291 +++++++++++++++++++++++++++++++++-
1 file changed, 288 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index f63f0d6e9..f18f1d7b9 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -103,7 +103,7 @@ pub fn specs() -> Vec {
"Place…",
["File"],
Some("Cmd+D"),
- "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}",
+ "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}",
has_doc,
file_place
),
@@ -327,6 +327,16 @@ fn file_place(s: &mut Session, p: &Value) -> Result {
if designcraft_textimport::is_text_file(&name) {
return super::place_text::place_text(s, p, &name, &bytes, link, before);
}
+ let want_w = p
+ .get("width")
+ .filter(|value| !value.is_null())
+ .map(|value| {
+ value
+ .as_f64()
+ .filter(|width| width.is_finite() && *width > 0.0)
+ .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero"))
+ })
+ .transpose()?;
// Image Import Options: which page of a PDF (1-based).
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
@@ -362,14 +372,20 @@ fn file_place(s: &mut Session, p: &Value) -> Result {
None => None,
};
let target_frame = super::id_param(p, "frame").or_else(|| {
+ if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) {
+ return None;
+ }
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
- let spread = super::spread_param(p, "file.place")?;
+ // `"spread": null` counts as omitted, like the other placement parameters.
+ let spread = match p.get("spread") {
+ Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
+ _ => super::spread_param(p, "file.place")?,
+ };
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
- let want_w = p.get("width").and_then(Value::as_f64);
if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) {
let mime = designcraft_render::image_mime(&bytes).to_string();
let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) };
@@ -734,6 +750,275 @@ fn file_revert(s: &mut Session, _: &Value) -> Result {
}
}
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod image_place_tests {
+ use designcraft_doc::{Content, ItemId};
+ use designcraft_geom::Rect;
+ use serde_json::{Value, json};
+
+ use crate::Session;
+
+ fn image_file() -> std::io::Result {
+ image_file_with_color(30)
+ }
+
+ fn image_file_with_color(red: u8) -> std::io::Result {
+ let file = tempfile::Builder::new().suffix(".png").tempfile()?;
+ let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png();
+ std::fs::write(file.path(), png)?;
+ Ok(file)
+ }
+
+ #[test]
+ fn consecutive_path_placements_create_distinct_graphics() {
+ let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()];
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
+ let mut placements = Vec::new();
+ let mut originals = Vec::new();
+
+ for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) {
+ let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap();
+ let id = ItemId(placed["id"].as_u64().unwrap());
+ assert!(
+ placements.iter().all(|previous: &Value| previous["id"] != placed["id"]),
+ "each explicit placement needs a distinct frame: {placed}"
+ );
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(id).unwrap();
+ assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0));
+ assert!(document.spreads[spread].items.iter().any(|item| item.id == id));
+ originals.push(serde_json::to_value(item).unwrap());
+ placements.push(placed);
+ }
+
+ let document = &session.doc().unwrap().doc;
+ assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3);
+ assert_eq!(document.assets.len(), 3);
+ for (placed, original) in placements.iter().zip(originals) {
+ assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original);
+ }
+ let links = session.execute("links.list", &json!({})).unwrap();
+ assert_eq!(links.as_array().unwrap().len(), 3);
+ for (placed, image) in placements.into_iter().zip(&images) {
+ let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap();
+ assert_eq!(row["path"], json!(image.path()));
+ assert_eq!(row["uses"].as_array().unwrap().len(), 1);
+ assert_eq!(row["uses"][0]["id"], placed["id"]);
+ }
+ }
+
+ #[test]
+ fn individual_placement_parameters_bypass_selected_frames() {
+ let image = image_file().unwrap();
+ for graphic in [false, true] {
+ for (key, value, expected) in [
+ ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)),
+ ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)),
+ ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)),
+ ("width", 0, Rect::ZERO),
+ ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)),
+ ] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ if graphic {
+ session.execute("file.place", &json!({"path": image.path()})).unwrap();
+ }
+ let id = ItemId(selected["id"].as_u64().unwrap());
+ let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap();
+ let mut params = json!({"path": image.path()});
+ params[key] = json!(value);
+
+ let result = session.execute("file.place", ¶ms);
+ if key == "width" && value == 0 {
+ assert!(result.is_err(), "zero width must be rejected, graphic={graphic}");
+ continue;
+ }
+ let placed = result.unwrap();
+
+ assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}");
+ let document = &session.doc().unwrap().doc;
+ assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original);
+ let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), expected);
+ let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
+ let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs();
+ assert!(scale_x > 0.0 && scale_y > 0.0);
+ }
+ }
+ }
+
+ #[test]
+ fn explicit_frame_wins_over_placement_and_selection() {
+ let image = image_file().unwrap();
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap();
+
+ let placed =
+ session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap();
+
+ assert_eq!(placed["id"], target["id"]);
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ assert!(matches!(item.content, Content::Graphic(_)));
+ assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned));
+ assert_eq!(document.spreads[0].items.len(), 2);
+ }
+
+ fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> {
+ let image = image_file()?;
+ for graphic in [false, true] {
+ for explicit_frame in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({}))?;
+ let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
+ if graphic {
+ session.execute("file.place", &json!({"path": image.path()}))?;
+ }
+ let mut params = json!({"path": image.path(), "width": width});
+ if explicit_frame {
+ params["frame"] = target["id"].clone();
+ session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?;
+ }
+ let before = session.doc()?;
+ let document = before.doc.clone();
+ let selection = before.selection.clone();
+ let revision = before.revision;
+ let assets = document.assets.len();
+
+ let result = session.execute("file.place", ¶ms);
+
+ assert!(
+ matches!(&result, Err(crate::EngineError::BadParams { .. })),
+ "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}"
+ );
+ let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?;
+ let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) };
+ assert_eq!(cmd, "file.place");
+ assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}");
+ let after = session.doc()?;
+ assert!(std::sync::Arc::ptr_eq(&document, &after.doc));
+ assert_eq!(after.selection, selection);
+ assert_eq!(after.revision, revision);
+ assert_eq!(after.doc.assets.len(), assets);
+ }
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn width_rejected_when_zero() {
+ assert_width_rejected(json!(0)).unwrap();
+ }
+
+ #[test]
+ fn width_rejected_when_negative() {
+ assert_width_rejected(json!(-80)).unwrap();
+ }
+
+ #[test]
+ fn width_rejected_when_non_numeric() {
+ for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] {
+ assert_width_rejected(width).unwrap();
+ }
+ }
+
+ #[test]
+ fn numeric_overflow_rejected_when_parsing_json() {
+ let result = serde_json::from_str::(r#"{"width":1e400}"#);
+ let error = result.unwrap_err();
+ assert!(error.is_data() || error.is_syntax(), "{error}");
+ eprintln!("overflow parser rejected width=1e400: {error}");
+ }
+
+ fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> {
+ let image = image_file()?;
+ let previous_image = image_file_with_color(31)?;
+ for graphic in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({}))?;
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
+ let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?;
+ let mut params = json!({"path": image.path()});
+ for key in keys {
+ params[*key] = Value::Null;
+ }
+
+ let placed = session.execute("file.place", ¶ms)?;
+
+ assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}");
+ if let Some(previous) = previous {
+ assert_ne!(placed["asset"], previous["asset"]);
+ }
+ let document = &session.doc()?.doc;
+ let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?);
+ let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic");
+ let Content::Graphic(content) = &item.content else {
+ return Err(crate::EngineError::Other("placed frame must be graphic".into()).into());
+ };
+ assert_eq!(json!(content.asset.0), placed["asset"]);
+ assert_eq!(document.spreads[0].items.len(), 1);
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn selection_used_when_x_null() {
+ assert_null_placement_uses_selection(&["x"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_y_null() {
+ assert_null_placement_uses_selection(&["y"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_spread_null() {
+ assert_null_placement_uses_selection(&["spread"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_width_null() {
+ assert_null_placement_uses_selection(&["width"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_all_placement_parameters_null() {
+ assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap();
+ }
+
+ #[test]
+ fn implicit_placement_fills_empty_and_replaces_graphic_selection() {
+ let image = image_file().unwrap();
+ let previous_image = image_file_with_color(31).unwrap();
+ for graphic in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap());
+
+ let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap();
+
+ assert_eq!(placed["id"], selected["id"]);
+ if let Some(previous) = previous {
+ assert_ne!(placed["asset"], previous["asset"]);
+ }
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
+ assert_eq!(json!(content.asset.0), placed["asset"]);
+ assert_eq!(document.spreads[0].items.len(), 1);
+ }
+ }
+}
+
#[cfg(test)]
mod pdf_crop_tests {
use serde_json::json;
From 0613948d3d1da1d968a3333747eb890a62a21dc1 Mon Sep 17 00:00:00 2001
From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
Date: Sat, 10 Oct 2026 09:53:25 -0600
Subject: [PATCH 002/135] SVG: a font family named in any case finds the
installed font
DesignCraft matched an SVG font-family case-insensitively when importing
the installed faces, but the final fontdb query compares names exactly,
so "DeJaVu SaNs" loaded DejaVu Sans and then set the text in the serif
fallback (Source Serif 4) without a word. Text frames already accept any
case. The SVG font selector now runs usvg default query with each named
family spelled the way the font database spells it; family order,
generics, weight, stretch and style are unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
---
crates/images/src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++----
1 file changed, 72 insertions(+), 7 deletions(-)
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
index 28a42522d..ece1697f1 100644
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -73,14 +73,18 @@ fn svg_options() -> &'static usvg::Options<'static> {
}
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
-/// as it would in a text frame. A name that is no family's but a face's PostScript name (as
-/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in
-/// that face, whatever font-weight and font-style say: the name already picks the weight and
-/// style, and a family's other faces would contradict it. Families listed before it come first.
+/// as it would in a text frame, and matches in any case, as it does there. A name that is no
+/// family's but a face's PostScript name (as Illustrator's SVG export writes them:
+/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and
+/// font-style say: the name already picks the weight and style, and a family's other faces would
+/// contradict it. Families listed before it come first.
fn select_font() -> usvg::FontSelectionFn<'static> {
- let bundled = usvg::FontResolver::default_font_selector();
+ use usvg::fontdb::{Family, Query, Stretch, Style, Weight};
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
+ let spelled = |db: &usvg::fontdb::Database, name: &str| {
+ db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone())
+ };
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& !has_family(db, name)
@@ -103,11 +107,51 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
- // A family (or generic family) that matches: usvg's own selection.
+ // A family (or generic family) that matches: the query below.
_ => break,
}
}
- bundled(font, db)
+ // usvg's default query, but with each named family spelled as the database spells it:
+ // fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif.
+ let names: Vec> = font
+ .families()
+ .iter()
+ .map(|f| match f {
+ usvg::FontFamily::Named(name) => Some(spelled(db, name).unwrap_or_else(|| name.clone())),
+ _ => None,
+ })
+ .collect();
+ let mut families: Vec = font
+ .families()
+ .iter()
+ .zip(&names)
+ .map(|(f, name)| match f {
+ usvg::FontFamily::Serif => Family::Serif,
+ usvg::FontFamily::SansSerif => Family::SansSerif,
+ usvg::FontFamily::Cursive => Family::Cursive,
+ usvg::FontFamily::Fantasy => Family::Fantasy,
+ usvg::FontFamily::Monospace => Family::Monospace,
+ usvg::FontFamily::Named(_) => Family::Name(name.as_deref().unwrap_or_default()),
+ })
+ .collect();
+ families.push(Family::Serif);
+ let stretch = match font.stretch() {
+ usvg::FontStretch::UltraCondensed => Stretch::UltraCondensed,
+ usvg::FontStretch::ExtraCondensed => Stretch::ExtraCondensed,
+ usvg::FontStretch::Condensed => Stretch::Condensed,
+ usvg::FontStretch::SemiCondensed => Stretch::SemiCondensed,
+ usvg::FontStretch::Normal => Stretch::Normal,
+ usvg::FontStretch::SemiExpanded => Stretch::SemiExpanded,
+ usvg::FontStretch::Expanded => Stretch::Expanded,
+ usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded,
+ usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded,
+ };
+ let style = match font.style() {
+ usvg::FontStyle::Normal => Style::Normal,
+ usvg::FontStyle::Italic => Style::Italic,
+ usvg::FontStyle::Oblique => Style::Oblique,
+ };
+ db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style })
})
}
@@ -518,6 +562,27 @@ mod tests {
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular");
}
+ #[test]
+ fn svg_family_names_match_in_any_case() {
+ let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap();
+ designcraft_fonts::FontDb::global().add_font(font);
+ let text = |family: &str, attrs: &str| {
+ format!(
+ r#"Ab "#
+ )
+ };
+ for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] {
+ let set = glyph_families(&text(family, ""));
+ assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}");
+ }
+ // Weight and style still reach the query: there's only a regular face, so it sets the text.
+ let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#));
+ assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}");
+ // A family nobody has still falls to the bundled serif.
+ let set = glyph_families(&text("DC Test SVG Nobody", ""));
+ assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}");
+ }
+
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec {
let mut b = Vec::new();
From 4a2f6037710a5722e0a7ecac05f68628d40233a9 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 16 Oct 2026 14:14:34 +0000
Subject: [PATCH 003/135] Integration: #404's case test is already on main
---
crates/images/src/lib.rs | 21 ---------------------
1 file changed, 21 deletions(-)
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
index 382f8e245..25d042c43 100644
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -607,27 +607,6 @@ mod tests {
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular");
}
- #[test]
- fn svg_family_names_match_in_any_case() {
- let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap();
- designcraft_fonts::FontDb::global().add_font(font);
- let text = |family: &str, attrs: &str| {
- format!(
- r#"Ab "#
- )
- };
- for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] {
- let set = glyph_families(&text(family, ""));
- assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}");
- }
- // Weight and style still reach the query: there's only a regular face, so it sets the text.
- let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#));
- assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}");
- // A family nobody has still falls to the bundled serif.
- let set = glyph_families(&text("DC Test SVG Nobody", ""));
- assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}");
- }
-
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec {
let mut b = Vec::new();
From 352abea9fce5ef73d01761f32ddd9d80150bac72 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:47:19 +0200
Subject: [PATCH 004/135] Truncate ASE swatch names that overflow the u16
length field
Found while testing crates/color/src/ase.rs:131-132
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/color/src/ase.rs | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs
index 6cfd72034..f44e3dba8 100644
--- a/crates/color/src/ase.rs
+++ b/crates/color/src/ase.rs
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec {
continue;
}
let mut b = Vec::new();
- let units: Vec = s.name.encode_utf16().chain(std::iter::once(0)).collect();
- b.extend((units.len() as u16).to_be_bytes());
+ // The length field is a u16 that counts the terminating NUL: truncate longer names.
+ let mut units: Vec = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
+ // Do not leave half of a surrogate pair at the cut.
+ if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
+ units.pop();
+ }
+ units.push(0);
+ b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
for u in units {
b.extend(u.to_be_bytes());
}
@@ -233,6 +239,21 @@ mod tests {
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
+ #[test]
+ fn over_long_name_is_truncated_to_fit() {
+ let sw = vec![Swatch {
+ name: "x".repeat(70_000),
+ value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
+ locked: false,
+ named: true,
+ hidden: false,
+ }];
+ let back = read(&write(&sw)).unwrap();
+ assert_eq!(back.len(), 1, "the swatch must survive the round trip");
+ assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
+ assert_eq!(back[0].value, sw[0].value);
+ }
+
#[test]
fn out_of_range_components_are_clamped() {
let mut f = Vec::new();
From a5ff17356d7c3ffe91f74a970b5aa44e3955a3dc Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:56:51 +0000
Subject: [PATCH 005/135] Drop out-of-range font sizes on DOCX import
Found while testing crates/textimport/src/docx.rs:183-185
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/docx.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs
index 783b010c6..d5b1c444a 100644
--- a/crates/textimport/src/docx.rs
+++ b/crates/textimport/src/docx.rs
@@ -180,8 +180,9 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
- if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) {
- a.size = Some(sz / 2.0);
+ // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped.
+ if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) {
+ a.size = Some(pt);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
a.font_family = Some(f.to_string());
@@ -702,6 +703,19 @@ mod tests {
}
}
+ #[test]
+ fn out_of_range_sizes_are_dropped() {
+ let document = format!(
+ r#"
+ a b
+ "#
+ );
+ let st = import(&docx(&document, "", "")).unwrap().story;
+ for at in [0, 1] {
+ assert_eq!(st.format_after(at).over.size, None, "run {at}");
+ }
+ }
+
#[test]
fn tracked_changes_import_as_accepted_including_moves() {
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
From 177ac8674195323f9b248e88aa3c10293b384c6d Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:41:15 +0000
Subject: [PATCH 006/135] Keep CDATA text in the DOCX/XLSX XML parser
Found while testing crates/textimport/src/docx.rs:134-135
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/docx.rs | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs
index 783b010c6..4d90a6b7a 100644
--- a/crates/textimport/src/docx.rs
+++ b/crates/textimport/src/docx.rs
@@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result {
p.kids.push(Node::Text(s));
}
}
+ Event::CData(t) => {
+ let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
+ if let Some(p) = stack.last_mut() {
+ p.kids.push(Node::Text(s));
+ }
+ }
Event::GeneralRef(e) => {
let name = String::from_utf8_lossy(&e).to_string();
let s = match name.as_str() {
@@ -578,6 +584,12 @@ mod tests {
use super::*;
use std::io::Write;
+ #[test]
+ fn cdata_text() {
+ let el = parse(" ").unwrap();
+ assert_eq!(el.child("t").unwrap().text(), "x < y");
+ }
+
/// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later.
#[test]
fn nesting_past_the_budget_is_refused() {
From 9887272efbba2d5fb842f57388b43cb63bc271d2 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 12:39:05 +0000
Subject: [PATCH 007/135] Fix "-0p0" for tiny negative pica/cicero values
Found while testing crates/geom/src/units.rs:276
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/geom/src/units.rs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs
index da1676d4b..f1077ffce 100644
--- a/crates/geom/src/units.rs
+++ b/crates/geom/src/units.rs
@@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
whole += 1.0;
rest = 0.0;
}
+ // Decide the sign from the rounded value so float noise doesn't print "-0p0".
+ let neg = neg && (whole > 0.0 || rest > 0.0);
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
}
@@ -304,6 +306,14 @@ mod tests {
(a - b).abs() < 1e-6
}
+ /// Tiny negatives that round to zero must not print a sign.
+ #[test]
+ fn tiny_negative_picas_ciceros_have_no_sign() {
+ assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0");
+ assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0");
+ assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
+ }
+
/// Inches show four decimals, as InDesign's fields do; other units three.
#[test]
fn inches_show_four_decimals() {
From 0e7e5c3f04d339e2dbc6461aa1e703599c47aaaf Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:07:40 +0200
Subject: [PATCH 008/135] Clamp polygon side count before narrowing to u32
Found while testing crates/engine/src/cmd/object.rs:710-711 (also :975)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/object.rs | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 82f33a6d8..3244819c8 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -708,7 +708,7 @@ pub fn specs() -> Vec {
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
- s.prefs.polygon_sides = (n as u32).clamp(3, 100);
+ s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result {
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
- let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
+ let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
@@ -3787,6 +3787,21 @@ mod polygon_tests {
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
+
+ #[test]
+ fn polygon_sides_clamp_before_narrowing() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
+ assert_eq!(r["sides"], 100);
+ let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
+ assert_eq!(r["sides"], 100);
+ let id =
+ s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
+ }
}
#[cfg(test)]
From e572a5316f28d5364c32dac5205a4fbb3d2d94f1 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Tue, 20 Oct 2026 18:26:44 +0500
Subject: [PATCH 009/135] Graphics: the start-up log says which backend was
chosen and how to change it
`Fallback::begin` returns before the fallback runs when `WGPU_BACKEND`
is set, logging that wgpu picks the backend. So the line below it is
reached only when the variable is *not* set -- and it said
"(WGPU_BACKEND overrides)", which reads in a log as though the user's
environment had chosen the backend when nothing had.
before: graphics: DirectX 12 (WGPU_BACKEND overrides)
after: graphics: DirectX 12 (first choice; set WGPU_BACKEND to choose another)
The report quotes that line as evidence of which backend a start used,
which is exactly the wrong conclusion to be led to.
Part of #1343.
---
apps/designcraft/src/gpu/backend.rs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs
index 0a3cda618..57073768c 100644
--- a/apps/designcraft/src/gpu/backend.rs
+++ b/apps/designcraft/src/gpu/backend.rs
@@ -231,7 +231,10 @@ impl Fallback {
choice.backend.name(),
choice.record.failed
),
- None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
+ // `begin` returns before this when `WGPU_BACKEND` is set, so reaching here means it is
+ // not: the message said the opposite, which reads in a log as though the user's
+ // environment had chosen the backend (#1343).
+ None => log::info!("graphics: {} (first choice; set WGPU_BACKEND to choose another)", choice.backend.name()),
}
let instance = instance_backends(choice.backend, &choice.record.failed);
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
From 7aba0e15af4f808818b0094c21ae7365a40c0fbf Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:10:59 +0000
Subject: [PATCH 010/135] Saturate table alternating-fill and index paragraph
counts
Found while testing crates/engine/src/cmd/table.rs:1396-1403
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/index.rs | 4 +++-
crates/engine/src/cmd/table.rs | 8 ++++----
crates/engine/src/tests_table.rs | 23 +++++++++++++++++++++++
3 files changed, 30 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs
index bb6f09917..6ca36ee7d 100644
--- a/crates/engine/src/cmd/index.rs
+++ b/crates/engine/src/cmd/index.rs
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result {
"currentPage" => IndexRange::CurrentPage,
"toEndOfStory" => IndexRange::ToEndOfStory,
"suppressPageRange" => IndexRange::SuppressPageRange,
- "nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
+ "nextParagraphs" => {
+ IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
+ }
"see" => IndexRange::See(target()),
"seeAlso" => IndexRange::SeeAlso(target()),
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index 7049c2f39..0780f65f0 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -1393,14 +1393,14 @@ fn parse_alt(v: &Value) -> Option {
}
let d = AltFills::default();
Some(AltFills {
- first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
+ first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
- next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
+ next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
- skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
- skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
+ skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
+ skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs
index 59d2f267b..2fc4fd338 100644
--- a/crates/engine/src/tests_table.rs
+++ b/crates/engine/src/tests_table.rs
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
assert!(!convert_enabled(&s, "Body"));
check(&s);
}
+
+#[test]
+fn alternating_fill_counts_saturate_instead_of_wrapping() {
+ let (mut s, _, _) = session_with_frame();
+ s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
+ s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
+ .unwrap();
+ let t = s.execute("table.get", &json!({})).unwrap();
+ let a = &t["options"]["altRows"];
+ assert_eq!(a["first"], u32::MAX);
+ assert_eq!(a["skipFirst"], u32::MAX);
+ assert_eq!(a["skipLast"], u32::MAX);
+ assert_eq!(a["next"], 1);
+}
+
+#[test]
+fn index_next_paragraphs_saturates_instead_of_wrapping() {
+ let (mut s, _, _) = session_with_frame();
+ s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
+ let l = s.execute("index.list", &json!({})).unwrap();
+ assert_eq!(l[0]["range"]["type"], "nextParagraphs");
+ assert_eq!(l[0]["range"]["value"], u32::MAX);
+}
From d271298362af27a67c419d3a06caeda58d9a5fb5 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:34:47 +0000
Subject: [PATCH 011/135] Refuse PDF page ranges in the Place PDF dialog
instead of using page 1
Fixes #1337
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/ui-egui/src/dialogs.rs | 8 ++++++++
crates/ui-egui/src/lib.rs | 13 +++++++++++++
2 files changed, 21 insertions(+)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..1c0bfcfce 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -3268,6 +3268,14 @@ pub fn confirm(app: &mut DesignApp) -> Result {
}
"pdfImport" => {
let crop = d.s("crop");
+ if !d.s("page").trim().is_empty() && d.n("page").is_none() {
+ // A range or list ("4-5", "2,5") is not a page number: refuse it rather than place page 1.
+ let e = "Enter a single page number; one page is placed at a time".to_string();
+ let mut d = d;
+ d.fields.insert("status".into(), json!(e));
+ app.ui.dialog = Some(d);
+ return Err(e);
+ }
if d.b("async") {
return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop });
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index cb98cac7f..bea9fbfdc 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2951,6 +2951,19 @@ mod browser_import_tests {
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
}
+ #[test]
+ fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() {
+ for spec in ["4-5", "2,5", "2-3"] {
+ let mut app = app();
+ let request = app.import_request("place");
+ deliver(&mut app, request, "three.pdf", pdf());
+ app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(spec));
+ assert!(dialogs::confirm(&mut app).is_err(), "{spec}");
+ assert!(app.session.doc().unwrap().doc.assets.is_empty(), "{spec}");
+ assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport", "{spec}");
+ }
+ }
+
#[test]
fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() {
let mut app = app();
From c35dd44f8227b41a98d6d7275987348d3253e643 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:17:27 +0000
Subject: [PATCH 012/135] Remember the Preview checkbox in Paragraph/Character
Style Options
Fixes #1340
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/ui-egui/src/dialogs.rs | 30 +++++++++++++++++++++++++++++-
crates/ui-egui/src/lib.rs | 3 +++
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..b48fe5e53 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -2829,6 +2829,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") {
check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview"));
+ app.ui.style_preview = d.b("preview");
style_options_preview(app, &mut d);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -5147,7 +5148,7 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) {
return;
}
let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" };
- app.ui.dialog = Some(Dialog::new(id, json!({"name": name})));
+ app.ui.dialog = Some(Dialog::new(id, json!({"name": name, "preview": app.ui.style_preview})));
}
/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named
@@ -7763,6 +7764,33 @@ mod tests {
assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
}
+ #[test]
+ fn style_options_preview_choice_is_remembered_on_reopen() {
+ use egui_kittest::{Harness, kittest::Queryable};
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ app.run("style.paragraph.create", json!({"name":"Tutorial","chars":{"size":10}})).unwrap();
+ open_style_options(&mut app, true, "Tutorial");
+ let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
+ |ui, app: &mut DesignApp| {
+ let key = egui::Id::new("style_preview_remember_fonts");
+ if ui.data(|d| d.get_temp::(key)).is_none() {
+ crate::theme::install_fonts(ui.ctx(), "");
+ ui.data_mut(|d| d.insert_temp(key, true));
+ return;
+ }
+ show(app, ui.ctx());
+ },
+ app,
+ );
+ h.run_steps(3);
+ h.get_by_label("Preview").click();
+ h.run_steps(3);
+ cancel(h.state_mut());
+ open_style_options(h.state_mut(), true, "Tutorial");
+ assert!(h.state().ui.dialog.as_ref().unwrap().b("preview"), "Preview stays checked on reopen");
+ }
+
#[test]
fn style_options_preview_updates_all_uses_cancels_and_commits_once() {
use egui_kittest::{Harness, kittest::Queryable};
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index cb98cac7f..bf9654734 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -235,6 +235,8 @@ pub struct UiState {
pub flattener: String,
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
pub pdf_export: PdfExportSettings,
+ /// The Preview checkbox of Paragraph/Character Style Options, remembered between openings.
+ pub style_preview: bool,
/// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4).
#[serde(skip)]
pub separation: Option,
@@ -387,6 +389,7 @@ impl Default for UiState {
language: String::new(),
flattener: String::new(),
pdf_export: PdfExportSettings::default(),
+ style_preview: false,
separation: None,
ink_limit: None,
display_quality: designcraft_render::DisplayQuality::High,
From ba4b6d40892ab4937d510cbec984f301b3256ffc Mon Sep 17 00:00:00 2001
From: Hamza
Date: Tue, 20 Oct 2026 20:59:04 +0500
Subject: [PATCH 013/135] Say which backend was chosen without claiming it was
the first
Two corrections to the message, both reported by CodeRabbit on #1371.
switched_from being None only means this start did not switch from a
backend that never showed a frame. A backend that failed on an earlier
start is remembered in the record, and choose() then picks the first
candidate that is not in that list -- so calling it the "first choice"
misstates how it was arrived at. The line now says either "nothing has
failed before" or which backends had, so the log says what happened.
The comment claimed that reaching this line means WGPU_BACKEND is
unset. wgpu-types returns an empty mask for an empty or unrecognised
value and begin() only returns early for a non-empty one, so such a
value reaches here with the variable set. The comment says that instead.
---
apps/designcraft/src/gpu/backend.rs | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs
index 57073768c..6aa250dd4 100644
--- a/apps/designcraft/src/gpu/backend.rs
+++ b/apps/designcraft/src/gpu/backend.rs
@@ -231,10 +231,21 @@ impl Fallback {
choice.backend.name(),
choice.record.failed
),
- // `begin` returns before this when `WGPU_BACKEND` is set, so reaching here means it is
- // not: the message said the opposite, which reads in a log as though the user's
- // environment had chosen the backend (#1343).
- None => log::info!("graphics: {} (first choice; set WGPU_BACKEND to choose another)", choice.backend.name()),
+ // Not a switch from an unfinished start. `begin` has already returned when
+ // `WGPU_BACKEND` names a backend, so wgpu is not being told which to use here -- an
+ // empty or unrecognised value is no choice and reaches this line like an unset one.
+ // The message used to say the environment had been asked, which reads in a log as
+ // though the user had chosen the backend (#1343). What this start did choose is
+ // either the first candidate or the first one that has not failed before, so it says
+ // which of the two it is rather than claiming the first choice.
+ None if choice.record.failed.is_empty() => {
+ log::info!("graphics: {} (nothing has failed before; set WGPU_BACKEND to choose another)", choice.backend.name())
+ }
+ None => log::info!(
+ "graphics: {} (the first that hasn't failed before, after {:?}; set WGPU_BACKEND to choose another)",
+ choice.backend.name(),
+ choice.record.failed
+ ),
}
let instance = instance_backends(choice.backend, &choice.record.failed);
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
From 6641e7c29fe8be823044adfec847d7342a4852e1 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 17:44:49 +0000
Subject: [PATCH 014/135] Drop out-of-range RTF \uN values instead of wrapping
them
Found while testing crates/textimport/src/rtf.rs:237
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/rtf.rs | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs
index baf065c30..c58e51603 100644
--- a/crates/textimport/src/rtf.rs
+++ b/crates/textimport/src/rtf.rs
@@ -234,9 +234,12 @@ pub fn import(bytes: &[u8]) -> Result {
}
"u" => {
if let Some(n) = param {
- let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
- if let Some(ch) = units.push(unit) {
- buf.push(ch);
+ // RTF allows -32768..=65535; anything else is dropped.
+ if (-32768..=65535).contains(&n) {
+ let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
+ if let Some(ch) = units.push(unit) {
+ buf.push(ch);
+ }
}
pending = stack.last().map_or(1, |s| s.uc);
}
@@ -282,6 +285,12 @@ fn cp1252(b: u8) -> char {
mod tests {
use super::*;
+ #[test]
+ fn out_of_range_unicode_escape_is_dropped() {
+ assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab");
+ assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab");
+ }
+
#[test]
fn imports_rtf_text_and_formatting() {
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
From e1e9923471aaa32ef1a964dd39c487f5d4968cab Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 18:02:07 +0000
Subject: [PATCH 015/135] Keep ':', '<', '>' and '\' in Tagged Text style names
Found while testing crates/textimport/src/tagged.rs:35 (also :52)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/tagged.rs | 58 +++++++++++++++++++++++++++++----
1 file changed, 51 insertions(+), 7 deletions(-)
diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs
index 9bc84b766..ffb106ea9 100644
--- a/crates/textimport/src/tagged.rs
+++ b/crates/textimport/src/tagged.rs
@@ -25,6 +25,31 @@ fn escape(out: &mut String, c: char) {
}
}
+/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact.
+fn escape_value(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ for c in v.chars() {
+ if matches!(c, '<' | '>' | '\\') {
+ out.push('\\');
+ }
+ out.push(c);
+ }
+ out
+}
+
+/// A tag value with backslash escapes removed.
+fn unescape_value(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ let mut it = v.chars();
+ while let Some(c) = it.next() {
+ match c {
+ '\\' => out.extend(it.next()),
+ c => out.push(c),
+ }
+ }
+ out
+}
+
/// `story` as Tagged Text.
pub fn tagged_text(doc: &Document, story: &Story) -> String {
let mut out = String::from("\r\n\r\n");
@@ -34,7 +59,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
out.push_str("\r\n");
}
let pf = story.paras.get(i).cloned().unwrap_or_default();
- let _ = write!(out, "", pf.style.replace(['<', '>', ':'], ""));
+ let _ = write!(out, "", escape_value(&pf.style));
for (rr, f) in story.runs() {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
if a >= b {
@@ -45,7 +70,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
}
let mut open = Vec::new();
if f.style != NO_CHAR_STYLE {
- let _ = write!(out, "", f.style.replace(['<', '>', ':'], ""));
+ let _ = write!(out, "", escape_value(&f.style));
open.push("CharStyle");
}
if let Some(v) = &f.over.font_family {
@@ -153,7 +178,7 @@ pub fn import(bytes: &[u8]) -> Result {
}
"ParaStyle" => {
started = true;
- let v = value.trim().to_string();
+ let v = unescape_value(value.trim());
if !v.is_empty() && !para_styles.contains(&v) {
para_styles.push(v.clone());
}
@@ -162,10 +187,10 @@ pub fn import(bytes: &[u8]) -> Result {
}
}
"CharStyle" => {
- let v = value.trim();
- fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() };
- if !v.is_empty() && !char_styles.contains(&v.to_string()) {
- char_styles.push(v.to_string());
+ let v = unescape_value(value.trim());
+ fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() };
+ if !v.is_empty() && !char_styles.contains(&v) {
+ char_styles.push(v);
}
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
@@ -268,6 +293,25 @@ mod tests {
assert_eq!(back.story.paras, story.paras);
}
+ /// Style names with ':', '<', '>' or '\' were written with those characters dropped, so
+ /// re-import gave the paragraphs a different style.
+ #[test]
+ fn style_names_with_tag_characters_round_trip() {
+ let doc = Document::new(&designcraft_doc::build::NewDocument::default());
+ for name in ["Body: Indent", "Ac", "x\\y", "end\\"] {
+ let mut story = Story::new(StoryId(0));
+ story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() });
+ story.paras[0].style = name.into();
+ let out = tagged_text(&doc, &story);
+ let back = import(out.as_bytes()).unwrap();
+ assert_eq!(back.story.text, "Text", "{out}");
+ assert_eq!(back.story.paras[0].style, name, "{out}");
+ assert_eq!(back.story.char_format_at(1).style, name, "{out}");
+ assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]);
+ assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]);
+ }
+ }
+
#[test]
fn tagged_text_round_trips() {
let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text";
From 606b28dbecbb5c7e30bc76cdca62721fa98bcda0 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Tue, 20 Oct 2026 21:30:54 +0200
Subject: [PATCH 016/135] Control panel: font size, leading and tracking have a
spinner and a list of values
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/chrome.rs | 90 +++++++++++++++++++++++++++++++-----
2 files changed, 80 insertions(+), 11 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 0128924c0..2f5365795 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 0df5361f9..e84376584 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -7,7 +7,7 @@ use serde_json::json;
use crate::panels;
use crate::theme::{Tokens, semibold};
-use crate::widgets::{caption, measure, number};
+use crate::widgets::{NumField, caption, measure, number};
use crate::{DesignApp, icons};
/// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The
@@ -564,6 +564,11 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
+/// The Control panel's font sizes (and leadings), as InDesign lists them.
+const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0];
+/// The Control panel's tracking values (1/1000 em).
+const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
+
fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let attrs = panels::text_attrs(app);
@@ -585,22 +590,36 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
panels::font_style_picker(app, ui, &fam, &sty, 170.0);
});
vsep(ui);
+ // Size, leading and tracking as InDesign's Control panel has them: a spinner on the left and
+ // a list of common values on the right; automatic leading in parentheses.
egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
- caption(ui, "𝐓");
- if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) {
+ let (sz, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
+ icons::paint(ui.painter(), sz, "font-size", t.icon);
+ if let Some(v) = NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui)
+ {
let _ = app.run("type.char", json!({"attrs": {"size": v}}));
}
- caption(ui, crate::i18n::tr(&app.ui.language, "VA"));
- if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) {
+ let (tr, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
+ icons::paint(ui.painter(), tr, "tracking", t.icon);
+ if let Some(v) = NumField::number("ctrack", chars["tracking"].as_f64(), "", 0)
+ .width(76.0)
+ .spinner()
+ .presets(TRACKING)
+ .step(5.0)
+ .range(-1000.0, 10000.0)
+ .show(ui)
+ {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
ui.end_row();
- caption(ui, "Ā");
- let lead = match chars["leading"]["kind"].as_str() {
- Some("points") => chars["leading"]["value"].as_f64(),
- _ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)),
- };
- if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) {
+ let (ld, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
+ icons::paint(ui.painter(), ld, "leading", t.icon);
+ let auto = chars["leading"]["kind"].as_str() != Some("points");
+ let lead =
+ if auto { chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) } else { chars["leading"]["value"].as_f64() };
+ let lead = lead.map(|v| (v * 100.0).round() / 100.0);
+ let key = if auto { "cleadauto" } else { "clead" };
+ if let Some(v) = NumField::number(key, lead, " pt", 2).width(84.0).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) {
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
}
caption(ui, crate::i18n::tr(&app.ui.language, "IT"));
@@ -1304,6 +1323,55 @@ mod tests {
assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right");
}
+ /// Zero2Graphic's page layout video (0:48): with the Type tool, the Control panel's font size
+ /// has a list of sizes; the instructor picks 72 pt from it. Leading has the same list.
+ #[test]
+ fn the_type_control_panel_lists_font_sizes_and_leadings() {
+ let mut app = app();
+ app.run("file.new", serde_json::json!({})).unwrap();
+ let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Headline"})).unwrap();
+ app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap();
+ app.session.set_tool("type");
+ let mut h = test_window::open(app, vec2(1800.0, 700.0));
+ let bar = test_window::panel_rect(&h, "control_bar");
+ let in_bar = |r: &egui::Rect| bar.y_range().contains(r.center().y);
+ let field = |h: &egui_kittest::Harness<'static, test_window::Window>, value: &str| {
+ h.query_all_by_value(value).map(|n| n.rect()).find(in_bar).unwrap_or_else(|| panic!("no {value:?} field in the Control panel"))
+ };
+ let press = |h: &mut egui_kittest::Harness<'static, test_window::Window>, p: egui::Pos2| {
+ h.hover_at(p);
+ h.drag_at(p);
+ h.drop_at(p);
+ };
+ let size_of = |h: &egui_kittest::Harness<'static, test_window::Window>| {
+ let st = h.state().app.session.doc().unwrap();
+ let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
+ st.doc.story(sid).unwrap().format_after(0).over.size
+ };
+ // The ⌄ segment right of the size field opens the list; choosing 72 pt sets the size.
+ let size = field(&h, "12 pt");
+ let chevron = pos2(size.max.x + 1.0 + crate::widgets::SEG_W / 2.0, size.center().y);
+ press(&mut h, chevron);
+ h.run_steps(3);
+ h.get_by_label("72 pt").click();
+ h.run_steps(3);
+ assert_eq!(size_of(&h), Some(72.0));
+ // Auto leading shows in parentheses, with the same list beside it.
+ let lead = field(&h, "(86.4 pt)");
+ press(&mut h, pos2(lead.max.x + 1.0 + crate::widgets::SEG_W / 2.0, lead.center().y));
+ h.run_steps(3);
+ h.get_by_label("60 pt").click();
+ h.run_steps(3);
+ let st = h.state().app.session.doc().unwrap();
+ let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
+ assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(60.0)));
+ // The spinner left of the size steps it by a point.
+ let size = field(&h, "72 pt");
+ press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0));
+ h.run_steps(3);
+ assert_eq!(size_of(&h), Some(73.0));
+ }
+
#[test]
fn the_page_tool_has_its_own_control_panel() {
// Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the
From aa3b7b7a24165d60980500a19ee2b6ed4b39f6ab Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Wed, 21 Oct 2026 00:52:13 +0200
Subject: [PATCH 017/135] Typing the automatic leading over its value fixes it
A field showing an automatic value in parentheses commits what is typed over
it even when it is the same number, so typing 87.6 over (87.6 pt) sets fixed
leading, as InDesign does.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 13 +++++++++++++
crates/ui-egui/src/widgets.rs | 5 ++++-
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index e84376584..cd937bc47 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1370,6 +1370,19 @@ mod tests {
press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0));
h.run_steps(3);
assert_eq!(size_of(&h), Some(73.0));
+ // Typing the automatic value itself over "(87.6 pt)" fixes the leading at it.
+ h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap();
+ h.run_steps(3);
+ let lead = field(&h, "(87.6 pt)");
+ press(&mut h, lead.center());
+ h.run_steps(2);
+ h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
+ h.event(egui::Event::Text("87.6".into()));
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ let st = h.state().app.session.doc().unwrap();
+ let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
+ assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6)));
}
#[test]
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index fb9876aa2..0d6596114 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -227,8 +227,11 @@ impl<'a> NumField<'a> {
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
}
if r.lost_focus() {
+ // An automatic value (in parentheses) typed over commits even when it's the same
+ // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value.
+ let typed_over_auto = self.parens && !buf.trim_start().starts_with('(');
if let Some(v) = self.parse(&buf).map(clamp)
- && self.value.is_none_or(|old| (old - v).abs() > 1e-9)
+ && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9))
{
out = Some(v);
} else if buf.trim().is_empty() && self.value.is_some() {
From babb86c3ae92f4467ba815952423cf43a4b1f76f Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Wed, 21 Oct 2026 03:54:27 +0200
Subject: [PATCH 018/135] Escape cancels a value typed over an automatic one
Typing over "(87.6 pt)" and pressing Escape leaves the leading automatic;
only Enter or leaving the field commits it.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 12 ++++++++++++
crates/ui-egui/src/widgets.rs | 5 +++--
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index cd937bc47..851808a25 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1383,6 +1383,18 @@ mod tests {
let st = h.state().app.session.doc().unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6)));
+ // Escape cancels: the same typing, then Escape, leaves the leading automatic.
+ h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap();
+ h.run_steps(3);
+ let auto = h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading;
+ let lead = field(&h, "(87.6 pt)");
+ press(&mut h, lead.center());
+ h.run_steps(2);
+ h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
+ h.event(egui::Event::Text("87.6".into()));
+ h.key_press(egui::Key::Escape);
+ h.run_steps(3);
+ assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic");
}
#[test]
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index 0d6596114..a6e0aa64f 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -228,8 +228,9 @@ impl<'a> NumField<'a> {
}
if r.lost_focus() {
// An automatic value (in parentheses) typed over commits even when it's the same
- // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value.
- let typed_over_auto = self.parens && !buf.trim_start().starts_with('(');
+ // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape cancels it.
+ let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape));
+ let typed_over_auto = self.parens && !escaped && !buf.trim_start().starts_with('(');
if let Some(v) = self.parse(&buf).map(clamp)
&& (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9))
{
From d49343f132f063bb0bf6f69024d153e2eaad9eba Mon Sep 17 00:00:00 2001
From: HEOJUNFO
Date: Wed, 21 Oct 2026 11:18:27 +0900
Subject: [PATCH 019/135] Export placed greyscale TIFFs as DeviceGray (#1426)
Co-Authored-By: Claude Opus 5.5 (1M context)
---
crates/pdf/src/export.rs | 87 ++++++++++++++++++++++++++++++++++++++--
crates/pdf/src/tests.rs | 20 +++++++++
2 files changed, 104 insertions(+), 3 deletions(-)
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 4e7627e9e..e773896af 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -1635,6 +1635,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
+/// Whether an ICC profile describes grey.
+fn is_grey_icc(icc: &[u8]) -> bool {
+ icc.get(16..20) == Some(b"GRAY".as_slice())
+}
+
+/// Whether `data` decodes to grey (with or without alpha), read from its header.
+fn is_grey(data: &[u8]) -> bool {
+ use image::ImageDecoder;
+ image::ImageReader::new(std::io::Cursor::new(data))
+ .with_guessed_format()
+ .ok()
+ .and_then(|r| r.into_decoder().ok())
+ .is_some_and(|dec| !dec.color_type().has_color())
+}
+
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
@@ -1674,6 +1689,10 @@ fn lossless(data: &Arc>, fmt: Option, mode: RasterMo
_ => None,
};
direct.or_else(|| {
+ // A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
+ if let Some(grey) = GreyImage::new(data) {
+ return Image::from_custom(grey, i).ok();
+ }
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
@@ -1728,6 +1747,61 @@ impl krilla::image::CustomImage for SrgbImage {
}
}
+/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
+/// a page that only holds black prints as black, not as RGB.
+#[derive(Clone, Hash)]
+struct GreyImage {
+ grey: Arc>,
+ /// Only when some pixel is not opaque.
+ alpha: Option>>,
+ size: (u32, u32),
+ icc: Option>>,
+}
+
+impl GreyImage {
+ /// `None` when `data` isn't grey or can't be decoded.
+ fn new(data: &[u8]) -> Option {
+ if !is_grey(data) {
+ return None;
+ }
+ let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
+ let size = img.dimensions();
+ let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
+ if pixels == 0 {
+ return None;
+ }
+ let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
+ for p in img.pixels() {
+ grey.push(p.0[0]);
+ alpha.push(p.0[1]);
+ }
+ let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
+ let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
+ Some(Self { grey: Arc::new(grey), alpha, size, icc })
+ }
+}
+
+impl krilla::image::CustomImage for GreyImage {
+ fn color_channel(&self) -> &[u8] {
+ &self.grey
+ }
+ fn alpha_channel(&self) -> Option<&[u8]> {
+ self.alpha.as_deref().map(Vec::as_slice)
+ }
+ fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
+ krilla::image::BitsPerComponent::Eight
+ }
+ fn size(&self) -> (u32, u32) {
+ self.size
+ }
+ fn icc_profile(&self) -> Option<&[u8]> {
+ self.icc.as_deref().map(Vec::as_slice)
+ }
+ fn color_space(&self) -> krilla::image::ImageColorspace {
+ krilla::image::ImageColorspace::Luma
+ }
+}
+
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
@@ -1774,11 +1848,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option {
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
}
- let rgb = img.to_rgb8();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
- image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
- jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
+ // A grey image stays a grey JPEG (#1426).
+ let grey = !img.color().has_color();
+ if grey {
+ let l = img.to_luma8();
+ image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
+ } else {
+ let rgb = img.to_rgb8();
+ image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
+ }
+ jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode)
}
#[cfg(test)]
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index ee02e2190..39f472ffc 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() {
}
}
+/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426):
+/// a page holding only a black-and-white scan declared colour.
+#[test]
+fn grey_tiff_exports_as_device_gray() {
+ let px: Vec = vec![0, 64, 128, 255, 255, 128, 64, 0];
+ let mut tif = Vec::new();
+ tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::(4, 2, &px).unwrap();
+ let mut d = doc_with_text("x");
+ place(&mut d, tif, (4, 2));
+ for standard in [Standard::None, Standard::PdfX4] {
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
+ assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
+ assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}");
+ }
+ // Recompressed, it stays a grey JPEG.
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap();
+ let s = String::from_utf8_lossy(&r.bytes);
+ assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG");
+}
+
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec {
let img =
From 578a1ce169466caf07242cbc2aade780228a7ced Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 09:54:22 +0200
Subject: [PATCH 020/135] =?UTF-8?q?Properties:=20=E2=80=A2=E2=80=A2?=
=?UTF-8?q?=E2=80=A2=20expands=20Character=20and=20Paragraph=20in=20place,?=
=?UTF-8?q?=20as=20in=20InDesign?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Character's ••• shows vertical and horizontal scale, baseline shift, skew, language and
OpenType (the Typography panel), and the All Caps, Small Caps, Superscript, Subscript,
Underline and Strikethrough buttons. Paragraph's ••• shows the left, right, first line and last
line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid
and Hyphenate. ••• again folds the section. It used to open the Character or Paragraph panel.
The language field moves under •••, where InDesign has it. New icons for the scales, baseline
shift, skew, indents, spacing and drop caps; widgets::checkbox and ••• report their labels to
accessibility.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/i18n.rs | 41 +++++
crates/ui-egui/src/i18n/ar.rs | 11 ++
crates/ui-egui/src/i18n/cs.rs | 11 ++
crates/ui-egui/src/i18n/it.rs | 11 ++
crates/ui-egui/src/i18n/ka.rs | 11 ++
crates/ui-egui/src/i18n/pl.rs | 11 ++
crates/ui-egui/src/i18n/pt_br.rs | 11 ++
crates/ui-egui/src/i18n/ru.rs | 11 ++
crates/ui-egui/src/i18n/uk.rs | 11 ++
crates/ui-egui/src/icons.rs | 71 ++++++++
crates/ui-egui/src/panels/properties.rs | 224 ++++++++++++++++++++++--
crates/ui-egui/src/widgets.rs | 4 +
13 files changed, 418 insertions(+), 11 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index a7b5105d3..8721cce1b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index efe089ef9..8dd4709f9 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -36,6 +36,47 @@ pub const LANGUAGES: &[(&str, &str)] = &[
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
const TABLE: &[(&str, [&str; 5])] = &[
+ ("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]),
+ ("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]),
+ ("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]),
+ (
+ "Skew (False Italic)",
+ ["Verzerren (falscher Kursivstil)", "Inclinaison (faux italique)", "Sesgar (cursiva falsa)", "歪み(斜体)", "倾斜(伪斜体)"],
+ ),
+ ("Left Indent", ["Einzug links", "Retrait gauche", "Sangría izquierda", "左インデント", "左缩进"]),
+ ("Right Indent", ["Einzug rechts", "Retrait droit", "Sangría derecha", "右インデント", "右缩进"]),
+ (
+ "First Line Left Indent",
+ ["Einzug links in erster Zeile", "Retrait gauche de première ligne", "Sangría izquierda en primera línea", "1行目左インデント", "首行左缩进"],
+ ),
+ (
+ "Last Line Right Indent",
+ ["Einzug rechts in letzter Zeile", "Retrait droit de dernière ligne", "Sangría derecha en última línea", "最終行右インデント", "末行右缩进"],
+ ),
+ (
+ "Drop Cap Number of Lines",
+ ["Initiale: Anzahl der Zeilen", "Lettrine (nombre de lignes)", "Capitular: número de líneas", "ドロップキャップの行数", "首字下沉行数"],
+ ),
+ (
+ "Drop Cap One or More Characters",
+ [
+ "Initiale: ein oder mehrere Zeichen",
+ "Lettrine (un ou plusieurs caractères)",
+ "Capitular: uno o más caracteres",
+ "ドロップキャップの文字数",
+ "首字下沉一个或多个字符",
+ ],
+ ),
+ (
+ "Align to Baseline Grid",
+ [
+ "An Grundlinienraster ausrichten",
+ "Aligner sur la grille de ligne de base",
+ "Alinear con cuadrícula base",
+ "ベースライングリッドに揃える",
+ "对齐基线网格",
+ ],
+ ),
// Menus.
("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]),
("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]),
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index b050de38b..9ce202c6e 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
+ ("Vertical Scale", "المقياس الرأسي"),
+ ("Horizontal Scale", "المقياس الأفقي"),
+ ("Baseline Shift", "إزاحة خط الأساس"),
+ ("Skew (False Italic)", "إمالة (مائل زائف)"),
+ ("Left Indent", "مسافة بادئة يسرى"),
+ ("Right Indent", "مسافة بادئة يمنى"),
+ ("First Line Left Indent", "مسافة بادئة يسرى للسطر الأول"),
+ ("Last Line Right Indent", "مسافة بادئة يمنى للسطر الأخير"),
+ ("Drop Cap Number of Lines", "عدد أسطر الحرف الاستهلالي"),
+ ("Drop Cap One or More Characters", "حرف استهلالي واحد أو أكثر"),
+ ("Align to Baseline Grid", "محاذاة إلى شبكة خط الأساس"),
("Paste Vector Art", "لصق رسم متجه"),
("Baseline Options", "خيارات خط الأساس"),
("Fixed Number", "عدد ثابت"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index e77e72a1c..d6931c30e 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
+ ("Vertical Scale", "Svislá změna velikosti"),
+ ("Horizontal Scale", "Vodorovná změna velikosti"),
+ ("Baseline Shift", "Posun účaří"),
+ ("Skew (False Italic)", "Zkosení (nepravá kurzíva)"),
+ ("Left Indent", "Odsazení zleva"),
+ ("Right Indent", "Odsazení zprava"),
+ ("First Line Left Indent", "Odsazení prvního řádku zleva"),
+ ("Last Line Right Indent", "Odsazení posledního řádku zprava"),
+ ("Drop Cap Number of Lines", "Počet řádků iniciály"),
+ ("Drop Cap One or More Characters", "Iniciála: jeden nebo více znaků"),
+ ("Align to Baseline Grid", "Zarovnat na mřížku účaří"),
("Reverse Path", "Obrátit cestu"),
("Set Anchor", "Nastavit kotvu"),
("Clear", "Vymazat"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index a918a9829..9390fba86 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
+ ("Vertical Scale", "Scala verticale"),
+ ("Horizontal Scale", "Scala orizzontale"),
+ ("Baseline Shift", "Spostamento linea di base"),
+ ("Skew (False Italic)", "Inclinazione (falso corsivo)"),
+ ("Left Indent", "Rientro sinistro"),
+ ("Right Indent", "Rientro destro"),
+ ("First Line Left Indent", "Rientro sinistro prima riga"),
+ ("Last Line Right Indent", "Rientro destro ultima riga"),
+ ("Drop Cap Number of Lines", "Capolettera: numero di righe"),
+ ("Drop Cap One or More Characters", "Capolettera: uno o più caratteri"),
+ ("Align to Baseline Grid", "Allinea alla griglia di base"),
("Paste Vector Art", "Incolla grafica vettoriale"),
("Baseline Options", "Opzioni linea di base"),
("Fixed Number", "Numero fisso"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
index d938c2dc7..2085b64de 100644
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
+ ("Vertical Scale", "ვერტიკალური მასშტაბი"),
+ ("Horizontal Scale", "ჰორიზონტალური მასშტაბი"),
+ ("Baseline Shift", "საბაზო ხაზის წანაცვლება"),
+ ("Skew (False Italic)", "დახრა (ცრუ კურსივი)"),
+ ("Left Indent", "მარცხენა შეწევა"),
+ ("Right Indent", "მარჯვენა შეწევა"),
+ ("First Line Left Indent", "პირველი სტრიქონის მარცხენა შეწევა"),
+ ("Last Line Right Indent", "ბოლო სტრიქონის მარჯვენა შეწევა"),
+ ("Drop Cap Number of Lines", "ინიციალის სტრიქონების რაოდენობა"),
+ ("Drop Cap One or More Characters", "ინიციალი: ერთი ან მეტი სიმბოლო"),
+ ("Align to Baseline Grid", "საბაზო ბადეზე სწორება"),
("Inches Decimal", "ინჩები (ათობითი)"),
("Back", "უკან"),
("Warning", "გაფრთხილება"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
index 68d685d99..ee304632e 100644
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
+ ("Vertical Scale", "Skala pionowa"),
+ ("Horizontal Scale", "Skala pozioma"),
+ ("Baseline Shift", "Przesunięcie linii bazowej"),
+ ("Skew (False Italic)", "Pochylenie (fałszywa kursywa)"),
+ ("Left Indent", "Wcięcie z lewej"),
+ ("Right Indent", "Wcięcie z prawej"),
+ ("First Line Left Indent", "Wcięcie pierwszego wiersza z lewej"),
+ ("Last Line Right Indent", "Wcięcie ostatniego wiersza z prawej"),
+ ("Drop Cap Number of Lines", "Inicjał: liczba wierszy"),
+ ("Drop Cap One or More Characters", "Inicjał: jeden lub więcej znaków"),
+ ("Align to Baseline Grid", "Wyrównaj do siatki linii bazowych"),
("Clear", "Wyczyść"),
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index 8521de594..89bc29f6a 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
+ ("Vertical Scale", "Escala vertical"),
+ ("Horizontal Scale", "Escala horizontal"),
+ ("Baseline Shift", "Deslocamento da linha de base"),
+ ("Skew (False Italic)", "Inclinação (falso itálico)"),
+ ("Left Indent", "Recuo à esquerda"),
+ ("Right Indent", "Recuo à direita"),
+ ("First Line Left Indent", "Recuo à esquerda da primeira linha"),
+ ("Last Line Right Indent", "Recuo à direita da última linha"),
+ ("Drop Cap Number of Lines", "Capitular: número de linhas"),
+ ("Drop Cap One or More Characters", "Capitular: um ou mais caracteres"),
+ ("Align to Baseline Grid", "Alinhar à grade de linha de base"),
("Paste Vector Art", "Colar arte vetorial"),
("Baseline Options", "Opções de linha de base"),
("Fixed Number", "Número fixo"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 251555ca5..6706b9fb1 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
+ ("Vertical Scale", "Масштаб по вертикали"),
+ ("Horizontal Scale", "Масштаб по горизонтали"),
+ ("Baseline Shift", "Смещение базовой линии"),
+ ("Skew (False Italic)", "Наклон (ложный курсив)"),
+ ("Left Indent", "Отступ слева"),
+ ("Right Indent", "Отступ справа"),
+ ("First Line Left Indent", "Отступ первой строки слева"),
+ ("Last Line Right Indent", "Отступ последней строки справа"),
+ ("Drop Cap Number of Lines", "Буквица: число строк"),
+ ("Drop Cap One or More Characters", "Буквица: один или несколько символов"),
+ ("Align to Baseline Grid", "Выровнять по сетке базовых линий"),
("Clear", "Очистить"),
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
index dd4a1434d..f3402b9d6 100644
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
+ ("Vertical Scale", "Масштаб по вертикалі"),
+ ("Horizontal Scale", "Масштаб по горизонталі"),
+ ("Baseline Shift", "Зсув базової лінії"),
+ ("Skew (False Italic)", "Нахил (несправжній курсив)"),
+ ("Left Indent", "Відступ ліворуч"),
+ ("Right Indent", "Відступ праворуч"),
+ ("First Line Left Indent", "Відступ першого рядка ліворуч"),
+ ("Last Line Right Indent", "Відступ останнього рядка праворуч"),
+ ("Drop Cap Number of Lines", "Буквиця: кількість рядків"),
+ ("Drop Cap One or More Characters", "Буквиця: один або кілька символів"),
+ ("Align to Baseline Grid", "Вирівняти за сіткою базових ліній"),
("Paste Vector Art", "Вставити векторну графіку"),
("Baseline Options", "Параметри базової лінії"),
("Fixed Number", "Фіксована кількість"),
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index a90fe989e..446b412fe 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -938,6 +938,77 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]);
}
}
+ "vscale" => {
+ pen.text(13.0, 10.5, "T", 15.0);
+ pen.line(&[(4.0, 3.0), (4.0, 17.0)]);
+ pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]);
+ pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]);
+ }
+ "hscale" => {
+ pen.text(10.0, 8.0, "T", 13.0);
+ pen.line(&[(3.0, 16.5), (17.0, 16.5)]);
+ pen.line(&[(5.0, 14.5), (3.0, 16.5), (5.0, 18.5)]);
+ pen.line(&[(15.0, 14.5), (17.0, 16.5), (15.0, 18.5)]);
+ }
+ "baseline-shift" => {
+ pen.text(7.0, 11.0, "A", 13.0);
+ pen.text(15.5, 5.5, "a", 8.0);
+ pen.line(&[(15.5, 17.5), (15.5, 10.0)]);
+ pen.line(&[(13.5, 12.0), (15.5, 10.0), (17.5, 12.0)]);
+ }
+ "skew" => {
+ pen.w = 1.8;
+ pen.line(&[(7.0, 4.0), (17.0, 4.0)]);
+ pen.line(&[(12.0, 4.0), (8.0, 17.0)]);
+ }
+ "indent-left" | "indent-right" | "indent-first" | "indent-last" => {
+ // Text lines, and an arrow at the side (or line) indented.
+ let (x0, x1) = match name {
+ "indent-left" => (9.0, 18.0),
+ "indent-right" => (2.0, 11.0),
+ _ => (2.0, 18.0),
+ };
+ for (k, y) in [4.0, 8.0, 12.0, 16.0].into_iter().enumerate() {
+ let (a, z) = match (name, k) {
+ ("indent-first", 0) => (9.0, x1),
+ ("indent-last", 3) => (x0, 11.0),
+ _ => (x0, x1),
+ };
+ pen.line(&[(a, y), (z, y)]);
+ }
+ let (y, from, to) = match name {
+ "indent-left" => (10.0, 2.0, 7.0),
+ "indent-right" => (10.0, 18.0, 13.0),
+ "indent-first" => (4.0, 2.0, 7.0),
+ _ => (16.0, 18.0, 13.0),
+ };
+ pen.line(&[(from, y), (to, y)]);
+ let d = if to > from { -2.0 } else { 2.0 };
+ pen.line(&[(to + d, y - 2.0), (to, y), (to + d, y + 2.0)]);
+ }
+ "space-before" | "space-after" => {
+ let before = name == "space-before";
+ let (bar, lines) = if before { (3.0, [11.0, 14.5, 18.0]) } else { (17.0, [2.0, 5.5, 9.0]) };
+ pen.line(&[(2.0, bar), (18.0, bar)]);
+ for y in lines {
+ pen.line(&[(8.0, y), (18.0, y)]);
+ }
+ let (a, z) = if before { (5.0, 10.0) } else { (10.0, 15.0) };
+ pen.line(&[(4.0, a), (4.0, z)]);
+ pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]);
+ pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]);
+ }
+ "drop-lines" | "drop-chars" => {
+ pen.text(5.5, 8.0, "A", 14.0);
+ if name == "drop-chars" {
+ pen.text(13.5, 4.5, "a", 8.0);
+ }
+ for (y, x) in [(if name == "drop-chars" { 8.5 } else { 3.5 }, 11.0), (8.5, 11.0), (13.5, 11.0), (17.5, 2.0)] {
+ if name == "drop-lines" || y > 5.0 {
+ pen.line(&[(x, y), (18.0, y)]);
+ }
+ }
+ }
"corner" => {
pen.w = 1.0;
for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] {
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 44acf6e2c..8d7abdd9a 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1312,21 +1312,132 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
}) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
- // Language: hyphenation, spelling and typographer's quotes follow it.
- ui.add_space(1.0);
+ // ••• shows the rest in place, as InDesign's Properties panel does.
+ let more = temp_bool(ui, "props_char_more", false);
+ if more {
+ character_more(app, ui, &c, fw);
+ }
+ if more_options(ui, &app.ui.language).clicked() {
+ set_temp_bool(ui, "props_char_more", !more);
+ }
+}
+
+/// A percentage or other number field in one half of a two-field row, with its icon.
+#[allow(clippy::too_many_arguments)]
+fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option {
+ let t = Tokens::get(ui.ctx());
+ let x = if right { half + 10.0 } else { 0.0 };
+ let ir = sub(b, x, y + 1.0, 19.0, 19.0);
+ icons::paint(ui.painter(), ir, icon, t.icon);
+ ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()).on_hover_text(tip);
+ place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).show(ui))
+}
+
+/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew,
+/// language and OpenType, then All Caps, Small Caps, Superscript, Subscript, Underline and
+/// Strikethrough, as InDesign lays them out.
+fn character_more(app: &mut DesignApp, ui: &mut Ui, c: &Value, fw: f32) {
+ let lang_ui = app.ui.language.clone();
+ let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
+ let half = (fw - 10.0) / 2.0;
+ let b = block(ui, 2.0 * FIELD_H + 5.0);
+ const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0];
+ let pct = |v: &Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0);
+ let field = |id, v, suffix, decimals| NumField::number(id, v, suffix, decimals).spinner();
+ if let Some(v) = half_field(
+ ui,
+ b,
+ false,
+ 0.0,
+ half,
+ "vscale",
+ &tr("Vertical Scale"),
+ field("cvscale", pct(&c["vScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}}));
+ }
+ if let Some(v) = half_field(
+ ui,
+ b,
+ true,
+ 0.0,
+ half,
+ "hscale",
+ &tr("Horizontal Scale"),
+ field("chscale", pct(&c["hScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
+ }
+ if let Some(v) = half_field(
+ ui,
+ b,
+ false,
+ 26.0,
+ half,
+ "baseline-shift",
+ &tr("Baseline Shift"),
+ field("cbshift", c["baselineShift"].as_f64(), " pt", 2).range(-5000.0, 5000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
+ }
+ if let Some(v) =
+ half_field(ui, b, true, 26.0, half, "skew", &tr("Skew (False Italic)"), field("cskew", c["skew"].as_f64(), "°", 1).range(-85.0, 85.0))
+ {
+ let _ = app.run("type.char", json!({"attrs": {"skew": v}}));
+ }
+ // Language (hyphenation, spelling and typographer's quotes follow it) and OpenType.
+ ui.add_space(4.0);
let lang = c["language"].as_str().unwrap_or("English: USA").to_string();
- let label = crate::i18n::tr(&app.ui.language, "Language");
- let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| {
- crate::rtl::label(ui, label);
+ let label = tr("Language");
+ let row = block(ui, FIELD_H);
+ let resp = place(ui, sub(row, 0.0, 0.0, half, FIELD_H), |ui| widgets::dropdown(ui, crate::i18n::tr(&lang_ui, &lang), half)).on_hover_ui(|ui| {
+ crate::rtl::label(ui, label.clone());
});
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label));
egui::Popup::menu(&resp).show(|ui| {
- ui.set_min_width(fw);
+ ui.set_min_width(half);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang));
});
- if more_options(ui, &app.ui.language).clicked() {
- show_panel(app, "character");
+ if place(ui, sub(row, half + 10.0, 0.0, half, FIELD_H), |ui| widgets::outline_button(ui, &tr("OpenType"), half)).clicked() {
+ show_typography_panel(app);
}
+ // The case and position buttons.
+ ui.add_space(8.0);
+ let caps = c["capitalization"].as_str().unwrap_or("normal").to_string();
+ let pos = c["position"].as_str().unwrap_or("normal").to_string();
+ let (ul, st) = (c["underline"].as_bool().unwrap_or(false), c["strikethrough"].as_bool().unwrap_or(false));
+ let buttons: [(&str, &str, bool, Value); 6] = [
+ ("TT", "All Caps", caps == "allCaps", json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})),
+ ("Tᴛ", "Small Caps", caps == "smallCaps", json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})),
+ ("T¹", "Superscript", pos == "superscript", json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})),
+ ("T₁", "Subscript", pos == "subscript", json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})),
+ ("T̲", "Underline", ul, json!({"underline": !ul})),
+ ("T", "Strikethrough", st, json!({"strikethrough": !st})),
+ ];
+ let pitch = fw / 6.0;
+ let row = block(ui, 22.0);
+ for (i, (text, tip, on, attrs)) in buttons.into_iter().enumerate() {
+ let r = Rect::from_center_size(row.min + vec2(pitch * (i as f32 + 0.5), 11.0), vec2(26.0, 22.0));
+ let resp = ui.interact(r, ui.id().with(("case", i)), Sense::click());
+ let t = Tokens::get(ui.ctx());
+ if on {
+ ui.painter().rect_filled(r, 2.0, t.tool_active);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, 2.0, t.hover);
+ }
+ let col = if on { t.text_strong } else { t.icon };
+ ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col);
+ // Strikethrough: the T struck through (a combining stroke doesn't draw in every font).
+ if i == 5 {
+ ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col));
+ }
+ let tip = tr(tip);
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
+ if resp.on_hover_text(&tip).clicked() {
+ let _ = app.run("type.char", json!({"attrs": attrs}));
+ }
+ }
+ ui.add_space(4.0);
}
/// Paragraph section: the 9 alignment buttons and •••.
@@ -1356,9 +1467,63 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) {
}
}
});
- if more_options(ui, &app.ui.language).clicked() {
- show_panel(app, "paragraph");
+ let more = temp_bool(ui, "props_para_more", false);
+ if more {
+ paragraph_more(app, ui, &a["para"], fw);
}
+ if more_options(ui, &app.ui.language).clicked() {
+ set_temp_bool(ui, "props_para_more", !more);
+ }
+}
+
+/// Properties › Paragraph after •••: left and right indent, first line and last line indent,
+/// space before and after, drop cap lines and characters, then Hyphenate and Align to Baseline
+/// Grid, as InDesign lays them out.
+fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) {
+ let lang_ui = app.ui.language.clone();
+ let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
+ let u = units(app);
+ let half = (fw - 10.0) / 2.0;
+ ui.add_space(4.0);
+ let b = block(ui, 4.0 * FIELD_H + 15.0);
+ let rows = [
+ (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")),
+ (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
+ (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
+ ];
+ for (i, (l, r)) in rows.into_iter().enumerate() {
+ let y = i as f32 * (FIELD_H + 5.0);
+ for (right, (icon, tip, key)) in [(false, l), (true, r)] {
+ let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
+ if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
+ let _ = app.run("type.para", json!({"attrs": {key: v}}));
+ }
+ }
+ }
+ let y = 3.0 * (FIELD_H + 5.0);
+ for (right, icon, tip, key, max) in [
+ (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
+ (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
+ ] {
+ let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64);
+ if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
+ let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
+ }
+ }
+ ui.add_space(4.0);
+ ui.horizontal(|ui| {
+ let grid = p["gridAlign"].as_str() == Some("allLines");
+ if widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}}));
+ }
+ ui.add_space(12.0);
+ let mut h = p["hyphenate"].as_bool().unwrap_or(true);
+ if widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() {
+ let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
+ }
+ });
+ ui.add_space(4.0);
}
fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
@@ -3058,6 +3223,9 @@ mod tests {
app,
);
h.run_steps(3);
+ assert!(h.query_by_label("Language").is_none(), "under •••, as in InDesign");
+ h.get_all_by_label("More Options").next().unwrap().click();
+ h.run_steps(3);
h.get_by_label("Language").click();
h.run_steps(2);
h.get_by_label("French").click();
@@ -3065,6 +3233,40 @@ mod tests {
assert_eq!(language(h.state_mut()), "French");
}
+ /// Properties › Character and Paragraph •••, as in InDesign: the sections expand in place,
+ /// with the scales, baseline shift, skew, language, OpenType and the case buttons, and the
+ /// indents, spacing and drop caps.
+ #[test]
+ fn more_options_expand_character_and_paragraph_in_place() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap();
+ app.run("edit.selectAll", json!({})).unwrap();
+ let mut h = panel_harness(app, show);
+ h.run_steps(3);
+ for l in ["Vertical Scale", "Small Caps", "Drop Cap Number of Lines", "Space Before"] {
+ assert!(h.query_by_label(l).is_none(), "{l} hidden until •••");
+ }
+ h.get_all_by_label("More Options").next().unwrap().click();
+ h.run_steps(3);
+ h.get_by_label("Small Caps").click();
+ h.run_steps(3);
+ let _ = h.get_by_label("OpenType");
+ let _ = h.get_by_label("Language");
+ h.get_all_by_label("More Options").nth(1).unwrap().click();
+ h.run_steps(3);
+ let _ = h.get_by_label("Hyphenate");
+ h.get_by_label("Align to Baseline Grid").click();
+ h.run_steps(3);
+ let a = text_attrs(h.state_mut()).unwrap();
+ assert_eq!(a["chars"]["capitalization"], "smallCaps");
+ assert_eq!(a["para"]["gridAlign"], "allLines");
+ // ••• again folds the section.
+ h.get_all_by_label("More Options").next().unwrap().click();
+ h.run_steps(3);
+ assert!(h.query_by_label("Small Caps").is_none());
+ }
+
fn kerning(app: &mut DesignApp) -> Value {
text_attrs(app).unwrap()["chars"]["kerning"].clone()
}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index fb9876aa2..cf7762cbd 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -382,6 +382,8 @@ pub fn more_options(ui: &mut Ui, language: &str) -> Response {
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y));
let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click());
+ let label = crate::i18n::tr(language, "More Options");
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
for i in 0..3 {
ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon });
}
@@ -567,6 +569,8 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
*on = !*on;
resp.mark_changed();
}
+ let checked = *on;
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label));
resp
}
From e177529f98bcebe70861b2bcaacd5feeac9a2c80 Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:02:03 +0200
Subject: [PATCH 021/135] =?UTF-8?q?Control=20panel:=20the=20text=20control?=
=?UTF-8?q?s=20have=20InDesign's=20Character=20(A)=20and=20Paragraph=20(?=
=?UTF-8?q?=C2=B6)=20modes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A shows the Character Formatting Controls: font and style, size and leading, All Caps,
Superscript, Underline / Small Caps, Subscript, Strikethrough, kerning and tracking, vertical and
horizontal scale, baseline shift and skew, the character style and the language. ¶ shows the
Paragraph Formatting Controls: the nine alignments, left and first line indents, right and last
line indents, space before and after, the drop cap's lines and characters, the paragraph style,
Hyphenate and Align to Baseline Grid. The fields' icons carry their names (tooltip and
accessibility). It used to show a fixed mix of both.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/chrome.rs | 435 +++++++++++++++++++-----
crates/ui-egui/src/i18n.rs | 21 ++
crates/ui-egui/src/i18n/ar.rs | 3 +
crates/ui-egui/src/i18n/cs.rs | 3 +
crates/ui-egui/src/i18n/it.rs | 3 +
crates/ui-egui/src/i18n/ka.rs | 3 +
crates/ui-egui/src/i18n/pl.rs | 3 +
crates/ui-egui/src/i18n/pt_br.rs | 3 +
crates/ui-egui/src/i18n/ru.rs | 3 +
crates/ui-egui/src/i18n/uk.rs | 3 +
crates/ui-egui/src/panels/properties.rs | 4 +-
12 files changed, 407 insertions(+), 78 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 1b18c4328..4cff2db26 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 851808a25..ff1b281c7 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -7,7 +7,7 @@ use serde_json::json;
use crate::panels;
use crate::theme::{Tokens, semibold};
-use crate::widgets::{NumField, caption, measure, number};
+use crate::widgets::{NumField, caption, measure};
use crate::{DesignApp, icons};
/// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The
@@ -569,20 +569,95 @@ const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0,
/// The Control panel's tracking values (1/1000 em).
const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
+/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character
+/// Formatting Controls (A).
+const PARA_MODE: &str = "ctl_text_para_mode";
+
+/// An icon and a field after it, as the Control panel lays its fields out.
+fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
+ icons::paint(ui.painter(), r, icon, t.icon);
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
+ resp.on_hover_text(tip);
+ field.spinner().show(ui)
+}
+
+/// A column of two rows (the Control panel's two-row groups).
+fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) {
+ ui.vertical(|ui| {
+ ui.spacing_mut().item_spacing.y = 3.0;
+ ui.horizontal(|ui| row(ui, 0));
+ ui.horizontal(|ui| row(ui, 1));
+ });
+}
+
+/// A small toggle button showing `text` (the case and position buttons).
+fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click());
+ if on {
+ ui.painter().rect_filled(r, 2.0, t.tool_active);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, 2.0, t.hover);
+ }
+ let col = if on { t.text_strong } else { t.icon };
+ ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col);
+ if strike {
+ ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col));
+ }
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
+ resp.on_hover_text(tip).clicked()
+}
+
+/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size
+/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift
+/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment,
+/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to
+/// Baseline Grid).
fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
+ let lang = app.ui.language.clone();
+ let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
let attrs = panels::text_attrs(app);
let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default();
let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default();
+ let mode_id = egui::Id::new(PARA_MODE);
+ let para_mode = ui.data(|d| d.get_temp::(mode_id)).unwrap_or(false);
ui.vertical(|ui| {
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong));
- });
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new("¶").font(semibold(15.0)).color(t.text_dim));
- });
+ ui.spacing_mut().item_spacing.y = 3.0;
+ for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "¶", "Paragraph Formatting Controls")] {
+ let on = para_mode == para;
+ let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
+ if on {
+ ui.painter().rect_filled(r, 2.0, t.tool_active);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, 2.0, t.hover);
+ }
+ ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim });
+ let tip = tr(tip);
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
+ if resp.on_hover_text(&tip).clicked() {
+ ui.data_mut(|d| d.insert_temp(mode_id, para));
+ }
+ }
});
vsep(ui);
+ if para_mode {
+ control_paragraph(app, ui, &attrs, ¶, &tr);
+ } else {
+ control_character(app, ui, &attrs, &chars, ¶, &tr);
+ }
+}
+
+fn control_character(
+ app: &mut DesignApp,
+ ui: &mut egui::Ui,
+ attrs: &Option,
+ chars: &serde_json::Value,
+ para: &serde_json::Value,
+ tr: &dyn Fn(&str) -> String,
+) {
ui.vertical(|ui| {
let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();
let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();
@@ -590,95 +665,268 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
panels::font_style_picker(app, ui, &fam, &sty, 170.0);
});
vsep(ui);
- // Size, leading and tracking as InDesign's Control panel has them: a spinner on the left and
- // a list of common values on the right; automatic leading in parentheses.
- egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
- let (sz, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
- icons::paint(ui.painter(), sz, "font-size", t.icon);
- if let Some(v) = NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui)
- {
- let _ = app.run("type.char", json!({"attrs": {"size": v}}));
+ // Size and leading: a spinner on the left and a list of common values on the right;
+ // automatic leading in parentheses.
+ rows(ui, |ui, row| {
+ if row == 0 {
+ if let Some(v) = icon_field(
+ ui,
+ "font-size",
+ &tr("Font Size"),
+ NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"size": v}}));
+ }
+ } else {
+ let auto = chars["leading"]["kind"].as_str() != Some("points");
+ let lead = if auto {
+ chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2))
+ } else {
+ chars["leading"]["value"].as_f64()
+ };
+ let lead = lead.map(|v| (v * 100.0).round() / 100.0);
+ let key = if auto { "cleadauto" } else { "clead" };
+ if let Some(v) = icon_field(
+ ui,
+ "leading",
+ &tr("Leading"),
+ NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
+ }
}
- let (tr, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
- icons::paint(ui.painter(), tr, "tracking", t.icon);
- if let Some(v) = NumField::number("ctrack", chars["tracking"].as_f64(), "", 0)
- .width(76.0)
- .spinner()
- .presets(TRACKING)
- .step(5.0)
- .range(-1000.0, 10000.0)
- .show(ui)
- {
- let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
+ });
+ vsep(ui);
+ // All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough.
+ let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string();
+ let pos = chars["position"].as_str().unwrap_or("normal").to_string();
+ let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false));
+ let mut set = None;
+ rows(ui, |ui, row| {
+ if row == 0 {
+ if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) {
+ set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }}));
+ }
+ if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) {
+ set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }}));
+ }
+ if text_toggle(ui, "T̲", false, ul, &tr("Underline")) {
+ set = Some(json!({"underline": !ul}));
+ }
+ } else {
+ if text_toggle(ui, "Tᴛ", false, caps == "smallCaps", &tr("Small Caps")) {
+ set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }}));
+ }
+ if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) {
+ set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }}));
+ }
+ if text_toggle(ui, "T", true, st, &tr("Strikethrough")) {
+ set = Some(json!({"strikethrough": !st}));
+ }
}
- ui.end_row();
- let (ld, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
- icons::paint(ui.painter(), ld, "leading", t.icon);
- let auto = chars["leading"]["kind"].as_str() != Some("points");
- let lead =
- if auto { chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) } else { chars["leading"]["value"].as_f64() };
- let lead = lead.map(|v| (v * 100.0).round() / 100.0);
- let key = if auto { "cleadauto" } else { "clead" };
- if let Some(v) = NumField::number(key, lead, " pt", 2).width(84.0).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) {
- let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
+ });
+ if let Some(a) = set {
+ let _ = app.run("type.char", json!({"attrs": a}));
+ }
+ vsep(ui);
+ rows(ui, |ui, row| {
+ if row == 0 {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
+ icons::paint(ui.painter(), r, "kerning", t.icon);
+ resp.on_hover_text(tr("Kerning"));
+ crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0);
+ } else {
+ if let Some(v) = icon_field(
+ ui,
+ "tracking",
+ &tr("Tracking"),
+ NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
+ }
}
- caption(ui, crate::i18n::tr(&app.ui.language, "IT"));
- if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) {
- let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
+ });
+ vsep(ui);
+ const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0];
+ let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0);
+ rows(ui, |ui, row| {
+ if row == 0 {
+ if let Some(v) = icon_field(
+ ui,
+ "vscale",
+ &tr("Vertical Scale"),
+ NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}}));
+ }
+ } else {
+ if let Some(v) = icon_field(
+ ui,
+ "hscale",
+ &tr("Horizontal Scale"),
+ NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
+ }
+ }
+ });
+ vsep(ui);
+ rows(ui, |ui, row| {
+ if row == 0 {
+ if let Some(v) = icon_field(
+ ui,
+ "baseline-shift",
+ &tr("Baseline Shift"),
+ NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
+ }
+ } else {
+ if let Some(v) = icon_field(
+ ui,
+ "skew",
+ &tr("Skew (False Italic)"),
+ NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0),
+ ) {
+ let _ = app.run("type.char", json!({"attrs": {"skew": v}}));
+ }
}
- ui.end_row();
});
vsep(ui);
ui.vertical(|ui| {
- let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
- ui.horizontal(|ui| {
- for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] {
- if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"align": a}}));
+ ui.spacing_mut().item_spacing.y = 3.0;
+ // The character style, then the language.
+ let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
+ let names: Vec = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
+ let mut pick = None;
+ egui::ComboBox::from_id_salt("ctl_char_style")
+ .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs)))
+ .width(150.0)
+ .show_ui(ui, |ui| {
+ for n in &names {
+ if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
+ pick = Some(n.clone());
+ }
}
- }
- });
- ui.horizontal(|ui| {
- for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] {
- if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"align": a}}));
- }
- }
+ });
+ if let Some(n) = pick {
+ let _ = app.run("style.character.apply", json!({"name": n}));
+ }
+ let lang = chars["language"].as_str().unwrap_or("English: USA").to_string();
+ egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| {
+ egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang));
});
});
+}
+
+fn control_paragraph(
+ app: &mut DesignApp,
+ ui: &mut egui::Ui,
+ attrs: &Option,
+ para: &serde_json::Value,
+ tr: &dyn Fn(&str) -> String,
+) {
+ let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
+ let mut align = None;
+ rows(ui, |ui, row| {
+ if row == 0 {
+ for (a, icon) in [
+ (Align::Left, "palign-left"),
+ (Align::Center, "palign-center"),
+ (Align::Right, "palign-right"),
+ (Align::TowardsSpine, "palign-spine-towards"),
+ ] {
+ if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
+ align = Some(a);
+ }
+ }
+ } else {
+ for (a, icon) in [
+ (Align::LeftJustified, "palign-justify-left"),
+ (Align::CenterJustified, "palign-justify-center"),
+ (Align::RightJustified, "palign-justify-right"),
+ (Align::FullyJustified, "palign-justify-all"),
+ (Align::AwayFromSpine, "palign-spine-away"),
+ ] {
+ if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
+ align = Some(a);
+ }
+ }
+ }
+ });
+ if let Some(a) = align {
+ let _ = app.run("type.para", json!({"attrs": {"align": a}}));
+ }
vsep(ui);
let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
- egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
- caption(ui, "→|");
- if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) {
- let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}}));
+ let mut set = None;
+ for ((ia, ta, ka), (ib, tb, kb)) in [
+ (("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")),
+ (("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
+ (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
+ ] {
+ rows(ui, |ui, row| {
+ if row == 0 {
+ if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) {
+ set = Some((ka, v));
+ }
+ } else {
+ let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) {
+ set = Some((kb, v));
+ }
+ }
+ });
+ vsep(ui);
+ }
+ if let Some((k, v)) = set {
+ let _ = app.run("type.para", json!({"attrs": {k: v}}));
+ }
+ let mut drop = None;
+ rows(ui, |ui, row| {
+ if row == 0 {
+ let max = designcraft_compose::MAX_DROP_CAP_LINES as f64;
+ if let Some(v) = icon_field(
+ ui,
+ "drop-lines",
+ &tr("Drop Cap Number of Lines"),
+ NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max),
+ ) {
+ drop = Some(("dropCapLines", v));
+ }
+ } else {
+ let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64;
+ if let Some(v) = icon_field(
+ ui,
+ "drop-chars",
+ &tr("Drop Cap One or More Characters"),
+ NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max),
+ ) {
+ drop = Some(("dropCapChars", v));
+ }
}
- caption(ui, "↑¶");
- if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) {
- let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}}));
- }
- ui.end_row();
- caption(ui, "→¶");
- if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) {
- let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}}));
- }
- caption(ui, "↓¶");
- if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) {
- let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}}));
- }
- ui.end_row();
});
+ if let Some((k, v)) = drop {
+ let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}}));
+ }
vsep(ui);
ui.vertical(|ui| {
+ ui.spacing_mut().item_spacing.y = 3.0;
let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string();
let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0)
+ attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0);
panels::para_style_picker(app, ui, &ps, ov > 0, 170.0);
- let hy = para["hyphenate"].as_bool().unwrap_or(true);
- let mut h = hy;
- if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
- let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
- }
+ ui.horizontal(|ui| {
+ let mut h = para["hyphenate"].as_bool().unwrap_or(true);
+ if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() {
+ let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
+ }
+ let grid = para["gridAlign"].as_str() == Some("allLines");
+ if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() {
+ let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}}));
+ }
+ });
});
}
@@ -1397,6 +1645,41 @@ mod tests {
assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic");
}
+ /// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the
+ /// Character Formatting Controls (the case buttons, kerning and tracking, the scales…), ¶ the
+ /// Paragraph Formatting Controls (alignment, indents, spacing, the drop cap…).
+ #[test]
+ fn the_type_control_panel_has_character_and_paragraph_modes() {
+ let mut app = app();
+ app.run("file.new", serde_json::json!({})).unwrap();
+ let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap();
+ app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap();
+ app.session.set_tool("type");
+ let mut h = test_window::open(app, vec2(2400.0, 700.0));
+ let bar = test_window::panel_rect(&h, "control_bar");
+ let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y);
+ let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| {
+ let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel"));
+ h.hover_at(r.center());
+ h.drag_at(r.center());
+ h.drop_at(r.center());
+ h.run_steps(3);
+ };
+ let has = |h: &egui_kittest::Harness<'static, test_window::Window>, label: &str| h.query_all_by_label(label).any(|n| in_bar(n.rect()));
+ assert!(has(&h, "Small Caps") && has(&h, "Vertical Scale") && !has(&h, "Drop Cap Number of Lines"));
+ click(&mut h, "Small Caps");
+ click(&mut h, "Paragraph Formatting Controls");
+ assert!(has(&h, "Drop Cap Number of Lines") && has(&h, "Space Before") && !has(&h, "Small Caps"));
+ click(&mut h, "Align to Baseline Grid");
+ click(&mut h, "Align center");
+ let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
+ assert_eq!(a["chars"]["capitalization"], "smallCaps");
+ assert_eq!(a["para"]["gridAlign"], "allLines");
+ assert_eq!(a["para"]["align"], "center");
+ click(&mut h, "Character Formatting Controls");
+ assert!(has(&h, "Small Caps"));
+ }
+
#[test]
fn the_page_tool_has_its_own_control_panel() {
// Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 8dd4709f9..816bbe7cd 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -36,6 +36,27 @@ pub const LANGUAGES: &[(&str, &str)] = &[
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
const TABLE: &[(&str, [&str; 5])] = &[
+ (
+ "Character Formatting Controls",
+ [
+ "Steuerelemente für Zeichenformatierung",
+ "Commandes de mise en forme des caractères",
+ "Controles de formato de carácter",
+ "文字形式コントロール",
+ "字符格式控制",
+ ],
+ ),
+ (
+ "Paragraph Formatting Controls",
+ [
+ "Steuerelemente für Absatzformatierung",
+ "Commandes de mise en forme des paragraphes",
+ "Controles de formato de párrafo",
+ "段落形式コントロール",
+ "段落格式控制",
+ ],
+ ),
+ ("Font Size", ["Schriftgrad", "Corps", "Tamaño de fuente", "フォントサイズ", "字体大小"]),
("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]),
("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]),
("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]),
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index 9ce202c6e..d11fabebc 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
+ ("Character Formatting Controls", "عناصر تحكم تنسيق الأحرف"),
+ ("Paragraph Formatting Controls", "عناصر تحكم تنسيق الفقرات"),
+ ("Font Size", "حجم الخط"),
("Vertical Scale", "المقياس الرأسي"),
("Horizontal Scale", "المقياس الأفقي"),
("Baseline Shift", "إزاحة خط الأساس"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index d6931c30e..b1004d9eb 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
+ ("Character Formatting Controls", "Ovládací prvky formátování znaků"),
+ ("Paragraph Formatting Controls", "Ovládací prvky formátování odstavců"),
+ ("Font Size", "Velikost písma"),
("Vertical Scale", "Svislá změna velikosti"),
("Horizontal Scale", "Vodorovná změna velikosti"),
("Baseline Shift", "Posun účaří"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index 9390fba86..172687d3d 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
+ ("Character Formatting Controls", "Controlli di formattazione carattere"),
+ ("Paragraph Formatting Controls", "Controlli di formattazione paragrafo"),
+ ("Font Size", "Dimensione font"),
("Vertical Scale", "Scala verticale"),
("Horizontal Scale", "Scala orizzontale"),
("Baseline Shift", "Spostamento linea di base"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
index 2085b64de..8bf351d74 100644
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
+ ("Character Formatting Controls", "სიმბოლოების ფორმატირების მართვა"),
+ ("Paragraph Formatting Controls", "აბზაცების ფორმატირების მართვა"),
+ ("Font Size", "შრიფტის ზომა"),
("Vertical Scale", "ვერტიკალური მასშტაბი"),
("Horizontal Scale", "ჰორიზონტალური მასშტაბი"),
("Baseline Shift", "საბაზო ხაზის წანაცვლება"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
index ee304632e..10e1a42e4 100644
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
+ ("Character Formatting Controls", "Elementy formatowania znaków"),
+ ("Paragraph Formatting Controls", "Elementy formatowania akapitów"),
+ ("Font Size", "Rozmiar czcionki"),
("Vertical Scale", "Skala pionowa"),
("Horizontal Scale", "Skala pozioma"),
("Baseline Shift", "Przesunięcie linii bazowej"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index 89bc29f6a..09c714a6b 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
+ ("Character Formatting Controls", "Controles de formatação de caracteres"),
+ ("Paragraph Formatting Controls", "Controles de formatação de parágrafos"),
+ ("Font Size", "Tamanho da fonte"),
("Vertical Scale", "Escala vertical"),
("Horizontal Scale", "Escala horizontal"),
("Baseline Shift", "Deslocamento da linha de base"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 6706b9fb1..0ba1dad67 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
+ ("Character Formatting Controls", "Элементы управления форматированием символов"),
+ ("Paragraph Formatting Controls", "Элементы управления форматированием абзацев"),
+ ("Font Size", "Кегль"),
("Vertical Scale", "Масштаб по вертикали"),
("Horizontal Scale", "Масштаб по горизонтали"),
("Baseline Shift", "Смещение базовой линии"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
index f3402b9d6..905841457 100644
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
+ ("Character Formatting Controls", "Елементи керування форматуванням символів"),
+ ("Paragraph Formatting Controls", "Елементи керування форматуванням абзаців"),
+ ("Font Size", "Кегль"),
("Vertical Scale", "Масштаб по вертикалі"),
("Horizontal Scale", "Масштаб по горизонталі"),
("Baseline Shift", "Зсув базової лінії"),
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 8d7abdd9a..ff8447aba 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1634,7 +1634,7 @@ pub(crate) const LANGUAGES: &[&str] = &[
/// The Language list (Character panel, Properties ▸ Character): picking one sets the language of
/// the selected text.
-fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
+pub(crate) fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
for l in LANGUAGES {
if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
let _ = app.run("type.char", json!({"attrs": {"language": l}}));
@@ -1646,7 +1646,7 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
/// Kerning for the text selection, or at a caret for the pair at the caret: the label shows
/// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value
/// in 1/1000 em (typed or stepped).
-fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) {
+pub(crate) fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) {
let kind = k["kind"].as_str().unwrap_or("metrics");
let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite());
let label = match (kind, manual) {
From 6cb9466129855db5e69dda24d504a1fbf7532a47 Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:57:12 +0200
Subject: [PATCH 022/135] =?UTF-8?q?Docs:=20the=20Properties=20=E2=80=A2?=
=?UTF-8?q?=E2=80=A2=E2=80=A2=20sections=20in=20the=20UI=20parity=20checkl?=
=?UTF-8?q?ist?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/ui-parity.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 2dcf4687a..043633d37 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Properties › Character and Paragraph ••• expand in place; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -27,7 +27,7 @@ basics of editing fail for too many users on Windows and Linux.
| Area | InDesign 2026 | DesignCraft | Status | Evidence |
|---|---|---|---|---|
| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md |
-| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | |
+| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); Character and Paragraph ••• expand the section in place (scales, baseline shift, skew, language, OpenType, case buttons; indents, spacing, drop caps) | ✓ | `properties.rs` `more_options_expand_character_and_paragraph_in_place` |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
| Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 |
From 58791345029fa5cfeb035f52ecefd5147bd158e0 Mon Sep 17 00:00:00 2001
From: Nikolai Nyegaard
Date: Wed, 21 Oct 2026 12:01:45 +0200
Subject: [PATCH 023/135] Preferences: list the languages alphabetically by
their own name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
---
crates/ui-egui/src/dialogs.rs | 4 +--
crates/ui-egui/src/i18n.rs | 49 +++++++++++++++++++++++++++++++++++
2 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..ebab15050 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1984,8 +1984,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui(
ui,
|ui| {
- for (code, name) in crate::i18n::LANGUAGES {
- if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() {
+ for (code, name) in crate::i18n::picker_languages() {
+ if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() {
// Applied by `show` right after this frame, which has `&mut DesignApp`.
d.fields.insert("applyLanguage".into(), json!(code));
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index efe089ef9..60b827dc3 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -34,6 +34,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("ka", "ქართული"),
];
+/// The interface languages in the order the Preferences picker lists them: English first (the
+/// source language), then the rest by their own name with accents folded, so Čeština sits under
+/// C. Names in other scripts follow the Latin ones in code point order: Cyrillic, Arabic,
+/// Georgian, then the CJK languages.
+pub fn picker_languages() -> Vec<(&'static str, &'static str)> {
+ let mut rest: Vec<(&str, &str)> = LANGUAGES.iter().copied().filter(|(code, _)| !code.is_empty()).collect();
+ rest.sort_by_cached_key(|(_, name)| name_sort_key(name));
+ std::iter::once(LANGUAGES[0]).chain(rest).collect()
+}
+
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
const TABLE: &[(&str, [&str; 5])] = &[
// Menus.
@@ -5133,3 +5143,42 @@ mod tests {
}
}
}
+
+/// `name` lowercased with the Latin accents that occur in language names folded away, so a
+/// byte-wise sort reads alphabetically. Other scripts are left as they are.
+fn name_sort_key(name: &str) -> String {
+ name.chars()
+ .flat_map(char::to_lowercase)
+ .map(|c| match c {
+ 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
+ 'ç' | 'ć' | 'č' => 'c',
+ 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
+ 'ì'..='ï' | 'ī' => 'i',
+ 'ñ' | 'ń' | 'ň' => 'n',
+ 'ò'..='ö' | 'ø' | 'ő' => 'o',
+ 'ś' | 'š' => 's',
+ 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
+ 'ý' | 'ÿ' => 'y',
+ 'ź' | 'ż' | 'ž' => 'z',
+ c => c,
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod picker_order_tests {
+ use super::*;
+
+ #[test]
+ fn picker_lists_english_first_then_names_alphabetically() {
+ let order = picker_languages();
+ assert_eq!(order.first(), Some(&("", "English")));
+ assert_eq!(order.len(), LANGUAGES.len());
+ let keys: Vec = order[1..].iter().map(|(_, name)| name_sort_key(name)).collect();
+ assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
+ let pos = |code: &str| order.iter().position(|(c, _)| *c == code).unwrap_or(usize::MAX);
+ assert!(pos("cs") < pos("de"), "Čeština sorts under C");
+ assert!(pos("pl") < pos("ru"), "Latin names come before Cyrillic");
+ assert!(pos("ru") < pos("ja"), "Cyrillic names come before CJK");
+ }
+}
From 13527f9cb73fd58bc3650b0ef446b429611dcc72 Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 13:15:51 +0200
Subject: [PATCH 024/135] Control panel fields: Escape reverts any typed value;
parity docs for the text modes
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 2 +-
crates/ui-egui/src/chrome.rs | 16 ++++++++++++++++
crates/ui-egui/src/widgets.rs | 8 +++++---
docs/ui-parity.md | 7 ++++---
4 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 4cff2db26..bcdbd62e9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -349,8 +349,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
-- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
+- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index ff1b281c7..749115b31 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1643,6 +1643,22 @@ mod tests {
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic");
+ // Escape also reverts a different number, in the leading field and in a plain one. (It
+ // leaves the text too, so each try selects it again.)
+ for (shown, typed) in [("(87.6 pt)", "20"), ("73 pt", "50")] {
+ h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap();
+ h.state_mut().app.session.set_tool("type");
+ h.run_steps(3);
+ let r = field(&h, shown);
+ press(&mut h, r.center());
+ h.run_steps(2);
+ h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
+ h.event(egui::Event::Text(typed.into()));
+ h.key_press(egui::Key::Escape);
+ h.run_steps(3);
+ }
+ assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "still automatic");
+ assert_eq!(size_of(&h), Some(73.0), "the size is unchanged");
}
/// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index 277d1fc9a..31fe194c2 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -228,10 +228,12 @@ impl<'a> NumField<'a> {
}
if r.lost_focus() {
// An automatic value (in parentheses) typed over commits even when it's the same
- // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape cancels it.
+ // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape reverts
+ // whatever was typed, as in InDesign.
let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape));
- let typed_over_auto = self.parens && !escaped && !buf.trim_start().starts_with('(');
- if let Some(v) = self.parse(&buf).map(clamp)
+ let typed_over_auto = self.parens && !buf.trim_start().starts_with('(');
+ if escaped {
+ } else if let Some(v) = self.parse(&buf).map(clamp)
&& (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9))
{
out = Some(v);
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 2dcf4687a..98912561a 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -26,8 +26,8 @@ basics of editing fail for too many users on Windows and Linux.
| Area | InDesign 2026 | DesignCraft | Status | Evidence |
|---|---|---|---|---|
-| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md |
-| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | |
+| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls, and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 |
+| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16 |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
| Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 |
@@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
| Date | Change | Summary |
|---|---|---|
+| 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place |
| 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) |
| 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests |
| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) |
From 4320235bc6fd52f0d4a6d7cbd5865c8ddfc7333b Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 14:33:10 +0200
Subject: [PATCH 025/135] Space Before / Space After in the shared translation
table
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/i18n.rs | 3 +++
crates/ui-egui/src/i18n/fr.rs | 2 --
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 8dd4709f9..a7d41aab7 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -77,6 +77,9 @@ const TABLE: &[(&str, [&str; 5])] = &[
"对齐基线网格",
],
),
+ // Properties › Paragraph (•••).
+ ("Space Before", ["Abstand davor", "Espace avant", "Espacio anterior", "段落前の間隔", "段前间距"]),
+ ("Space After", ["Abstand danach", "Espace après", "Espacio posterior", "段落後の間隔", "段后间距"]),
// Menus.
("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]),
("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]),
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
index 78c5d5948..fe1fcce32 100644
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -167,8 +167,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Single Right Quotation Mark", "Guillemet simple fermant"),
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
- ("Space After", "Espace après"),
- ("Space Before", "Espace avant"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
("Story Column Direction", "Sens des colonnes de l'article"),
From 8ec58cc1cc8b10363ab0f30a533157e7bac93d83 Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 14:38:28 +0200
Subject: [PATCH 026/135] Number fields are labelled by the icon that names
them (accessibility)
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 25 +++++++++++++++++++++++-
crates/ui-egui/src/panels/properties.rs | 7 +++++--
crates/ui-egui/src/widgets.rs | 26 ++++++++++++++++++++++++-
3 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 749115b31..690d8fc32 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -579,8 +579,9 @@ fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Opti
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
icons::paint(ui.painter(), r, icon, t.icon);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
+ let label = resp.id;
resp.on_hover_text(tip);
- field.spinner().show(ui)
+ field.spinner().labelled_by(label).show(ui)
}
/// A column of two rows (the Control panel's two-row groups).
@@ -1367,6 +1368,28 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
#[cfg(test)]
mod tests {
+
+ /// A Control panel field is labelled by its icon, so assistive technology reads "Tracking"
+ /// (or "Font Size", …) with the value.
+ #[test]
+ fn icon_fields_are_labelled_by_their_icon() {
+ use egui::accesskit::Role;
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx, "en");
+ ctx.enable_accesskit();
+ let mut update = None;
+ for _ in 0..2 {
+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
+ icon_field(ui, "tracking", "Tracking", NumField::number("tracking_test", Some(0.0), "", 0));
+ });
+ out.textures_delta.clear();
+ update = out.platform_output.accesskit_update;
+ }
+ let update = update.expect("an AccessKit tree");
+ let named = |n: &egui::accesskit::Node| n.label() == Some("Tracking") || n.value() == Some("Tracking");
+ let icon = update.nodes.iter().find(|(_, n)| named(n)).map(|(id, _)| *id).expect("the icon's node");
+ assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TextInput && n.labelled_by().contains(&icon)), "the editor names its icon");
+ }
use egui::{pos2, vec2};
use egui_kittest::kittest::{NodeT, Queryable};
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index ff8447aba..8a2d03f65 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1329,8 +1329,11 @@ fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str,
let x = if right { half + 10.0 } else { 0.0 };
let ir = sub(b, x, y + 1.0, 19.0, 19.0);
icons::paint(ui.painter(), ir, icon, t.icon);
- ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()).on_hover_text(tip);
- place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).show(ui))
+ let resp = ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
+ let label = resp.id;
+ resp.on_hover_text(tip);
+ place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show(ui))
}
/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew,
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index 31fe194c2..d8eb6c51e 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -80,6 +80,8 @@ pub struct NumField<'a> {
range: (f64, f64),
enabled: bool,
parens: bool,
+ /// The widget naming the field (an icon or caption beside it), for accessibility.
+ label: Option,
}
impl<'a> NumField<'a> {
@@ -90,7 +92,19 @@ impl<'a> NumField<'a> {
Self::new(id, value, NumKind::Number { suffix, decimals })
}
fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self {
- NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false }
+ NumField {
+ id,
+ value,
+ kind,
+ width: 72.0,
+ spinner: false,
+ presets: &[],
+ step: None,
+ range: (f64::MIN, f64::MAX),
+ enabled: true,
+ parens: false,
+ label: None,
+ }
}
/// Total width (spinner and preset segments included).
pub fn width(mut self, w: f32) -> Self {
@@ -130,6 +144,12 @@ impl<'a> NumField<'a> {
self.enabled = on;
self
}
+ /// The editor is labelled by widget `id` (an icon or caption that names the field), so
+ /// assistive technology reads the field's name with its value.
+ pub fn labelled_by(mut self, id: egui::Id) -> Self {
+ self.label = Some(id);
+ self
+ }
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
@@ -210,6 +230,10 @@ impl<'a> NumField<'a> {
editor_ui.disable();
}
let r = editor_ui.put(value_rect, te);
+ let r = match self.label {
+ Some(id) => r.labelled_by(id),
+ None => r,
+ };
if r.has_focus() {
// ↑/↓ step the value while typing.
let (up, down, shift) = ui.input_mut(|i| {
From 189cccd2fc8f5a53c50ee3b40e9c2519a79bb8c6 Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 16:04:31 +0200
Subject: [PATCH 027/135] Bulleted and Numbered List buttons give InDesign's
default hanging indent
The Properties buttons and Type > Bulleted & Numbered Lists > Apply Bullets /
Apply Numbers (list.bullets, list.numbers) apply the list with Left Indent
0.25 in / First Line Indent -0.25 in when the paragraph has no indents, so the
text lines up after the bullet; pressed again, the list and that indent go.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/engine/src/cmd/lists.rs | 125 +++++++++++++++++++++++-
crates/ui-egui/src/i18n.rs | 2 +
crates/ui-egui/src/i18n/ar.rs | 2 +
crates/ui-egui/src/i18n/cs.rs | 2 +
crates/ui-egui/src/i18n/de.rs | 2 +
crates/ui-egui/src/i18n/it.rs | 2 +
crates/ui-egui/src/i18n/ja.rs | 2 +
crates/ui-egui/src/i18n/ka.rs | 2 +
crates/ui-egui/src/i18n/pl.rs | 2 +
crates/ui-egui/src/i18n/pt_br.rs | 2 +
crates/ui-egui/src/i18n/ru.rs | 2 +
crates/ui-egui/src/i18n/uk.rs | 2 +
crates/ui-egui/src/menus.rs | 16 +++
crates/ui-egui/src/panels/properties.rs | 36 ++++++-
docs/target-app-parity.md | 3 +-
16 files changed, 198 insertions(+), 5 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index a7b5105d3..7936e5797 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs
index 9f8824870..cad62f271 100644
--- a/crates/engine/src/cmd/lists.rs
+++ b/crates/engine/src/cmd/lists.rs
@@ -1,13 +1,37 @@
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
-use designcraft_doc::NumberedList;
+use designcraft_doc::{ListType, NumberedList};
use serde_json::{Value, json};
-use super::{CommandSpec, bad, cmd, has_doc, str_param};
+use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
+use crate::{Result, Session};
+
+/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
+/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
+/// the text after its tab at the left indent.
+pub const LIST_HANG: f64 = 18.0;
pub fn specs() -> Vec {
vec![
+ cmd!(
+ "list.bullets",
+ "Apply Bullets",
+ ["Type", "Bulleted & Numbered Lists"],
+ None,
+ "{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
+ has_text_or_frames,
+ |s, p| apply_list(s, p, ListType::Bullets)
+ ),
+ cmd!(
+ "list.numbers",
+ "Apply Numbers",
+ ["Type", "Bulleted & Numbered Lists"],
+ None,
+ "{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
+ has_text_or_frames,
+ |s, p| apply_list(s, p, ListType::Numbers)
+ ),
cmd!(
"list.define",
"Define Lists…",
@@ -48,12 +72,109 @@ pub fn specs() -> Vec {
]
}
+/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
+/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
+/// style rather than set locally, so a paragraph back to its style shows no override.
+fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
+ let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
+ let on = match p.get("on") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
+ };
+ let targets = super::text::format_targets_pub(s);
+ if targets.is_empty() {
+ return Err(bad(name, "select text or a text frame"));
+ }
+ let d = &s.doc()?.doc;
+ // Off when every selected paragraph already has this list, as the button shows it pressed.
+ let on = on.unwrap_or_else(|| {
+ !targets.iter().all(|t| {
+ d.text_story(t.story, t.cell).is_some_and(|st| {
+ let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start)));
+ st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
+ })
+ })
+ });
+ s.edit(|d, _| {
+ let styles = d.styles.clone();
+ for t in &targets {
+ let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
+ st.format_paras(t.range.clone(), |pf| {
+ let (now, _) = styles.resolve_para(pf);
+ let (style, _) = styles.resolve_para_style(&pf.style);
+ // A value: left to the style when it gives the same, else set here.
+ let mut set = |key: &str, v: Value, same: bool| {
+ let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
+ };
+ let list = if on { kind } else { ListType::None };
+ set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
+ if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
+ set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
+ set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
+ } else if !on && now.list_type == kind && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG {
+ set("leftIndent", json!(0.0), style.left_indent == 0.0);
+ set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
+ }
+ });
+ }
+ Ok(json!({"on": on}))
+ })
+}
+
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
+ /// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
+ /// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
+ /// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
+ #[test]
+ fn list_buttons_apply_the_default_hanging_indent() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
+ let sid = f["story"].as_u64().unwrap();
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
+ let para = |s: &Session, i: usize| {
+ let d = &s.doc().unwrap().doc;
+ let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
+ (d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
+ };
+ let undo = s.doc().unwrap().history.undo.len();
+ assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
+ for i in 0..2 {
+ let (p, _) = para(&s, i);
+ assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
+ }
+ assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
+ assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
+ // The number of a bullet's tab: the text starts at the left indent.
+ let d = &s.doc().unwrap().doc;
+ let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
+ let line = &cs.frames[0].lines[0];
+ let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
+ assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
+ // Numbers instead: the indent stays.
+ s.execute("list.numbers", &json!({})).unwrap();
+ let (p, _) = para(&s, 0);
+ assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
+ // Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
+ assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
+ let (p, own) = para(&s, 0);
+ assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
+ assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
+ // A paragraph with its own indents keeps them.
+ s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
+ s.execute("list.bullets", &json!({"on": true})).unwrap();
+ let (p, _) = para(&s, 0);
+ assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
+ s.execute("list.bullets", &json!({"on": false})).unwrap();
+ assert_eq!(para(&s, 0).0.left_indent, 36.0);
+ assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
+ }
+
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index efe089ef9..484ffde93 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -1633,6 +1633,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]),
+ ("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]),
+ ("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]),
(
"Define Text Variable…",
["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"],
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index b050de38b..bfdf45542 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
+ ("Apply Bullets", "تطبيق التعداد النقطي"),
+ ("Apply Numbers", "تطبيق الترقيم"),
("Paste Vector Art", "لصق رسم متجه"),
("Baseline Options", "خيارات خط الأساس"),
("Fixed Number", "عدد ثابت"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index e77e72a1c..033c85b51 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
+ ("Apply Bullets", "Použít odrážky"),
+ ("Apply Numbers", "Použít číslování"),
("Reverse Path", "Obrátit cestu"),
("Set Anchor", "Nastavit kotvu"),
("Clear", "Vymazat"),
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index efc128858..4f52c8de2 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Ausschneiden"),
("Copy", "Kopieren"),
("Paste", "Einfügen"),
+ ("Apply Bullets", "Aufzählungszeichen anwenden"),
+ ("Apply Numbers", "Nummerierung anwenden"),
("Inches Decimal", "Zoll dezimal"),
("Back", "Zurück"),
("Warning", "Warnung"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index a918a9829..dba32fdd4 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
+ ("Apply Bullets", "Applica punti elenco"),
+ ("Apply Numbers", "Applica numeri"),
("Paste Vector Art", "Incolla grafica vettoriale"),
("Baseline Options", "Opzioni linea di base"),
("Fixed Number", "Numero fisso"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
index 5647ca940..7a29a55c0 100644
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "カット"),
("Copy", "コピー"),
("Paste", "ペースト"),
+ ("Apply Bullets", "箇条書きを適用"),
+ ("Apply Numbers", "段落番号を適用"),
("Paste Vector Art", "ベクトルアートをペースト"),
("Baseline Options", "ベースラインオプション"),
("Column Rules", "段落境界線"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
index d938c2dc7..5ad070a41 100644
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
+ ("Apply Bullets", "მარკერების გამოყენება"),
+ ("Apply Numbers", "ნუმერაციის გამოყენება"),
("Inches Decimal", "ინჩები (ათობითი)"),
("Back", "უკან"),
("Warning", "გაფრთხილება"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
index 68d685d99..8eca515d9 100644
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
+ ("Apply Bullets", "Zastosuj wypunktowanie"),
+ ("Apply Numbers", "Zastosuj numerowanie"),
("Clear", "Wyczyść"),
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index 8521de594..4fcfe85be 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
+ ("Apply Bullets", "Aplicar marcadores"),
+ ("Apply Numbers", "Aplicar números"),
("Paste Vector Art", "Colar arte vetorial"),
("Baseline Options", "Opções de linha de base"),
("Fixed Number", "Número fixo"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 251555ca5..d9ee2a929 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
+ ("Apply Bullets", "Применить маркеры"),
+ ("Apply Numbers", "Применить нумерацию"),
("Clear", "Очистить"),
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
index dd4a1434d..cce040a48 100644
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
+ ("Apply Bullets", "Застосувати маркери"),
+ ("Apply Numbers", "Застосувати нумерацію"),
("Paste Vector Art", "Вставити векторну графіку"),
("Baseline Options", "Параметри базової лінії"),
("Fixed Number", "Фіксована кількість"),
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 79b8b2c4e..dde5ca033 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -502,6 +502,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
"-",
">Bulleted & Numbered Lists",
+ "cmd:list.bullets",
+ "cmd:list.numbers",
+ "-",
"cmd:list.define",
"<",
"-",
@@ -2377,6 +2380,19 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option {
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
+ // Pressed, as the Bulleted and Numbered List buttons: the selected paragraphs have it.
+ "list.bullets" | "list.numbers" => {
+ let kind = if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers };
+ app.session
+ .active()
+ .and_then(|st| {
+ let t = st.selection.text?;
+ let story = st.doc.text_story(t.story, t.cell)?;
+ let pf = story.paras.get(story.para_at(t.range().start))?;
+ Some(st.doc.styles.resolve_para(pf).0.list_type == kind)
+ })
+ .unwrap_or(false)
+ }
"window.controlBar" => app.ui.control_bar,
"window.split" => app.split,
"window.newWindow" => app.second_window,
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 44acf6e2c..79280f66f 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1366,10 +1366,13 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
- for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] {
+ // As InDesign's: a list with the default hanging indent, or (pressed) no list.
+ for (icon, kind, cmd, tip) in
+ [("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")]
+ {
let on = lt == kind;
if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}}));
+ let _ = app.run(cmd, json!({"on": !on}));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -3317,6 +3320,35 @@ mod tests {
h
}
+ /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs
+ /// bullets with InDesign's hanging indent; pressed again, both go.
+ #[test]
+ fn the_bulleted_list_button_applies_a_hanging_indent() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ let f = app
+ .run(
+ "frame.create",
+ json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs
+1 cup milk"}),
+ )
+ .unwrap();
+ app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
+ let mut h = panel(app);
+ h.get_by_label("Bulleted List").click();
+ h.run_steps(3);
+ let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap();
+ assert_eq!(
+ (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()),
+ (Some("bullets"), Some(18.0), Some(-18.0))
+ );
+ assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it");
+ h.get_by_label("Bulleted List").click();
+ h.run_steps(3);
+ let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap();
+ assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0)));
+ }
+
#[test]
fn several_shapes_show_pathfinder_and_intersect_combines_them() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index cdd6da689..3d86a3fed 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (list buttons with the default hanging indent; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -401,6 +401,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web).
- 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.
+- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if it is still as applied. One undo step; values equal to the paragraph style's are left to the style.
- 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.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
From 2c1973a6e49ca901fbea25dea9ac717b7328c45d Mon Sep 17 00:00:00 2001
From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com>
Date: Wed, 21 Oct 2026 16:16:25 +0200
Subject: [PATCH 028/135] =?UTF-8?q?Control=20panel=20(=C2=B6):=20Bulleted?=
=?UTF-8?q?=20and=20Numbered=20List,=20Shading=20and=20Border,=20as=20InDe?=
=?UTF-8?q?sign's?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/chrome.rs | 140 +++++++++++++++++++++++++++++++++++
crates/ui-egui/src/icons.rs | 14 ++++
docs/ui-parity.md | 2 +-
4 files changed, 156 insertions(+), 1 deletion(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index f8c5c2d83..0031bd5ed 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 690d8fc32..3af8e9b91 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -912,6 +912,66 @@ fn control_paragraph(
let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}}));
}
vsep(ui);
+ // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or,
+ // pressed, none).
+ let lt = para["listType"].as_str().unwrap_or("none").to_string();
+ let mut list = None;
+ rows(ui, |ui, row| {
+ let (icon, kind, cmd, tip) = if row == 0 {
+ ("bullets", "bullets", "list.bullets", "Bulleted List")
+ } else {
+ ("numbering", "numbers", "list.numbers", "Numbered List")
+ };
+ let on = lt == kind;
+ if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() {
+ list = Some((cmd, !on));
+ }
+ });
+ if let Some((cmd, on)) = list {
+ let _ = app.run(cmd, json!({"on": on}));
+ }
+ vsep(ui);
+ // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the
+ // colour, as InDesign's.
+ let doc = app.session.active().map(|d| d.doc.clone());
+ let mut shade = None;
+ let mut dialog = None;
+ // Both checkboxes as wide as the wider label, so the icons and colours line up in columns.
+ let check_w = ["Shading", "Border"]
+ .iter()
+ .map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x))
+ .fold(0.0, f32::max)
+ + 28.0;
+ rows(ui, |ui, row| {
+ let (on_key, color_key, label, icon, tab) = if row == 0 {
+ ("shadingOn", "shadingColor", "Shading", "para-shading", "shading")
+ } else {
+ ("borderOn", "borderColor", "Border", "para-border", "border")
+ };
+ let mut on = para[on_key].as_bool().unwrap_or(false);
+ ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+ ui.set_min_width(check_w);
+ if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() {
+ shade = Some(json!({ on_key: on }));
+ }
+ });
+ if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() {
+ dialog = Some(tab);
+ }
+ if let Some(doc) = &doc {
+ let cur = para[color_key].as_str().unwrap_or("[Black]").to_string();
+ if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) {
+ shade = Some(json!({ color_key: w }));
+ }
+ }
+ });
+ if let Some(attrs) = shade {
+ let _ = app.run("type.para", json!({ "attrs": attrs }));
+ }
+ if let Some(tab) = dialog {
+ let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab }));
+ }
+ vsep(ui);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 3.0;
let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string();
@@ -931,6 +991,35 @@ fn control_paragraph(
});
}
+/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name,
+/// and the document's swatches in its menu. Returns the swatch picked.
+fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click());
+ let resp = resp.on_hover_text(label);
+ resp.widget_info(|| {
+ let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label);
+ info.current_text_value = Some(cur.to_string());
+ info
+ });
+ let p = ui.painter();
+ p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input });
+ p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
+ let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max);
+ p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border));
+ icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon);
+ let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0);
+ let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0));
+ crate::widgets::paint_chip(p, chip, c, g);
+ let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y));
+ p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text);
+ let mut picked = None;
+ egui::Popup::menu(&resp).show(|ui| {
+ picked = crate::panels::swatch_list(ui, doc, cur);
+ });
+ picked
+}
+
/// The widest a document tab grows: a longer name is shortened so its close button and the
/// tabs after it stay in view.
const DOC_TAB_MAX_W: f32 = 260.0;
@@ -1719,6 +1808,57 @@ mod tests {
assert!(has(&h, "Small Caps"));
}
+ /// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List
+ /// buttons (with the default hanging indent), and Shading and Border with their colours and
+ /// the Paragraph Borders and Shading dialog.
+ #[test]
+ fn the_paragraph_control_panel_has_lists_shading_and_border() {
+ let mut app = app();
+ app.run("file.new", serde_json::json!({})).unwrap();
+ let f = app
+ .run(
+ "frame.create",
+ serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs
+1 cup milk"}),
+ )
+ .unwrap();
+ app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
+ app.session.set_tool("type");
+ let mut h = test_window::open(app, vec2(2400.0, 700.0));
+ let bar = test_window::panel_rect(&h, "control_bar");
+ let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y);
+ let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| {
+ let r = h
+ .query_all_by_label(label)
+ .map(|n| n.rect())
+ .filter(|r| in_bar(*r))
+ .nth(nth)
+ .unwrap_or_else(|| panic!("no {label:?} in the Control panel"));
+ h.hover_at(r.center());
+ h.drag_at(r.center());
+ h.drop_at(r.center());
+ h.run_steps(3);
+ };
+ click(&mut h, "Paragraph Formatting Controls", 0);
+ click(&mut h, "Bulleted List", 0);
+ let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
+ assert_eq!(
+ (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()),
+ (Some("bullets"), Some(18.0), Some(-18.0))
+ );
+ click(&mut h, "Numbered List", 0);
+ assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers");
+ click(&mut h, "Shading", 0);
+ click(&mut h, "Border", 0);
+ let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
+ assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true)));
+ assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour");
+ // The icon beside Border opens the dialog on its Border tab.
+ click(&mut h, "Paragraph Borders and Shading…", 1);
+ let d = h.state().app.ui.dialog.clone().expect("the dialog");
+ assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border"));
+ }
+
#[test]
fn the_page_tool_has_its_own_control_panel() {
// Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 446b412fe..f33e40184 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -1046,6 +1046,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]);
}
}
+ // Paragraph shading: a square with a checked fill (the Control panel's Shading).
+ "para-shading" => {
+ pen.w = 1.0;
+ pen.rect(4.0, 4.0, 16.0, 16.0);
+ pen.frect(4.0, 4.0, 10.0, 10.0);
+ pen.frect(10.0, 10.0, 16.0, 16.0);
+ }
+ // Paragraph border: a box with its corner drawn heavy (the Control panel's Border).
+ "para-border" => {
+ pen.w = 1.0;
+ pen.rect(4.0, 4.0, 16.0, 16.0);
+ pen.w = 2.5;
+ pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]);
+ }
"pilcrow-menu" => {
pen.text(9.0, 10.0, "¶", 14.0);
pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]);
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 70e42d176..b1facbefd 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -26,7 +26,7 @@ basics of editing fail for too many users on Windows and Linux.
| Area | InDesign 2026 | DesignCraft | Status | Evidence |
|---|---|---|---|---|
-| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls, and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 |
+| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls (¶ with the Bulleted and Numbered List buttons, Shading and Border), and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 |
| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16; `properties.rs` `more_options_expand_character_and_paragraph_in_place` |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
From 0fc845750aafaf596591f3e48e2eac1632b0e943 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Wed, 21 Oct 2026 20:04:12 +0500
Subject: [PATCH 029/135] An underline runs across a tab, as the rest of the
line's text (#1466)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A tab draws nothing, so it was left out of the underline and strikethrough
bars: they stopped at the last letter before each tab and began again after it.
Forms rule their fill-in lines with underlined tabs — `Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥` —
so an imported form lost every writing line, and an IDML `Underline="true"` on
a run of tabs drew nothing at all.
A tab is a character with an advance, and InDesign rules its whole width.
`PlacedGlyph::spans_rules` says which glyphs a rule runs across: the drawn
ones, and a tab or a right-indent tab that advances. The spaces a line break
trimmed off the end, the break characters and the markers' carriers are not
part of the line's text and are still skipped, so an underline stops at the
text and not at the frame edge.
Both places that draw the bars from the glyphs use it: the renderer
(`build_line`) and the PDF export (`text_rules`). The HTML and PPTX exports
mark up runs of text, which already included their tabs.
Measured on the repro in the issue, a 420×120 pt page at 2×:
before one underline, 63 px: the word "Name"
now three, 63 + 219 + 231 px: the word and both tab stretches
and the exported PDF gains the two fills for them. A tab with a leader is one
continuous bar across the leader dots to the right-aligned text after it.
Tests: the renderer's bars and the PDF export's bars for "NameCity" are
one bar covering the tab's width, not two with a gap where it is. Both fail
without the change (two bars, 36→67.7 and 72→93.2, the tab's 67.7–72 unruled).
---
crates/compose/src/lib.rs | 14 ++++++++++++
crates/pdf/src/text.rs | 38 ++++++++++++++++++++++++++++++-
crates/render/src/text.rs | 48 ++++++++++++++++++++++++++++++++++++++-
3 files changed, 98 insertions(+), 2 deletions(-)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 4a2c66397..8188ea290 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
}
impl PlacedGlyph {
+ /// Whether an underline or a strikethrough runs across this glyph.
+ ///
+ /// A tab is not drawn, but it is a character with an advance, and the rule runs across its
+ /// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
+ /// without this they lost every writing line (#1466). The spaces a line break trimmed off the
+ /// end, the break characters themselves and the markers' carriers are not part of the line's
+ /// text and are not spanned.
+ pub fn spans_rules(&self) -> bool {
+ if self.visible {
+ return true;
+ }
+ self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
+ }
+
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index 300a47d93..1aaf435d9 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option Vec<(designcraft_compose::Rule, Rect)> {
let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
for line in &ft.lines {
- for g in line.glyphs.iter().filter(|g| g.visible) {
+ for g in line.glyphs.iter().filter(|g| g.spans_rules()) {
let style = &cs.styles[g.style as usize];
let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
if !on {
@@ -638,3 +638,39 @@ fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath {
}
bp
}
+
+#[cfg(test)]
+mod rule_tests {
+ use super::*;
+ use designcraft_compose::Cache;
+ use designcraft_doc::{Document, ParaFormat, SpreadRef};
+ use designcraft_geom::Rect;
+
+ /// A tab draws nothing but advances to its stop, and the underline runs across it, as it does
+ /// on screen: a form's fill-in lines are underlined tabs (#1466).
+ #[test]
+ fn an_underline_runs_across_a_tab() {
+ let mut doc = Document::new(&Default::default());
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = Some(true);
+ }
+ let layer = doc.default_layer();
+ let (_, sid) =
+ doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 300.0), layer, "Name\tCity", ParaFormat::default()).unwrap();
+ let cs = Cache::new().get(&doc, sid, None);
+ let line = &cs.frames[0].lines[0];
+ let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
+ let bars = text_rules(&cs, &cs.frames[0], true);
+ assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
+ let bar = bars[0].1;
+ assert!(bar.x0 <= tab.x + 0.5 && bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab: {bar:?}, tab {} + {}", tab.x, tab.adv);
+ // The strikethrough of the same text behaves the same way.
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = None;
+ style.chars.strikethrough = Some(true);
+ }
+ let cs = Cache::new().get(&doc, sid, None);
+ assert!(text_rules(&cs, &cs.frames[0], true).is_empty(), "no underline now");
+ assert_eq!(text_rules(&cs, &cs.frames[0], false).len(), 1, "one strike across the line");
+ }
+}
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index 70f31b4f6..a4fa80ad0 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -162,7 +162,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
- if !g.visible {
+ if !g.spans_rules() {
continue;
}
let style = &cs.styles[g.style as usize];
@@ -185,6 +185,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
}
}
}
+ // A tab carries the rule across its width but draws nothing itself.
+ if !g.visible {
+ continue;
+ }
let outline = db.outline(&g.face, g.gid);
if outline.elements().is_empty() {
continue;
@@ -725,3 +729,45 @@ mod cache_identity_tests {
assert_eq!(glyphs.lines[0].runs, runs);
}
}
+
+#[cfg(test)]
+mod rule_tests {
+ use super::*;
+
+ /// A tab is not drawn, but it is a character with an advance, and InDesign underlines it:
+ /// forms rule their fill-in lines with underlined tabs (`Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥`). The bars
+ /// stopped at the last letter before each tab, so an imported form lost every writing line
+ /// (#1466).
+ #[test]
+ fn an_underline_runs_across_a_tab() {
+ let mut doc = designcraft_doc::Document::new(&Default::default());
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = Some(true);
+ }
+ let layer = doc.default_layer();
+ let (_, sid) = doc
+ .add_text_frame(
+ designcraft_doc::SpreadRef::Doc(0),
+ Rect::new(36.0, 36.0, 400.0, 300.0),
+ layer,
+ "Name\tCity",
+ designcraft_doc::ParaFormat::default(),
+ )
+ .unwrap();
+ let owner = crate::Cache::new();
+ let cs = owner.get(&doc, sid, None);
+ let line = &cs.frames[0].lines[0];
+ let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
+ assert!(!tab.visible, "a tab draws nothing itself");
+ assert!(tab.adv > 1.0, "it advances to its stop: {}", tab.adv);
+
+ let mut cache = GlyphCache::default();
+ cache.tick();
+ let glyphs = cache.get(&cs, &cs.frames[0]);
+ let bars = &glyphs.lines[0].underlines;
+ assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
+ let bar = bars[0].2;
+ assert!(bar.x0 <= tab.x + 0.5, "the bar starts at the text, not at the tab: {bar:?}");
+ assert!(bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab's width: {bar:?}, tab {} + {}", tab.x, tab.adv);
+ }
+}
From c71bf1a32c65e6a5c53c9ab0a5d4346f1aab8a79 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 01:12:32 +0200
Subject: [PATCH 030/135] List buttons: pressed when every targeted paragraph
has the list; off takes only its own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Session::selection_has_list` checks every paragraph the list commands apply
to (selected text, cells or text frames). Type › Apply Bullets / Numbers and
Properties' Bulleted and Numbered List buttons both use it, so a selection
reaching an unbulleted paragraph isn't shown pressed. An explicit `on: false`
leaves paragraphs with the other list type, and their hanging indent, alone.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/lists.rs | 72 +++++++++++++++++++++----
crates/ui-egui/src/menus.rs | 14 ++---
crates/ui-egui/src/panels/properties.rs | 13 +++--
3 files changed, 74 insertions(+), 25 deletions(-)
diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs
index cad62f271..cf89273f4 100644
--- a/crates/engine/src/cmd/lists.rs
+++ b/crates/engine/src/cmd/lists.rs
@@ -85,22 +85,18 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
if targets.is_empty() {
return Err(bad(name, "select text or a text frame"));
}
- let d = &s.doc()?.doc;
// Off when every selected paragraph already has this list, as the button shows it pressed.
- let on = on.unwrap_or_else(|| {
- !targets.iter().all(|t| {
- d.text_story(t.story, t.cell).is_some_and(|st| {
- let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start)));
- st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
- })
- })
- });
+ let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)));
s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
st.format_paras(t.range.clone(), |pf| {
let (now, _) = styles.resolve_para(pf);
+ // Off takes away this list only: a paragraph with the other list keeps it.
+ if !on && now.list_type != kind {
+ return;
+ }
let (style, _) = styles.resolve_para_style(&pf.style);
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
@@ -121,6 +117,26 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
})
}
+/// Whether every paragraph of `targets` has list `kind`.
+fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
+ targets.iter().all(|t| {
+ d.text_story(t.story, t.cell).is_some_and(|st| {
+ let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start)));
+ st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
+ })
+ })
+}
+
+impl Session {
+ /// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows
+ /// pressed: every paragraph the list commands apply to has list `kind`, through the selected
+ /// text, table cells or text frames.
+ pub fn selection_has_list(&self, kind: ListType) -> bool {
+ let targets = super::text::format_targets_pub(self);
+ !targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))
+ }
+}
+
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -175,6 +191,44 @@ mod tests {
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
}
+ /// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted
+ /// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own
+ /// list only: a bulleted paragraph keeps bullets and indent when Numbers go off.
+ #[test]
+ fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() {
+ use designcraft_doc::ListType;
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
+ let sid = f["story"].as_u64().unwrap();
+ let select = |s: &mut Session, a: usize, b: usize| {
+ s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
+ };
+ select(&mut s, 0, 3);
+ s.execute("list.bullets", &json!({})).unwrap();
+ assert!(s.selection_has_list(ListType::Bullets));
+ assert!(!s.selection_has_list(ListType::Numbers));
+ // From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both.
+ select(&mut s, 0, 7);
+ assert!(!s.selection_has_list(ListType::Bullets));
+ // The frame selected: its whole story, Flour unbulleted.
+ s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap();
+ assert!(!s.selection_has_list(ListType::Bullets));
+ // Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent.
+ select(&mut s, 5, 7);
+ s.execute("list.numbers", &json!({"on": true})).unwrap();
+ select(&mut s, 0, 7);
+ s.execute("list.numbers", &json!({"on": false})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
+ let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0;
+ assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0));
+ assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0));
+ // With no text or frame selected, nothing is pressed.
+ s.execute("selection.set", &json!({"ids": []})).unwrap();
+ assert!(!s.selection_has_list(ListType::Bullets));
+ }
+
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index dde5ca033..0bdae7d1e 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -2380,18 +2380,10 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option {
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
- // Pressed, as the Bulleted and Numbered List buttons: the selected paragraphs have it.
+ // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies
+ // to (selected text, cells or text frames) has it.
"list.bullets" | "list.numbers" => {
- let kind = if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers };
- app.session
- .active()
- .and_then(|st| {
- let t = st.selection.text?;
- let story = st.doc.text_story(t.story, t.cell)?;
- let pf = story.paras.get(story.para_at(t.range().start))?;
- Some(st.doc.styles.resolve_para(pf).0.list_type == kind)
- })
- .unwrap_or(false)
+ app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers })
}
"window.controlBar" => app.ui.control_bar,
"window.split" => app.split,
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 79280f66f..f3d31c584 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1362,15 +1362,18 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) {
}
fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
- let Some(a) = text_attrs(app) else { return };
- let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string();
+ if text_attrs(app).is_none() {
+ return;
+ }
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
- // As InDesign's: a list with the default hanging indent, or (pressed) no list.
+ // As InDesign's: a list with the default hanging indent, or (pressed: every selected
+ // paragraph has it, as Type › Apply Bullets shows) no list.
+ use designcraft_doc::ListType;
for (icon, kind, cmd, tip) in
- [("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")]
+ [("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")]
{
- let on = lt == kind;
+ let on = app.session.selection_has_list(kind);
if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run(cmd, json!({"on": !on}));
}
From 874d2be248f046402172eaa0a83711cdbacd69c8 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 01:20:59 +0200
Subject: [PATCH 031/135] Control panel and Properties: a negative Last Line
Right Indent; list buttons pressed by every paragraph
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Last Line Right Indent field takes negative values, as the First Line
Left Indent does. The Control panel's Bulleted and Numbered List buttons
show pressed through `Session::selection_has_list`, as Type › Apply Bullets
and Properties do. With #1483's fix merged, an explicit off no longer takes
the other list type away.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 38 +++++++++++++++++++------
crates/ui-egui/src/panels/properties.rs | 3 +-
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 3af8e9b91..62b47cd7a 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -873,7 +873,8 @@ fn control_paragraph(
set = Some((ka, v));
}
} else {
- let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ // The first and last line indents may be negative (a hanging line), as InDesign's.
+ let min = if matches!(kb, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 };
if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) {
set = Some((kb, v));
}
@@ -913,16 +914,13 @@ fn control_paragraph(
}
vsep(ui);
// Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or,
- // pressed, none).
- let lt = para["listType"].as_str().unwrap_or("none").to_string();
+ // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows.
+ use designcraft_doc::ListType;
+ let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k));
let mut list = None;
rows(ui, |ui, row| {
- let (icon, kind, cmd, tip) = if row == 0 {
- ("bullets", "bullets", "list.bullets", "Bulleted List")
- } else {
- ("numbering", "numbers", "list.numbers", "Numbered List")
- };
- let on = lt == kind;
+ let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") };
+ let on = pressed.get(row).copied().unwrap_or(false);
if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() {
list = Some((cmd, !on));
}
@@ -1857,6 +1855,28 @@ mod tests {
click(&mut h, "Paragraph Borders and Shading…", 1);
let d = h.state().app.ui.dialog.clone().expect("the dialog");
assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border"));
+ // The Last Line Right Indent takes a negative value, as the First Line Left Indent does.
+ h.state_mut().app.ui.dialog = None;
+ h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
+ h.state_mut().app.session.set_tool("type");
+ h.run_steps(3);
+ // The field is labelled by its icon.
+ let at = h
+ .query_all_by_label("Last Line Right Indent")
+ .filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput)
+ .map(|n| n.rect())
+ .find(|r| in_bar(*r))
+ .expect("the last line indent")
+ .center();
+ h.hover_at(at);
+ h.drag_at(at);
+ h.drop_at(at);
+ h.run_steps(2);
+ h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
+ h.event(egui::Event::Text("-0p6".into()));
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0));
}
#[test]
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index c1f2f23a1..9d4052a87 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1497,7 +1497,8 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) {
for (i, (l, r)) in rows.into_iter().enumerate() {
let y = i as f32 * (FIELD_H + 5.0);
for (right, (icon, tip, key)) in [(false, l), (true, r)] {
- let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ // The first and last line indents may be negative (a hanging line), as InDesign's.
+ let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 };
let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: v}}));
From aa1a0a73f3ab73fcf22a33a54d4f7c740735c3d7 Mon Sep 17 00:00:00 2001
From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com>
Date: Wed, 21 Oct 2026 23:45:41 +0200
Subject: [PATCH 032/135] Gradient panel: drag the gradient onto the Swatches
panel to keep it
A gradient made in the Gradient panel stayed unnamed: there was no way
to drag it into the Swatches panel for reuse. The panel's thumbnail now
drags the selection's gradient, and the Swatches list takes the drop as
a new gradient swatch (swatch.new, "New Gradient Swatch"). A gradient
that already is a swatch is left as it is.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/panels/swatches.rs | 67 +++++++++++++++++++++++++--
1 file changed, 64 insertions(+), 3 deletions(-)
diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs
index 3e7030e87..e5e9a934c 100644
--- a/crates/ui-egui/src/panels/swatches.rs
+++ b/crates/ui-egui/src/panels/swatches.rs
@@ -18,8 +18,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
- for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
- swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
+ // A gradient dragged from the Gradient panel's thumbnail is dropped on the list.
+ let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| {
+ for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
+ swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
+ }
+ });
+ // An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one.
+ if let Some(g) = dropped
+ && doc.swatch(&g.0).is_some_and(|w| w.hidden)
+ {
+ let _ = app.run("swatch.new", json!({"swatch": g.0}));
}
for g in &doc.color_groups {
let open_id = egui::Id::new(("color_group_open", &g.name));
@@ -575,6 +584,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
#[derive(Clone, Debug)]
pub struct SwatchDrag(pub String);
+/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the
+/// Swatches panel takes it as a new gradient swatch.
+#[derive(Clone, Debug)]
+pub struct GradientDrag(pub String);
+
/// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK).
fn color_json(c: &designcraft_color::Color) -> serde_json::Value {
use designcraft_color::Color;
@@ -680,8 +694,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let full = ui.available_width().max(60.0);
ui.add_enabled_ui(has_item, |ui| {
ui.horizontal(|ui| {
- let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click());
+ let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag());
thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border);
+ // Drag the gradient onto the Swatches panel to keep it as a swatch.
+ if is_gradient {
+ resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone()));
+ if resp.dragged()
+ && let Some(p) = ui.ctx().pointer_interact_pos()
+ {
+ let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag")));
+ thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border);
+ }
+ }
// Clicking the thumbnail applies the gradient shown (InDesign's way to start one).
if resp.clicked() && !is_gradient {
send = Some(json!({}));
@@ -1025,6 +1049,43 @@ mod gradient_panel_tests {
assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0));
assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset);
}
+
+ /// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel,
+ /// which keeps it as a gradient swatch.
+ #[test]
+ fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() {
+ use egui::{Event, Modifiers, PointerButton};
+ let mut app = app_with_frame();
+ app.run("object.gradient", json!({})).unwrap();
+ app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
+ app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap();
+ let named =
+ |app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::>();
+ let before = named(&app);
+ let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
+ h.run_steps(6);
+ // The thumbnail sits left of the Type row.
+ let ty = h.get_by_label("Type:").rect();
+ let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0);
+ let to = h.get_by_label("[Black]").rect().center();
+ h.event(Event::PointerMoved(from));
+ h.step();
+ h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+ h.step();
+ for i in 1..=10 {
+ h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0)));
+ h.step();
+ }
+ h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
+ h.run_steps(4);
+ let after = named(&h.state().app);
+ assert_eq!(after.len(), before.len() + 1);
+ assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
+ let d = &h.state().app.session.doc().unwrap().doc;
+ assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
+ let id = h.state().app.session.doc().unwrap().selection.items[0];
+ assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
+ }
}
#[cfg(test)]
From dcdabf7f3d9db9fae5a5194a39a0156762654977 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 10:05:00 +0200
Subject: [PATCH 033/135] Properties: the Last Line Right Indent may be
negative
`para_field_min` gives the first and last line indents a negative minimum, as
InDesign and the Paragraph panel allow; the other paragraph fields stay at 0.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/panels/properties.rs | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 9a8cc1133..e6250abfd 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1525,7 +1525,7 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) {
for (i, (l, r)) in rows.into_iter().enumerate() {
let y = i as f32 * (FIELD_H + 5.0);
for (right, (icon, tip, key)) in [(false, l), (true, r)] {
- let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ let min = para_field_min(key);
let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: v}}));
@@ -3065,6 +3065,12 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String {
if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) }
}
+/// The least value a paragraph field takes: the first and last line indents may be negative (a
+/// hanging line, a last line past the right indent), as in InDesign; the others not.
+pub(crate) fn para_field_min(key: &str) -> f64 {
+ if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }
+}
+
#[cfg(test)]
mod tests {
use egui_kittest::{Harness, kittest::Queryable};
@@ -3139,6 +3145,18 @@ mod tests {
}
/// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel.
+ /// The first and last line indents take negative values, as InDesign's (and the Paragraph
+ /// panel's) do; the left and right indents and the spacing don't.
+ #[test]
+ fn last_line_indent_may_be_negative() {
+ for key in ["firstLineIndent", "lastLineIndent"] {
+ assert!(super::para_field_min(key) < 0.0, "{key}");
+ }
+ for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] {
+ assert_eq!(super::para_field_min(key), 0.0, "{key}");
+ }
+ }
+
#[test]
fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
From 047941bc9d53890a018f871c0ea16e8811962bea Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 10:07:31 +0200
Subject: [PATCH 034/135] Properties and Control panel: the Last Line Right
Indent may be negative
Both read their minimum from `para_field_min`: the first and last line
indents may be negative, as InDesign and the Paragraph panel allow.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 2 +-
crates/ui-egui/src/panels/properties.rs | 20 +++++++++++++++++++-
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 690d8fc32..455c909f5 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -873,7 +873,7 @@ fn control_paragraph(
set = Some((ka, v));
}
} else {
- let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ let min = crate::panels::properties::para_field_min(kb);
if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) {
set = Some((kb, v));
}
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 2814cb1fa..557db027e 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1528,7 +1528,7 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) {
for (i, (l, r)) in rows.into_iter().enumerate() {
let y = i as f32 * (FIELD_H + 5.0);
for (right, (icon, tip, key)) in [(false, l), (true, r)] {
- let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 };
+ let min = para_field_min(key);
let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: v}}));
@@ -3068,6 +3068,12 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String {
if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) }
}
+/// The least value a paragraph field takes: the first and last line indents may be negative (a
+/// hanging line, a last line past the right indent), as in InDesign; the others not.
+pub(crate) fn para_field_min(key: &str) -> f64 {
+ if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }
+}
+
#[cfg(test)]
mod tests {
use egui_kittest::{Harness, kittest::Queryable};
@@ -3142,6 +3148,18 @@ mod tests {
}
/// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel.
+ /// The first and last line indents take negative values, as InDesign's (and the Paragraph
+ /// panel's) do; the left and right indents and the spacing don't.
+ #[test]
+ fn last_line_indent_may_be_negative() {
+ for key in ["firstLineIndent", "lastLineIndent"] {
+ assert!(super::para_field_min(key) < 0.0, "{key}");
+ }
+ for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] {
+ assert_eq!(super::para_field_min(key), 0.0, "{key}");
+ }
+ }
+
#[test]
fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
From fa81009be2450dcfa10ea3b858f5139d6f13a1ab Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:22:08 +0200
Subject: [PATCH 035/135] Keep a literal ':' in style names through an IDML
round trip
Found while testing crates/idml/src/names.rs:148-155
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/idml/src/names.rs | 27 ++++++++++++++++++++++++---
crates/idml/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+), 3 deletions(-)
diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs
index 569244470..b9102c9ae 100644
--- a/crates/idml/src/names.rs
+++ b/crates/idml/src/names.rs
@@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl
pub const OBJECT_BUILTINS: &[(&str, &str)] =
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
-/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
+/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:`
+/// or `\\`, so it is not read back as a group separator).
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
- builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
+ builtins
+ .iter()
+ .find(|(o, _)| *o == name)
+ .map(|(_, i)| i.to_string())
+ .unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":"))
}
/// IDML `Name` → our style name.
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
return o.to_string();
}
- name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
+ let name = name.strip_prefix("$ID/").unwrap_or(name);
+ let mut out = String::with_capacity(name.len());
+ let mut chars = name.chars();
+ while let Some(c) = chars.next() {
+ match c {
+ '\\' => match chars.clone().next() {
+ Some(next @ ('\\' | ':')) => {
+ out.push(next);
+ chars.next();
+ }
+ _ => out.push('\\'),
+ },
+ ':' => out.push('/'),
+ _ => out.push(c),
+ }
+ }
+ out
}
/// `ParagraphStyle/…` Self id for one of our names.
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 37fb2af50..d7106ad48 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -4368,3 +4368,42 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
}
+
+#[test]
+fn style_names_with_a_literal_colon_survive_a_round_trip() {
+ use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle};
+ let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"];
+ let mut d = small_doc();
+ for n in names {
+ let st = d.styles_mut();
+ st.paragraph.push(ParagraphStyle {
+ name: n.into(),
+ based_on: None,
+ next_style: None,
+ para: Default::default(),
+ chars: Default::default(),
+ shortcut: String::new(),
+ });
+ st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() });
+ st.cell.push(CellStyle { name: n.into(), ..Default::default() });
+ st.table.push(TableStyle { name: n.into(), ..Default::default() });
+ st.object.push(ObjectStyle { name: n.into(), ..Default::default() });
+ }
+ let back = import_idml(&export_idml(&d)).unwrap();
+ for n in names {
+ assert!(
+ back.styles.paragraph.iter().any(|s| s.name == n),
+ "paragraph {n:?}: {:?}",
+ back.styles.paragraph.iter().map(|s| &s.name).collect::>()
+ );
+ assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}");
+ assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}");
+ assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}");
+ assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}");
+ }
+ // A group is still written as an IDML `:` group path.
+ assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child");
+ for n in names {
+ assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n);
+ }
+}
From 35e5b39874a40550404c61337f5e0ee3539a24d0 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:40:18 +0000
Subject: [PATCH 036/135] Clamp text frame column count before narrowing to u32
Found while testing crates/engine/src/cmd/object.rs:2519
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/object.rs | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 82f33a6d8..54fa18d31 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -2516,7 +2516,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
- o.columns = (v as u32).clamp(1, 40);
+ o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
@@ -4497,6 +4497,21 @@ mod column_rule_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
+
+ /// A column count past `u32::MAX` was cast to `u32` before the clamp, so
+ /// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
+ #[test]
+ fn huge_column_count_clamps_to_the_maximum() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
+ let id = ItemId(r["id"].as_u64().unwrap());
+ let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
+ for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
+ s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
+ assert_eq!(columns(&s), want, "columns {given}");
+ }
+ }
}
#[cfg(test)]
From 3bdd70a9758d0b47341f310f298b0181226933ba Mon Sep 17 00:00:00 2001
From: bflatastic
Date: Thu, 22 Oct 2026 17:57:35 +0700
Subject: [PATCH 037/135] README: the app icon above the title
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 4ca03d4dc..cd0f56479 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
DesignCraft
From a473793c07c622b76915a417311808652d25f1a6 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 15:59:33 +0500
Subject: [PATCH 038/135] Rotated cell text is settable and reported, not
silently flattened (#1468)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A cell's text rotation (Cell Options › Text › Rotation) was already read from
IDML and written back, but only 0 is composed, so an imported form's narrow
header columns of vertical labels came in horizontal and wrapped mid-word
("Eingela / ngt am") — and nothing anywhere said why. The reporter's file has
30 such cells.
Composing rotated cell text is a larger change than this. What this does is
stop it being silent, and let the value be set from the API:
- the IDML import reports it once per file: "2 table cell(s) have rotated text
(Cell Options › Text › Rotation): the angle is kept and written back, but the
text is composed horizontally";
- `table.setCell` takes `rotation` (0, 90, 180 or 270, any other angle refused,
negatives and multiples normalised), which nothing could set before;
- `table.get` reports each cell's rotation, so a round trip can be checked.
Tests: the angle through an export and back with the warning counted once for
the file (and no warning when no cell is rotated), and the command's accepted
and refused angles. Each fails without its own part.
---
crates/engine/src/cmd/table.rs | 26 ++++++++++++++++++++++++--
crates/idml/src/import.rs | 14 ++++++++++++++
crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++
3 files changed, 70 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index 7049c2f39..7e8bcc6cb 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -297,7 +297,7 @@ fn table_specs() -> Vec {
"Cell Options",
[],
None,
- "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
+ "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
+ // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
+ // composed so far, which the import says and preflight reports (#1468).
+ let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
+ Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
+ Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
+ _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
+ },
+ None => None,
+ };
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
+ if let Some(v) = rotation {
+ cell.rotation = v;
+ }
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
- "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
+ "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1832,6 +1844,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
+ // Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
+ // which the IDML import reports (#1468).
+ for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
+ s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
+ let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
+ assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
+ }
+ for bad in [45, 1, 100] {
+ assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
+ }
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index d770aef79..f9cfed0e2 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -295,6 +295,10 @@ struct Importer<'r> {
parents: Vec,
/// Things read but not kept as they were, for the import report.
warnings: Vec,
+ /// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
+ /// written back, but only 0 is composed, so a narrow header column of vertical labels comes
+ /// in horizontal and wraps mid-word (#1468).
+ rotated_cells: usize,
parent_ids: HashMap,
spreads: Vec,
page_index: HashMap,
@@ -536,6 +540,7 @@ impl<'r> Importer<'r> {
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
+ rotated_cells: 0,
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -791,6 +796,12 @@ impl<'r> Importer<'r> {
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
+ if self.rotated_cells > 0 {
+ let n = self.rotated_cells;
+ self.warnings.push(format!(
+ "{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
+ ));
+ }
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
@@ -2232,6 +2243,9 @@ impl<'r> Importer<'r> {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
+ if cell.rotation != 0.0 {
+ self.rotated_cells += 1;
+ }
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 035333da4..9fcbf2d4b 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -4234,3 +4234,35 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
}
+
+/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is
+/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word,
+/// and nothing said so (#1468).
+#[test]
+fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() {
+ let mut d = Document::new(&NewDocument::default());
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
+ let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0);
+ t.cell_mut(0, 0).unwrap().rotation = 90.0;
+ t.cell_mut(0, 1).unwrap().rotation = 270.0;
+ d.story_mut(sid).unwrap().insert_table(0, t);
+ let bytes = export_idml(&d);
+ let story = zip_text(&bytes, "Stories/");
+ assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}");
+
+ let back = import_idml_report(&bytes, &|_| None).unwrap();
+ let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone();
+ assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0));
+ let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect();
+ assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings);
+ assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]);
+ assert!(about[0].contains("composed horizontally"), "{}", about[0]);
+
+ // A file with no rotated cell says nothing.
+ let mut plain = Document::new(&NewDocument::default());
+ let (_, sid) =
+ plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap();
+ plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0));
+ let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap();
+ assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings);
+}
From e7ef29d9d1826add4ee8a55374898989ccaef4e1 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 16:18:33 +0500
Subject: [PATCH 039/135] A page named twice in an export list is exported once
(found while probing)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The same selection written two ways gave different files:
file.exportPdf {"pages": "2,2"} → 1 page
file.exportPdf {"pages": [2, 2]} → 2 pages, the second a copy
file.exportPdf {"pages": [2,2,2]} → 3 pages
`parse_page_range` keeps the first of a repeat (`"2-2,2"` is one page too); the
array form appended whatever it was given. A list says which pages to export,
not how many times, so the array now drops a repeat as the string does. Both
`file.exportPdf` and `file.exportPptx` read the same parameter.
The test pins the agreement between the two forms over nine lists, in PDF and
PPTX, and the two facts I measured while checking it: the pages come out in the
document's order whichever order the list names them in, and `[2, 2]` is now
byte for byte the file `"2"` gives. A page that does not exist is still
refused, and an empty list still exports nothing.
Not from a ticket: I found it probing the page-range parameter.
---
crates/engine/src/cmd/export.rs | 56 ++++++++++++++++++++++++++++++++-
1 file changed, 55 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs
index 36fd3d52a..9db85e7a9 100644
--- a/crates/engine/src/cmd/export.rs
+++ b/crates/engine/src/cmd/export.rs
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result= 1 && *n <= page_count)
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
- v.push(n - 1);
+ // A page named twice is one page, as `"2,2"` already is: the list says which
+ // pages to export, not how many times. `[2, 2]` exported two copies of page 2
+ // while `"2,2"` exported one.
+ if !v.contains(&(n - 1)) {
+ v.push(n - 1);
+ }
}
Some(v)
}
@@ -1326,3 +1331,52 @@ mod pptx_tests {
assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
+
+#[cfg(test)]
+mod page_list_tests {
+ use serde_json::{Value, json};
+
+ use crate::Session;
+
+ fn doc(pages: u64) -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
+ s
+ }
+
+ /// The same selection written two ways exported different files: `"2,2"` is one page, because
+ /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
+ /// The list says which pages to export, not how many times.
+ #[test]
+ fn a_page_named_twice_is_exported_once_either_way() {
+ let mut s = doc(4);
+ for (pages, want) in [
+ (json!("2,2"), 1),
+ (json!([2, 2]), 1),
+ (json!([2, 2, 2]), 1),
+ (json!("2-2,2"), 1),
+ (json!([1, 2, 1]), 2),
+ (json!("1,2,1"), 2),
+ (json!([4, 1]), 2),
+ (json!("4,1"), 2),
+ (json!("all"), 4),
+ ] {
+ let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
+ assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
+ let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
+ assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
+ }
+ // The two forms now agree byte for byte, and the pages come out in the document's order
+ // whichever order the list names them in (which is how the export already behaved).
+ let mut file = |pages: Value| {
+ let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
+ r["base64"].as_str().unwrap_or_default().to_string()
+ };
+ assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
+ assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
+ assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
+ // A page that does not exist is still refused, and an empty list still exports nothing.
+ assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
+ assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
+ }
+}
From 0a719bf88809d1d981c8290a0726f9305fe2f5a3 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 13:36:24 +0200
Subject: [PATCH 040/135] Hyphenation Settings: InDesign's dialog from the
Paragraph panel menu, and in Paragraph Style Options
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) opens
InDesign's Hyphenation Settings for the selected paragraphs: Hyphenate;
Words with at Least, After First and Before Last (letters), Hyphen Limit
(hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens slider in
InDesign's eleven steps; Hyphenate Capitalized Words, Last Word and Across
Column; Preview, which shows the edits without an undo step. OK applies them
as one step; mixed values show blank and are left alone unless edited.
Paragraph Style Options › Hyphenation uses the same form (it had only
Hyphenate and four numbers). The panel menu lists its items in InDesign's
order. The new strings are translated in the shared and per-language tables.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/dialogs.rs | 249 ++++++++++++++++++++++--
crates/ui-egui/src/i18n.rs | 27 +++
crates/ui-egui/src/i18n/ar.rs | 9 +
crates/ui-egui/src/i18n/cs.rs | 9 +
crates/ui-egui/src/i18n/de.rs | 9 +
crates/ui-egui/src/i18n/it.rs | 9 +
crates/ui-egui/src/i18n/ja.rs | 9 +
crates/ui-egui/src/i18n/ka.rs | 9 +
crates/ui-egui/src/i18n/pl.rs | 9 +
crates/ui-egui/src/i18n/pt_br.rs | 9 +
crates/ui-egui/src/i18n/ru.rs | 9 +
crates/ui-egui/src/i18n/uk.rs | 9 +
crates/ui-egui/src/menus.rs | 20 +-
crates/ui-egui/src/panels/properties.rs | 6 +-
15 files changed, 369 insertions(+), 24 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 8a2352e86..0b7d97994 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. Hyphenate Across Column is kept and round-trips through IDML, but composition doesn't apply it yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..999aed488 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -2061,6 +2061,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
},
),
"justification" => crate::i18n::tr(&app.ui.language, "Justification"),
+ "hyphenation" => crate::i18n::tr(&app.ui.language, "Hyphenation Settings"),
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
"findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"),
"polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"),
@@ -2102,6 +2103,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"alert" => 440.0,
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0,
"newDocument" => 960.0,
+ "hyphenation" => 560.0,
"numberingSection" => 560.0,
crate::effects_dialog::ID => 720.0,
_ => 640.0,
@@ -2354,6 +2356,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let current = d.fields.get("current").cloned().unwrap_or_default();
justification(&app.ui.language, ui, &mut d, "", ¤t);
}
+ "hyphenation" => {
+ let current = d.fields.get("current").cloned().unwrap_or_default();
+ let units = app.session.active().map_or(Unit::Picas, |st| st.doc.settings.horizontal_units);
+ let preview = crate::i18n::tr(&app.ui.language, "Preview").to_string();
+ ui.horizontal_top(|ui| {
+ ui.vertical(|ui| hyphenation(&app.ui.language, ui, &mut d, "", ¤t, units));
+ ui.add_space(24.0);
+ check(ui, &mut d, "preview", &preview);
+ });
+ hyphenation_preview(app, &mut d);
+ }
"insertXref" => insert_xref(app, ui, &mut d),
"findFont" => find_font(app, ui, &mut d),
"colorPicker" => color_picker(ui, &mut d),
@@ -3162,6 +3175,17 @@ pub fn confirm(app: &mut DesignApp) -> Result {
}
app.run("type.para", json!({"attrs": attrs}))
}
+ "hyphenation" => {
+ // The preview goes; the edits are made once, as one undo step.
+ if d.fields.contains_key(PREVIEWED) {
+ app.session.cancel_interaction();
+ }
+ let attrs = hyphenation_edits(&d);
+ if attrs.is_empty() {
+ return Ok(Value::Null);
+ }
+ app.run("type.para", json!({"attrs": attrs}))
+ }
"justification" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
if let Err(e) = check_justification(&d, "", ¤t) {
@@ -4171,27 +4195,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
"tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc),
"rules" => paragraph_rules(app, ui, d, "p.", &pv),
- "hyph" => {
- let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true);
- if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
- d.fields.insert("p.hyphenate".into(), json!(h));
- }
- egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
- for (label, key) in [
- ("Words with at Least:", "hyphMinWord"),
- ("After First:", "hyphAfterFirst"),
- ("Before Last:", "hyphBeforeLast"),
- ("Hyphen Limit:", "hyphLimit"),
- ] {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
- let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
- if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
- d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64));
- }
- ui.end_row();
- }
- });
- }
+ "hyph" => hyphenation(&app.ui.language, ui, d, "p.", &pv, units),
"justify" => justification(&app.ui.language, ui, d, "p.", &pv),
"color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc),
"advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
@@ -5785,6 +5789,133 @@ fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(),
/// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ
/// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields
/// edited.
+/// Paragraph attributes the Hyphenation Settings dialog sets.
+pub const HYPH_ATTRS: [&str; 10] = [
+ "hyphenate",
+ "hyphMinWord",
+ "hyphAfterFirst",
+ "hyphBeforeLast",
+ "hyphLimit",
+ "hyphZone",
+ "hyphWeight",
+ "hyphCapitalized",
+ "hyphLastWord",
+ "hyphAcrossColumn",
+];
+
+/// InDesign's Hyphenation Settings: Hyphenate; Words with at Least, After First and Before Last
+/// (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens
+/// slider (0–10, as InDesign's HyphenWeight); Hyphenate Capitalized Words, Last Word and Across
+/// Column. Edited fields are stored as `{prefix}{key}`; the others show `current` (a mixed value
+/// is null and shows blank). The Hyphenation dialog for the selected paragraphs and Paragraph
+/// Style Options › Hyphenation both use it.
+fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value, units: Unit) {
+ let tr = |s: &str| crate::i18n::tr(lang, s).to_string();
+ let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
+ // Labels right-aligned against their fields, as InDesign's.
+ let label_right = |ui: &mut egui::Ui, text: &str| {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend))
+ });
+ };
+ let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
+ let v = cur(d, key);
+ let mut on = v.as_bool().unwrap_or(true);
+ if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() {
+ d.fields.insert(format!("{prefix}{key}"), json!(on));
+ }
+ };
+ checkbox(ui, d, "hyphenate", "Hyphenate");
+ ui.add_space(4.0);
+ egui::Grid::new(("hyph", prefix)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
+ // InDesign's ranges.
+ for (label, key, unit, max) in [
+ ("Words with at Least:", "hyphMinWord", "letters", 25.0),
+ ("After First:", "hyphAfterFirst", "letters", 15.0),
+ ("Before Last:", "hyphBeforeLast", "letters", 15.0),
+ ("Hyphen Limit:", "hyphLimit", "hyphens", 25.0),
+ ] {
+ label_right(ui, &tr(label));
+ let min = if key == "hyphLimit" { 0.0 } else { 1.0 };
+ if let Some(n) = crate::widgets::spin_number(ui, &format!("hy{prefix}{key}"), cur(d, key).as_f64(), "", 60.0, 0) {
+ d.fields.insert(format!("{prefix}{key}"), json!(n.round().clamp(min, max) as u64));
+ }
+ crate::rtl::label(ui, tr(unit));
+ ui.end_row();
+ }
+ label_right(ui, &tr("Hyphenation Zone:"));
+ if let Some(n) = crate::widgets::spin_measure(ui, &format!("hy{prefix}zone"), cur(d, "hyphZone").as_f64(), units, 60.0) {
+ d.fields.insert(format!("{prefix}hyphZone"), json!(if n.is_finite() { n.clamp(0.0, 8640.0) } else { 36.0 }));
+ }
+ ui.end_row();
+ });
+ ui.add_space(6.0);
+ // Better Spacing ↔ Fewer Hyphens, in InDesign's eleven steps.
+ ui.horizontal(|ui| {
+ crate::rtl::label(ui, tr("Better Spacing"));
+ let w = cur(d, "hyphWeight").as_f64();
+ let mut step = w.map_or(5.0, |w| (w * 10.0).round()).clamp(0.0, 10.0);
+ // The ticks go under the knob: their place in the paint order is taken first.
+ let ticks = ui.painter().add(egui::Shape::Noop);
+ let r = ui
+ .scope(|ui| {
+ ui.spacing_mut().slider_width = 180.0;
+ ui.add(egui::Slider::new(&mut step, 0.0..=10.0).step_by(1.0).show_value(false).handle_shape(egui::style::HandleShape::Circle))
+ })
+ .inner;
+ // The tick marks under the track.
+ let t = crate::theme::Tokens::get(ui.ctx());
+ let inner = r.rect.shrink2(egui::vec2(r.rect.height() / 2.0, 0.0));
+ let y = r.rect.center().y;
+ let marks: Vec = (0..=10)
+ .map(|i| {
+ let x = inner.left() + inner.width() * i as f32 / 10.0;
+ egui::Shape::line_segment([egui::pos2(x, y - 8.0), egui::pos2(x, y + 8.0)], egui::Stroke::new(1.0, t.text_dim))
+ })
+ .collect();
+ ui.painter().set(ticks, egui::Shape::Vec(marks));
+ if r.changed() {
+ d.fields.insert(format!("{prefix}hyphWeight"), json!(step / 10.0));
+ }
+ crate::rtl::label(ui, tr("Fewer Hyphens"));
+ });
+ ui.add_space(6.0);
+ egui::Grid::new(("hyphc", prefix)).num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
+ checkbox(ui, d, "hyphCapitalized", "Hyphenate Capitalized Words");
+ checkbox(ui, d, "hyphLastWord", "Hyphenate Last Word");
+ ui.end_row();
+ checkbox(ui, d, "hyphAcrossColumn", "Hyphenate Across Column");
+ ui.end_row();
+ });
+}
+
+/// The Hyphenation dialog's edits, as `type.para` attrs.
+fn hyphenation_edits(d: &Dialog) -> Map {
+ HYPH_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect()
+}
+
+/// Preview on: the edits show on the page (redone when a field changes) without an undo step;
+/// off, the paragraphs go back.
+fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) {
+ let shown = d.fields.get(PREVIEWED).cloned();
+ if !d.b("preview") {
+ if shown.is_some() {
+ app.session.cancel_interaction();
+ d.fields.remove(PREVIEWED);
+ }
+ return;
+ }
+ let params = json!({"attrs": hyphenation_edits(d)});
+ if shown.as_ref() == Some(¶ms) {
+ return;
+ }
+ if shown.is_none() && app.session.begin_interaction("Hyphenation").is_err() {
+ return;
+ }
+ let _ = app.session.preview("type.para", params.clone());
+ d.fields.insert(PREVIEWED.into(), params);
+}
+
fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) {
let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -7606,6 +7737,82 @@ mod tests {
app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone()
}
+ /// The hyphenation video (Graphd Tutorials, 0:39–1:13): the Paragraph panel menu's
+ /// Hyphenation… opens InDesign's Hyphenation Settings for the selected paragraphs. Preview
+ /// shows the slider moved to Fewer Hyphens on the page without an undo step, OK makes it one
+ /// step on the selected paragraph only, and Cancel puts it back.
+ #[test]
+ fn hyphenation_settings_preview_apply_and_cancel_on_the_selected_paragraph() {
+ let (mut app, story) = two_paragraphs();
+ let ctx = dialog_context("en");
+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
+ let sid = designcraft_doc::StoryId(story.as_u64().unwrap());
+ let weight = |app: &DesignApp, para: usize| {
+ let d = &app.session.doc().unwrap().doc;
+ d.styles.resolve_para(&d.story(sid).unwrap().paras[para]).0.hyph_weight
+ };
+ assert!(crate::menus::menu_enabled(&app, "app.hyphenationDialog"));
+ app.run("app.hyphenationDialog", json!({})).unwrap();
+ for _ in 0..3 {
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ }
+ let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
+ let d = app.ui.dialog.clone().unwrap();
+ assert_eq!(
+ (d.id.as_str(), d.fields["current"]["hyphWeight"].as_f64(), d.fields["current"]["hyphenate"].as_bool()),
+ ("hyphenation", Some(0.5), Some(true))
+ );
+ for label in [
+ "Hyphenation Settings",
+ "Hyphenate",
+ "Words with at Least:",
+ "letters",
+ "Hyphen Limit:",
+ "hyphens",
+ "Hyphenation Zone:",
+ "Better Spacing",
+ "Fewer Hyphens",
+ "Hyphenate Capitalized Words",
+ "Hyphenate Last Word",
+ "Hyphenate Across Column",
+ "Preview",
+ ] {
+ assert!(labels.iter().any(|(t, _, _)| t == label), "{label}: {labels:?}");
+ }
+ // Fewer Hyphens (9 of 10) with Preview: on the page, no undo step.
+ let before = undo_steps(&app);
+ let d = app.ui.dialog.as_mut().unwrap();
+ d.fields.insert("hyphWeight".into(), json!(0.9));
+ d.fields.insert("preview".into(), json!(true));
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ assert_eq!(weight(&app, 0), 0.9, "previewed");
+ assert_eq!(undo_steps(&app), before, "a preview is no step");
+ // Cancel: back as it was.
+ cancel(&mut app);
+ assert_eq!(weight(&app, 0), 0.5);
+ // Again, and OK: one step, the selected paragraph only.
+ app.run("app.hyphenationDialog", json!({})).unwrap();
+ let d = app.ui.dialog.as_mut().unwrap();
+ d.fields.insert("hyphWeight".into(), json!(0.9));
+ d.fields.insert("preview".into(), json!(true));
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ confirm(&mut app).unwrap();
+ assert_eq!((weight(&app, 0), weight(&app, 1)), (0.9, 0.5));
+ assert_eq!(undo_steps(&app), before + 1, "one undo step");
+ // Paragraph Style Options › Hyphenation has the same form.
+ app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::story::BASIC_PARAGRAPH, "section": "hyph"})));
+ for _ in 0..3 {
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ }
+ let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
+ for label in ["Hyphenation Zone:", "Better Spacing", "Fewer Hyphens", "Hyphenate Last Word", "Hyphenate Across Column"] {
+ assert!(labels.iter().any(|(t, _, _)| t == label), "style options: {label}");
+ }
+ app.ui.dialog = None;
+ app.run("edit.deselectAll", json!({})).unwrap();
+ assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph");
+ }
+
#[test]
fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() {
let (mut app, story) = two_paragraphs();
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index acf70ff32..8b840fad4 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -1000,6 +1000,33 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),
+ ("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]),
+ (
+ "Hyphenation Settings",
+ ["Silbentrennungseinstellungen", "Paramètres de césure", "Configuración de separación por sílabas", "ハイフネーション設定", "连字设置"],
+ ),
+ ("hyphens", ["Trennstriche", "tirets", "guiones", "ハイフン", "连字符"]),
+ ("Hyphenation Zone:", ["Silbentrennungsbereich:", "Zone de césure :", "Zona de separación:", "ハイフネーション領域 :", "连字区:"]),
+ ("Better Spacing", ["Bessere Abstände", "Meilleur espacement", "Mejor espaciado", "スペースを優先", "更好的间距"]),
+ ("Fewer Hyphens", ["Weniger Trennstriche", "Moins de tirets", "Menos guiones", "ハイフンを減らす", "更少的连字符"]),
+ (
+ "Hyphenate Capitalized Words",
+ [
+ "Großgeschriebene Wörter trennen",
+ "Césure des mots en capitales",
+ "Separar palabras en mayúscula",
+ "大文字で始まる単語をハイフネーション",
+ "连字大写的单词",
+ ],
+ ),
+ (
+ "Hyphenate Last Word",
+ ["Letztes Wort trennen", "Césure du dernier mot", "Separar última palabra", "最後の単語をハイフネーション", "连字最后一个单词"],
+ ),
+ (
+ "Hyphenate Across Column",
+ ["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"],
+ ),
(
"Differs from this document",
["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"],
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index 31f7fb70d..c7145d909 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
+ ("Hyphenation…", "الواصلة…"),
+ ("Hyphenation Settings", "إعدادات الواصلة"),
+ ("hyphens", "واصلات"),
+ ("Hyphenation Zone:", "منطقة الواصلة:"),
+ ("Better Spacing", "تباعد أفضل"),
+ ("Fewer Hyphens", "واصلات أقل"),
+ ("Hyphenate Capitalized Words", "وصل الكلمات ذات الأحرف الكبيرة"),
+ ("Hyphenate Last Word", "وصل الكلمة الأخيرة"),
+ ("Hyphenate Across Column", "الوصل عبر الأعمدة"),
("Paste Vector Art", "لصق رسم متجه"),
("Baseline Options", "خيارات خط الأساس"),
("Fixed Number", "عدد ثابت"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index 4d41c30dc..c9f223542 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
+ ("Hyphenation…", "Dělení slov…"),
+ ("Hyphenation Settings", "Nastavení dělení slov"),
+ ("hyphens", "spojovníky"),
+ ("Hyphenation Zone:", "Zóna dělení:"),
+ ("Better Spacing", "Lepší mezery"),
+ ("Fewer Hyphens", "Méně spojovníků"),
+ ("Hyphenate Capitalized Words", "Dělit slova s velkými písmeny"),
+ ("Hyphenate Last Word", "Dělit poslední slovo"),
+ ("Hyphenate Across Column", "Dělit přes sloupce"),
("Reverse Path", "Obrátit cestu"),
("Set Anchor", "Nastavit kotvu"),
("Clear", "Vymazat"),
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index d47657b09..48a1a6001 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Ausschneiden"),
("Copy", "Kopieren"),
("Paste", "Einfügen"),
+ ("Hyphenation…", "Silbentrennung …"),
+ ("Hyphenation Settings", "Silbentrennungseinstellungen"),
+ ("hyphens", "Trennstriche"),
+ ("Hyphenation Zone:", "Silbentrennungsbereich:"),
+ ("Better Spacing", "Bessere Abstände"),
+ ("Fewer Hyphens", "Weniger Trennstriche"),
+ ("Hyphenate Capitalized Words", "Großgeschriebene Wörter trennen"),
+ ("Hyphenate Last Word", "Letztes Wort trennen"),
+ ("Hyphenate Across Column", "Über Spalten trennen"),
("Inches Decimal", "Zoll dezimal"),
("Back", "Zurück"),
("Warning", "Warnung"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index 755272fe5..65837d395 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
+ ("Hyphenation…", "Sillabazione…"),
+ ("Hyphenation Settings", "Impostazioni sillabazione"),
+ ("hyphens", "trattini"),
+ ("Hyphenation Zone:", "Area di sillabazione:"),
+ ("Better Spacing", "Spaziatura migliore"),
+ ("Fewer Hyphens", "Meno trattini"),
+ ("Hyphenate Capitalized Words", "Sillaba parole maiuscole"),
+ ("Hyphenate Last Word", "Sillaba ultima parola"),
+ ("Hyphenate Across Column", "Sillaba tra colonne"),
("Paste Vector Art", "Incolla grafica vettoriale"),
("Baseline Options", "Opzioni linea di base"),
("Fixed Number", "Numero fisso"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
index e23ef15c7..aa4fca1f6 100644
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -30,6 +30,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "カット"),
("Copy", "コピー"),
("Paste", "ペースト"),
+ ("Hyphenation…", "ハイフネーション…"),
+ ("Hyphenation Settings", "ハイフネーション設定"),
+ ("hyphens", "ハイフン"),
+ ("Hyphenation Zone:", "ハイフネーション領域 :"),
+ ("Better Spacing", "スペースを優先"),
+ ("Fewer Hyphens", "ハイフンを減らす"),
+ ("Hyphenate Capitalized Words", "大文字で始まる単語をハイフネーション"),
+ ("Hyphenate Last Word", "最後の単語をハイフネーション"),
+ ("Hyphenate Across Column", "段をまたいでハイフネーション"),
("Paste Vector Art", "ベクトルアートをペースト"),
("Baseline Options", "ベースラインオプション"),
("Column Rules", "段落境界線"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
index 0300b62d7..40abcf9ad 100644
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
+ ("Hyphenation…", "დამარცვლა…"),
+ ("Hyphenation Settings", "დამარცვლის პარამეტრები"),
+ ("hyphens", "დეფისი"),
+ ("Hyphenation Zone:", "დამარცვლის ზონა:"),
+ ("Better Spacing", "უკეთესი დაშორება"),
+ ("Fewer Hyphens", "ნაკლები დეფისი"),
+ ("Hyphenate Capitalized Words", "მთავრული სიტყვების დამარცვლა"),
+ ("Hyphenate Last Word", "ბოლო სიტყვის დამარცვლა"),
+ ("Hyphenate Across Column", "სვეტებს შორის დამარცვლა"),
("Inches Decimal", "ინჩები (ათობითი)"),
("Back", "უკან"),
("Warning", "გაფრთხილება"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
index 7d1d12c67..5429ccb67 100644
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
+ ("Hyphenation…", "Dzielenie wyrazów…"),
+ ("Hyphenation Settings", "Ustawienia dzielenia wyrazów"),
+ ("hyphens", "dywizy"),
+ ("Hyphenation Zone:", "Strefa dzielenia:"),
+ ("Better Spacing", "Lepsze odstępy"),
+ ("Fewer Hyphens", "Mniej dywizów"),
+ ("Hyphenate Capitalized Words", "Dziel wyrazy pisane wielką literą"),
+ ("Hyphenate Last Word", "Dziel ostatni wyraz"),
+ ("Hyphenate Across Column", "Dziel między łamami"),
("Clear", "Wyczyść"),
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index f552e9a63..4513a107f 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
+ ("Hyphenation…", "Hifenização…"),
+ ("Hyphenation Settings", "Configurações de hifenização"),
+ ("hyphens", "hifens"),
+ ("Hyphenation Zone:", "Zona de hifenização:"),
+ ("Better Spacing", "Melhor espaçamento"),
+ ("Fewer Hyphens", "Menos hifens"),
+ ("Hyphenate Capitalized Words", "Hifenizar palavras em maiúsculas"),
+ ("Hyphenate Last Word", "Hifenizar última palavra"),
+ ("Hyphenate Across Column", "Hifenizar entre colunas"),
("Paste Vector Art", "Colar arte vetorial"),
("Baseline Options", "Opções de linha de base"),
("Fixed Number", "Número fixo"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 3f6ef1be0..599537cac 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
+ ("Hyphenation…", "Расстановка переносов…"),
+ ("Hyphenation Settings", "Параметры расстановки переносов"),
+ ("hyphens", "дефисов"),
+ ("Hyphenation Zone:", "Зона переносов:"),
+ ("Better Spacing", "Лучшие интервалы"),
+ ("Fewer Hyphens", "Меньше переносов"),
+ ("Hyphenate Capitalized Words", "Переносить слова с прописной буквы"),
+ ("Hyphenate Last Word", "Переносить последнее слово"),
+ ("Hyphenate Across Column", "Переносить между колонками"),
("Clear", "Очистить"),
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
index 9c7cc855a..060a7d43b 100644
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
+ ("Hyphenation…", "Перенесення…"),
+ ("Hyphenation Settings", "Параметри перенесення"),
+ ("hyphens", "дефісів"),
+ ("Hyphenation Zone:", "Зона перенесення:"),
+ ("Better Spacing", "Кращі інтервали"),
+ ("Fewer Hyphens", "Менше перенесень"),
+ ("Hyphenate Capitalized Words", "Переносити слова з великої літери"),
+ ("Hyphenate Last Word", "Переносити останнє слово"),
+ ("Hyphenate Across Column", "Переносити між колонками"),
("Paste Vector Art", "Вставити векторну графіку"),
("Baseline Options", "Параметри базової лінії"),
("Fixed Number", "Фіксована кількість"),
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index fb43f8cb9..0f56aac76 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -109,6 +109,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
),
("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"),
("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"),
+ (
+ "app.hyphenationDialog",
+ "Hyphenation…",
+ None,
+ "{} — Hyphenation Settings of the selected paragraphs (type.para): limits, zone, Better Spacing ↔ Fewer Hyphens, Preview",
+ ),
(
"app.justificationDialog",
"Justification…",
@@ -1291,6 +1297,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
+ let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
+ return Some(Err("select text or a text frame".into()));
+ };
+ let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
+ let current: serde_json::Map = crate::dialogs::HYPH_ATTRS
+ .iter()
+ .map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() }))
+ .collect();
+ app.ui.dialog = Some(crate::dialogs::Dialog::new("hyphenation", json!({"current": current})));
+ Ok(Value::Null)
+ }
"app.justificationDialog" => {
let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
return Some(Err("select text or a text frame".into()));
@@ -2438,7 +2456,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if is_window_control(id) {
return window_control_available(app, id);
}
- if id == "app.justificationDialog" {
+ if matches!(id, "app.justificationDialog" | "app.hyphenationDialog") {
return enabled(app, "type.para");
}
if id.starts_with("fit.") {
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 1cbd623a6..d2c1cb95b 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1743,11 +1743,13 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::menus::menu_button(ui, "☰", |ui| {
+ // In the order of InDesign's Paragraph panel menu.
for (label, id) in [
- ("Paragraph Rules…", "app.paragraphRulesDialog"),
("Justification…", "app.justificationDialog"),
- ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
("Span Columns…", "app.spanColumnsDialog"),
+ ("Hyphenation…", "app.hyphenationDialog"),
+ ("Paragraph Rules…", "app.paragraphRulesDialog"),
+ ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
("Bullets and Numbering…", "app.bulletsNumberingDialog"),
] {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
From 2946f2af6202a744dcc2162a2259066b1a4d62ac Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 16:35:47 +0500
Subject: [PATCH 041/135] A page count Insert Pages cannot mean is refused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found probing `layout.pages.insert` on a two-page document:
{"count": 0} → one page inserted (0 clamped up to 1)
{"count": -1} → one page (`as_u64` fails, the default applies)
{"count": 1.5} → one page (the same way)
{"count": 10000} → 9999 pages, quietly capped
A count that is given is a number of pages, so each of those is a usage error
now: "count 0: 1 to 9999 pages". A count that is absent still means one page.
`after` is left alone. My first attempt also refused a position past the last
page, and `insert_after_u64_max_appends_at_the_end` caught it: appending is
what an out-of-range `after` is meant to do, and the test now says so in the
same place.
The test covers the five counts, that none of them inserted anything, that a
position past the end still appends, and that `{"count": 2, "after": 0}` still
inserts two pages after the first. Without the check, `{"count": 0}` inserts a
page again.
Not from a ticket: I found it probing the command's parameters.
---
crates/engine/src/cmd/layout.rs | 48 ++++++++++++++++++++++++++++++---
1 file changed, 45 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
index 615e7c1df..92cf0c6e2 100644
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -112,14 +112,25 @@ pub fn specs() -> Vec {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
- "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
+ "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
- let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
+ const ID: &str = "layout.pages.insert";
+ // A count that is given but is not a number of pages used to insert one page:
+ // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
+ let count = match p.get("count").filter(|v| !v.is_null()) {
+ None => 1,
+ Some(v) => match v.as_u64().map(|n| n as usize).filter(|n| (1..=9999).contains(n)) {
+ Some(n) => n,
+ None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
+ },
+ };
let st = s.doc()?;
let n = st.doc.page_count();
+ // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
+ // pins deliberately.
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
- let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
+ let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
s.edit(|d, _| {
let ids = d.insert_pages(Some(after), count, parent)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
@@ -2055,3 +2066,34 @@ mod parent_base_tests {
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
+
+#[cfg(test)]
+mod insert_count_tests {
+ use serde_json::json;
+
+ use crate::Session;
+
+ /// A count that is given but is not a number of pages used to insert one page anyway:
+ /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
+ /// to 9999. A position past the last page appends, which is deliberate.
+ #[test]
+ fn a_page_count_insert_pages_cannot_mean_is_refused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+ let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
+ assert_eq!(total(&mut s), 3, "one page added");
+
+ for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
+ let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string();
+ assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
+ }
+ // Nothing was inserted by any of those.
+ assert_eq!(s.doc().unwrap().doc.page_count(), 3);
+ // A position past the last page still appends, which `insert_pages_bounds.rs` pins.
+ let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
+ assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
+ // The positions it does have still work: after the first page of three.
+ let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
+ assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
+ }
+}
From c212c89bab697768c9839292cb3d2e098f474dd3 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 17:00:05 +0500
Subject: [PATCH 042/135] A text anchor's name is the document's, so it is not
reused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`anchor.create` took any name, including one another anchor already had, so a
document could hold two anchors called "Photo 1". Every reference to the name
then means whichever the search finds first: `xref.insert {"anchor": "Photo 1"}`
and `hyperlink.create {"anchor": "Photo 1"}` take the first, and an export
writes both destinations under the one name. A name nobody can resolve is not a
name.
A name already in the document is a usage error now, naming it and where to
look: "a text anchor named `Photo 1` is already in this document (anchor.list
names them)". A name that is empty or only spaces is refused too — a
cross-reference built from ` ` would show nothing.
Case still distinguishes names, as it does elsewhere in the document, and
nothing about existing anchors changes: the check is on creation.
The test covers the same name in the same story and in another one, the empty
names, that only one anchor exists afterwards, and that another name and a
name differing in case are both fine. Without the check the duplicate is
created again.
Not from a ticket: found probing `anchor.create`.
---
crates/engine/src/cmd/xref.rs | 48 ++++++++++++++++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs
index d462f3d1b..446148095 100644
--- a/crates/engine/src/cmd/xref.rs
+++ b/crates/engine/src/cmd/xref.rs
@@ -22,7 +22,7 @@ pub fn specs() -> Vec {
"New Hyperlink Destination…",
["Type", "Hyperlinks & Cross-References"],
None,
- "{name} — a text anchor at the insertion point",
+ "{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
in_story_text,
create_anchor
),
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result {
fn create_anchor(s: &mut Session, p: &Value) -> Result {
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
+ if name.trim().is_empty() {
+ return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
+ }
+ // Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
+ // `hyperlink.create` take the first they find, and a file writes both under one name.
+ if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
+ return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
+ }
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -215,6 +223,44 @@ mod tests {
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
}
+ /// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
+ /// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
+ /// exported file writes both destinations under one name.
+ #[test]
+ fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+ let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
+ let select = |s: &mut Session, story: serde_json::Value, from: usize| {
+ s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
+ };
+ select(&mut s, a["story"].clone(), 0);
+ s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
+
+ // The same name again, in the same story and in another one.
+ select(&mut s, a["story"].clone(), 10);
+ let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
+ assert!(e.contains("already in this document"), "{e}");
+ let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
+ let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
+ select(&mut s, b["story"].clone(), 0);
+ assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
+ // A name of spaces is not a name.
+ assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
+ assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
+
+ // One anchor, so a reference by name can only mean that one.
+ let anchors = s.execute("anchor.list", &json!({})).unwrap();
+ assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
+ // Another name is fine.
+ s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
+ assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
+ // The check is on the name, not on where it is: a name that differs only in case is a
+ // different name, as it is everywhere else in the document.
+ select(&mut s, b["story"].clone(), 0);
+ s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
+ }
+
#[test]
fn cross_reference_into_a_story_with_a_running_header_composes() {
let mut s = Session::new();
From caced43205739f3bf13d9ce19ac8210ad3b8022b Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 14:03:59 +0200
Subject: [PATCH 043/135] Paragraph panel: InDesign's layout and panel menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The panel shows the nine alignment buttons in InDesign's groups; indent,
spacing and drop cap fields by their icons in two columns, with Space
Between Paragraphs Using Same Style (Ignore or a space); Shading and Border
with the Paragraph Borders and Shading button and colour; and Hyphenate
with Do Not Align / Align to Baseline Grid.
The panel menu follows InDesign's order: composers, Only Align First Line
to Grid, Balance Ragged Lines, Optical Margin Alignment, the paragraph
dialogs (Keep Options…, Drop Caps and Nested Styles… and GREP Styles… now
open for the selected paragraphs), Bullets and Numbering…, Restart/Continue
Numbering, Define Lists… and the paragraph direction.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/ui-egui/src/chrome.rs | 2 +-
crates/ui-egui/src/dialogs.rs | 526 ++++++++++++------------
crates/ui-egui/src/i18n.rs | 69 ++++
crates/ui-egui/src/i18n/ar.rs | 12 +
crates/ui-egui/src/i18n/cs.rs | 12 +
crates/ui-egui/src/i18n/de.rs | 12 +
crates/ui-egui/src/i18n/it.rs | 12 +
crates/ui-egui/src/i18n/ja.rs | 12 +
crates/ui-egui/src/i18n/ka.rs | 12 +
crates/ui-egui/src/i18n/pl.rs | 12 +
crates/ui-egui/src/i18n/pt_br.rs | 12 +
crates/ui-egui/src/i18n/ru.rs | 12 +
crates/ui-egui/src/i18n/uk.rs | 12 +
crates/ui-egui/src/icons.rs | 15 +
crates/ui-egui/src/menus.rs | 18 +-
crates/ui-egui/src/panels/properties.rs | 437 +++++++++++++-------
crates/ui-egui/src/widgets.rs | 21 +-
docs/target-app-parity.md | 2 +-
19 files changed, 802 insertions(+), 409 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 244d6fc9d..02204bda7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. Hyphenate Across Column is kept and round-trips through IDML, but composition doesn't apply it yet. No readiness percentage change.
+- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 62b47cd7a..8517bc63e 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -991,7 +991,7 @@ fn control_paragraph(
/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name,
/// and the document's swatches in its menu. Returns the swatch picked.
-fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option {
+pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click());
let resp = resp.on_hover_text(label);
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 999aed488..32df58f6c 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -2057,6 +2057,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
match d.s("section").as_str() {
"span" => "Span Columns",
"bullets" => "Bullets and Numbering",
+ "keep" => "Keep Options",
+ "nested" => "Drop Caps and Nested Styles",
+ "grep" => "GREP Styles",
_ => "Paragraph Borders and Shading",
},
),
@@ -3897,270 +3900,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
text_field(ui, d, "x.class", 160.0);
});
}
- "nested" => {
- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0)));
- egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
- for (label, key, max) in [
- ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
- ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
- ] {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
- let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
- if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
- d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64)));
- }
- ui.end_row();
- }
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:"));
- let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
- let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
- egui::ComboBox::from_id_salt("ps_dropcap_style")
- .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
- .width(160.0)
- .show_ui(ui, |ui| {
- for c in &cnames {
- if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() {
- d.fields.insert("p.dropCapStyle".into(), json!(c));
- }
- }
- });
- ui.end_row();
- });
- for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] {
- let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false);
- if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
- d.fields.insert(format!("p.{key}"), json!(on));
- }
- }
- ui.add_space(8.0);
- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0)));
- let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
- let mut changed = false;
- let mut remove = None;
- for (i, ns) in list.iter_mut().enumerate() {
- ui.horizontal(|ui| {
- let style = ns["style"].as_str().unwrap_or("[None]").to_string();
- egui::ComboBox::from_id_salt(("ns_style", i))
- .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
- .width(110.0)
- .show_ui(ui, |ui| {
- for c in &cnames {
- if ui
- .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
- .clicked()
- {
- ns["style"] = json!(c);
- changed = true;
- }
- }
- });
- let through = ns["through"].as_bool().unwrap_or(true);
- egui::ComboBox::from_id_salt(("ns_thr", i))
- .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" })))
- .width(70.0)
- .show_ui(ui, |ui| {
- for (v, l) in [(true, "through"), (false, "up to")] {
- if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
- ns["through"] = json!(v);
- changed = true;
- }
- }
- });
- let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
- let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
- if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
- ns["count"] = json!(n);
- *changed = true;
- }
- };
- if !crate::i18n::category_first(&app.ui.language) {
- count_value(ui, ns, &mut changed);
- }
- let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
- egui::ComboBox::from_id_salt(("ns_until", i))
- .selected_text(crate::rtl::widget(
- ui,
- if crate::i18n::category_first(&app.ui.language) {
- format!("{}:", crate::i18n::tr(&app.ui.language, &kind))
- } else {
- crate::i18n::tr(&app.ui.language, &kind).to_owned()
- },
- ))
- .width(100.0)
- .show_ui(ui, |ui| {
- for k in [
- "sentences",
- "words",
- "characters",
- "letters",
- "digits",
- "tab",
- "forcedLineBreak",
- "emSpace",
- "enSpace",
- "dropcap",
- "chars",
- ] {
- if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() {
- ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
- changed = true;
- }
- }
- });
- if crate::i18n::category_first(&app.ui.language) {
- count_value(ui, ns, &mut changed);
- }
- if kind == "chars" {
- let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
- if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
- ns["until"]["chars"] = json!(c);
- changed = true;
- }
- }
- if ui
- .small_button("×")
- .on_hover_ui(|ui| {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
- })
- .clicked()
- {
- remove = Some(i);
- }
- });
- }
- if let Some(i) = remove {
- list.remove(i);
- changed = true;
- }
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() {
- list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}}));
- changed = true;
- }
- if changed {
- d.fields.insert("p.nestedStyles".into(), Value::Array(list));
- }
- ui.add_space(8.0);
- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "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(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
- .width(130.0)
- .show_ui(ui, |ui| {
- for c in &cnames {
- if ui
- .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
- .clicked()
- {
- l["style"] = json!(c);
- changed = true;
- }
- }
- });
- crate::rtl::label(
- ui,
- if crate::i18n::category_first(&app.ui.language) {
- format!("{}:", crate::i18n::tr(&app.ui.language, "lines"))
- } else {
- crate::i18n::tr(&app.ui.language, "for").to_owned()
- },
- );
- 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;
- }
- if !crate::i18n::category_first(&app.ui.language) {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
- }
- if ui
- .small_button("×")
- .on_hover_ui(|ui| {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
- })
- .clicked()
- {
- remove = Some(i);
- }
- });
- }
- if let Some(i) = remove {
- lines.remove(i);
- changed = true;
- }
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "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" => {
- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0)));
- let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default();
- let mut changed = false;
- let mut remove = None;
- for (i, g) in list.iter_mut().enumerate() {
- ui.horizontal(|ui| {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:"));
- let style = g["style"].as_str().unwrap_or("").to_string();
- egui::ComboBox::from_id_salt(("gs_style", i))
- .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
- .width(110.0)
- .show_ui(ui, |ui| {
- for c in &cnames {
- if ui
- .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
- .clicked()
- {
- g["style"] = json!(c);
- changed = true;
- }
- }
- });
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:"));
- let mut pat = g["pattern"].as_str().unwrap_or("").to_string();
- let bad = grep_error(&pat).is_some();
- let r = ui.add(
- egui::TextEdit::singleline(&mut pat)
- .desired_width(150.0)
- .text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))),
- );
- if r.changed() {
- g["pattern"] = json!(pat);
- changed = true;
- }
- if ui
- .small_button("×")
- .on_hover_ui(|ui| {
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
- })
- .clicked()
- {
- remove = Some(i);
- }
- });
- if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) {
- crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90)));
- }
- }
- if let Some(i) = remove {
- list.remove(i);
- changed = true;
- }
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() {
- list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"}));
- changed = true;
- }
- if changed {
- d.fields.insert("p.grepStyles".into(), Value::Array(list));
- }
- }
+ "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames),
+ "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames),
"chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }),
"indents" => {
egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -4693,6 +4434,254 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc
/// Paragraph Style Options › Keep Options: lines kept with the next paragraph, the paragraph's
/// lines kept together (all of them, or its first and last lines), and where it starts.
+/// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields).
+fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) {
+ let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone());
+ crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0)));
+ egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+ for (label, key, max) in [
+ ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
+ ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
+ ] {
+ crate::rtl::label(ui, crate::i18n::tr(lang, label));
+ let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
+ if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
+ d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64)));
+ }
+ ui.end_row();
+ }
+ crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:"));
+ let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
+ let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
+ egui::ComboBox::from_id_salt("ps_dropcap_style")
+ .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
+ .width(160.0)
+ .show_ui(ui, |ui| {
+ for c in cnames {
+ if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
+ d.fields.insert("p.dropCapStyle".into(), json!(c));
+ }
+ }
+ });
+ ui.end_row();
+ });
+ for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] {
+ let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false);
+ if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() {
+ d.fields.insert(format!("p.{key}"), json!(on));
+ }
+ }
+ ui.add_space(8.0);
+ crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0)));
+ let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
+ let mut changed = false;
+ let mut remove = None;
+ for (i, ns) in list.iter_mut().enumerate() {
+ ui.horizontal(|ui| {
+ let style = ns["style"].as_str().unwrap_or("[None]").to_string();
+ egui::ComboBox::from_id_salt(("ns_style", i))
+ .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
+ .width(110.0)
+ .show_ui(ui, |ui| {
+ for c in cnames {
+ if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
+ ns["style"] = json!(c);
+ changed = true;
+ }
+ }
+ });
+ let through = ns["through"].as_bool().unwrap_or(true);
+ egui::ComboBox::from_id_salt(("ns_thr", i))
+ .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" })))
+ .width(70.0)
+ .show_ui(ui, |ui| {
+ for (v, l) in [(true, "through"), (false, "up to")] {
+ if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() {
+ ns["through"] = json!(v);
+ changed = true;
+ }
+ }
+ });
+ let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
+ let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
+ if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
+ ns["count"] = json!(n);
+ *changed = true;
+ }
+ };
+ if !crate::i18n::category_first(lang) {
+ count_value(ui, ns, &mut changed);
+ }
+ let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
+ egui::ComboBox::from_id_salt(("ns_until", i))
+ .selected_text(crate::rtl::widget(
+ ui,
+ if crate::i18n::category_first(lang) {
+ format!("{}:", crate::i18n::tr(lang, &kind))
+ } else {
+ crate::i18n::tr(lang, &kind).to_owned()
+ },
+ ))
+ .width(100.0)
+ .show_ui(ui, |ui| {
+ for k in
+ ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"]
+ {
+ if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() {
+ ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
+ changed = true;
+ }
+ }
+ });
+ if crate::i18n::category_first(lang) {
+ count_value(ui, ns, &mut changed);
+ }
+ if kind == "chars" {
+ let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
+ if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
+ ns["until"]["chars"] = json!(c);
+ changed = true;
+ }
+ }
+ if ui
+ .small_button("×")
+ .on_hover_ui(|ui| {
+ crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
+ })
+ .clicked()
+ {
+ remove = Some(i);
+ }
+ });
+ }
+ if let Some(i) = remove {
+ list.remove(i);
+ changed = true;
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Nested Style"))).clicked() {
+ list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}}));
+ changed = true;
+ }
+ if changed {
+ d.fields.insert("p.nestedStyles".into(), Value::Array(list));
+ }
+ ui.add_space(8.0);
+ crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "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(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
+ .width(130.0)
+ .show_ui(ui, |ui| {
+ for c in cnames {
+ if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
+ l["style"] = json!(c);
+ changed = true;
+ }
+ }
+ });
+ crate::rtl::label(
+ ui,
+ if crate::i18n::category_first(lang) {
+ format!("{}:", crate::i18n::tr(lang, "lines"))
+ } else {
+ crate::i18n::tr(lang, "for").to_owned()
+ },
+ );
+ 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;
+ }
+ if !crate::i18n::category_first(lang) {
+ crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" }));
+ }
+ if ui
+ .small_button("×")
+ .on_hover_ui(|ui| {
+ crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
+ })
+ .clicked()
+ {
+ remove = Some(i);
+ }
+ });
+ }
+ if let Some(i) = remove {
+ lines.remove(i);
+ changed = true;
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "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 Styles: a character style applied to text matching each pattern (`p.` fields).
+fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) {
+ let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone());
+ crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "GREP Styles")).font(semibold(12.0)));
+ let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default();
+ let mut changed = false;
+ let mut remove = None;
+ for (i, g) in list.iter_mut().enumerate() {
+ ui.horizontal(|ui| {
+ crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:"));
+ let style = g["style"].as_str().unwrap_or("").to_string();
+ egui::ComboBox::from_id_salt(("gs_style", i))
+ .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
+ .width(110.0)
+ .show_ui(ui, |ui| {
+ for c in cnames {
+ if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
+ g["style"] = json!(c);
+ changed = true;
+ }
+ }
+ });
+ crate::rtl::label(ui, crate::i18n::tr(lang, "To Text:"));
+ let mut pat = g["pattern"].as_str().unwrap_or("").to_string();
+ let bad = grep_error(&pat).is_some();
+ let r =
+ ui.add(egui::TextEdit::singleline(&mut pat).desired_width(150.0).text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))));
+ if r.changed() {
+ g["pattern"] = json!(pat);
+ changed = true;
+ }
+ if ui
+ .small_button("×")
+ .on_hover_ui(|ui| {
+ crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
+ })
+ .clicked()
+ {
+ remove = Some(i);
+ }
+ });
+ if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) {
+ crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90)));
+ }
+ }
+ if let Some(i) = remove {
+ list.remove(i);
+ changed = true;
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New GREP Style"))).clicked() {
+ list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"}));
+ changed = true;
+ }
+ if changed {
+ d.fields.insert("p.grepStyles".into(), Value::Array(list));
+ }
+}
+
fn keep_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) {
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Keep with Next:"));
@@ -5100,6 +5089,15 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.set_min_width(480.0);
match d.s("section").as_str() {
"span" => span_columns(&lang, ui, d, &pv, &doc),
+ "keep" => keep_options(&lang, ui, d, &pv),
+ "nested" | "grep" => {
+ let cnames: Vec = doc.styles.character.iter().map(|c| c.name.clone()).collect();
+ if d.s("section") == "nested" {
+ drop_caps_and_nested_styles(&lang, ui, d, &pv, &cnames);
+ } else {
+ grep_styles(&lang, ui, d, &pv, &cnames);
+ }
+ }
"bullets" => {
let (char_styles, families, fonts, scope) = ListUi::new_parts(app);
let (family, style) = (d.s("family"), d.s("style"));
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 0de369f91..508c8e014 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -1065,6 +1065,75 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),
+ ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]),
+ ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]),
+ ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]),
+ (
+ "Space Between Paragraphs Using Same Style",
+ [
+ "Abstand zwischen Absätzen mit gleichem Format",
+ "Espace entre les paragraphes de même style",
+ "Espacio entre párrafos con el mismo estilo",
+ "同じスタイルの段落間のスペース",
+ "使用相同样式的段落间距",
+ ],
+ ),
+ ("Ignore", ["Ignorieren", "Ignorer", "Ignorar", "無視", "忽略"]),
+ (
+ "Do Not Align to Baseline Grid",
+ [
+ "Nicht am Grundlinienraster ausrichten",
+ "Ne pas aligner sur la grille de ligne de base",
+ "No alinear con cuadrícula base",
+ "ベースライングリッドに揃えない",
+ "不对齐基线网格",
+ ],
+ ),
+ (
+ "Only Align First Line to Grid",
+ [
+ "Nur erste Zeile am Raster ausrichten",
+ "Aligner uniquement la première ligne sur la grille",
+ "Alinear solo primera línea con cuadrícula",
+ "最初の行のみグリッドに揃える",
+ "仅首行对齐网格",
+ ],
+ ),
+ (
+ "Balance Ragged Lines",
+ ["Unausgeglichene Zeilen ausgleichen", "Équilibrer les lignes irrégulières", "Equilibrar líneas desiguales", "行末を調整", "平衡未对齐的行"],
+ ),
+ (
+ "Drop Caps and Nested Styles…",
+ [
+ "Initialen und verschachtelte Formate …",
+ "Lettrines et styles imbriqués…",
+ "Capitulares y estilos anidados…",
+ "ドロップキャップと先頭文字スタイル…",
+ "首字下沉和嵌套样式…",
+ ],
+ ),
+ ("GREP Styles…", ["GREP-Stile …", "Styles GREP…", "Estilos GREP…", "正規表現スタイル…", "GREP 样式…"]),
+ (
+ "Restart/Continue Numbering",
+ [
+ "Nummerierung neu beginnen/fortführen",
+ "Recommencer/Poursuivre la numérotation",
+ "Reiniciar/Continuar numeración",
+ "番号の振り直し/継続",
+ "重新开始/继续编号",
+ ],
+ ),
+ (
+ "Convert Bullets and Numbering to Text",
+ [
+ "Aufzählungszeichen und Nummerierung in Text konvertieren",
+ "Convertir les puces et la numérotation en texte",
+ "Convertir viñetas y numeración en texto",
+ "箇条書きと段落番号をテキストに変換",
+ "将项目符号和编号转换为文本",
+ ],
+ ),
("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]),
(
"Hyphenation Settings",
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index e702b89c3..49fd3046e 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
+ ("Restart Numbering", "إعادة الترقيم"),
+ ("Continue Numbering", "متابعة الترقيم"),
+ ("Keep Options…", "خيارات الإبقاء…"),
+ ("Space Between Paragraphs Using Same Style", "المسافة بين الفقرات ذات النمط نفسه"),
+ ("Ignore", "تجاهل"),
+ ("Do Not Align to Baseline Grid", "عدم المحاذاة إلى شبكة خط الأساس"),
+ ("Only Align First Line to Grid", "محاذاة السطر الأول فقط إلى الشبكة"),
+ ("Balance Ragged Lines", "موازنة الأسطر غير المتساوية"),
+ ("Drop Caps and Nested Styles…", "الأحرف الاستهلالية والأنماط المتداخلة…"),
+ ("GREP Styles…", "أنماط GREP…"),
+ ("Restart/Continue Numbering", "إعادة/متابعة الترقيم"),
+ ("Convert Bullets and Numbering to Text", "تحويل التعداد النقطي والترقيم إلى نص"),
("Hyphenation…", "الواصلة…"),
("Hyphenation Settings", "إعدادات الواصلة"),
("hyphens", "واصلات"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index 34d8cc002..4547a4b37 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
+ ("Restart Numbering", "Začít číslování znovu"),
+ ("Continue Numbering", "Pokračovat v číslování"),
+ ("Keep Options…", "Volby zachování…"),
+ ("Space Between Paragraphs Using Same Style", "Mezera mezi odstavci se stejným stylem"),
+ ("Ignore", "Ignorovat"),
+ ("Do Not Align to Baseline Grid", "Nezarovnávat na mřížku účaří"),
+ ("Only Align First Line to Grid", "Zarovnat na mřížku jen první řádek"),
+ ("Balance Ragged Lines", "Vyrovnat nezarovnané řádky"),
+ ("Drop Caps and Nested Styles…", "Iniciály a vnořené styly…"),
+ ("GREP Styles…", "Styly GREP…"),
+ ("Restart/Continue Numbering", "Začít znovu/pokračovat v číslování"),
+ ("Convert Bullets and Numbering to Text", "Převést odrážky a číslování na text"),
("Hyphenation…", "Dělení slov…"),
("Hyphenation Settings", "Nastavení dělení slov"),
("hyphens", "spojovníky"),
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index b2d66cbe7..068c32255 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Ausschneiden"),
("Copy", "Kopieren"),
("Paste", "Einfügen"),
+ ("Restart Numbering", "Nummerierung neu beginnen"),
+ ("Continue Numbering", "Nummerierung fortführen"),
+ ("Keep Options…", "Umbruchoptionen …"),
+ ("Space Between Paragraphs Using Same Style", "Abstand zwischen Absätzen mit gleichem Format"),
+ ("Ignore", "Ignorieren"),
+ ("Do Not Align to Baseline Grid", "Nicht am Grundlinienraster ausrichten"),
+ ("Only Align First Line to Grid", "Nur erste Zeile am Raster ausrichten"),
+ ("Balance Ragged Lines", "Unausgeglichene Zeilen ausgleichen"),
+ ("Drop Caps and Nested Styles…", "Initialen und verschachtelte Formate …"),
+ ("GREP Styles…", "GREP-Stile …"),
+ ("Restart/Continue Numbering", "Nummerierung neu beginnen/fortführen"),
+ ("Convert Bullets and Numbering to Text", "Aufzählungszeichen und Nummerierung in Text konvertieren"),
("Hyphenation…", "Silbentrennung …"),
("Hyphenation Settings", "Silbentrennungseinstellungen"),
("hyphens", "Trennstriche"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index 6adcaa467..48161b64e 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -28,6 +28,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
+ ("Restart Numbering", "Riavvia numerazione"),
+ ("Continue Numbering", "Continua numerazione"),
+ ("Keep Options…", "Opzioni di separazione…"),
+ ("Space Between Paragraphs Using Same Style", "Spazio tra paragrafi con lo stesso stile"),
+ ("Ignore", "Ignora"),
+ ("Do Not Align to Baseline Grid", "Non allineare alla griglia linee di base"),
+ ("Only Align First Line to Grid", "Allinea solo la prima riga alla griglia"),
+ ("Balance Ragged Lines", "Bilancia righe irregolari"),
+ ("Drop Caps and Nested Styles…", "Capilettera e stili nidificati…"),
+ ("GREP Styles…", "Stili GREP…"),
+ ("Restart/Continue Numbering", "Riavvia/continua numerazione"),
+ ("Convert Bullets and Numbering to Text", "Converti elenchi puntati e numerati in testo"),
("Hyphenation…", "Sillabazione…"),
("Hyphenation Settings", "Impostazioni sillabazione"),
("hyphens", "trattini"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
index c8515805b..413b2d99e 100644
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "カット"),
("Copy", "コピー"),
("Paste", "ペースト"),
+ ("Restart Numbering", "番号の振り直し"),
+ ("Continue Numbering", "番号の継続"),
+ ("Keep Options…", "段落分離禁止オプション…"),
+ ("Space Between Paragraphs Using Same Style", "同じスタイルの段落間のスペース"),
+ ("Ignore", "無視"),
+ ("Do Not Align to Baseline Grid", "ベースライングリッドに揃えない"),
+ ("Only Align First Line to Grid", "最初の行のみグリッドに揃える"),
+ ("Balance Ragged Lines", "行末を調整"),
+ ("Drop Caps and Nested Styles…", "ドロップキャップと先頭文字スタイル…"),
+ ("GREP Styles…", "正規表現スタイル…"),
+ ("Restart/Continue Numbering", "番号の振り直し/継続"),
+ ("Convert Bullets and Numbering to Text", "箇条書きと段落番号をテキストに変換"),
("Hyphenation…", "ハイフネーション…"),
("Hyphenation Settings", "ハイフネーション設定"),
("hyphens", "ハイフン"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
index c756cc123..4767b6c30 100644
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
+ ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
+ ("Continue Numbering", "ნუმერაციის გაგრძელება"),
+ ("Keep Options…", "შენარჩუნების პარამეტრები…"),
+ ("Space Between Paragraphs Using Same Style", "დაშორება ერთი სტილის აბზაცებს შორის"),
+ ("Ignore", "უგულებელყოფა"),
+ ("Do Not Align to Baseline Grid", "საბაზისო ბადეზე არ გასწორდეს"),
+ ("Only Align First Line to Grid", "ბადეზე მხოლოდ პირველი ხაზის გასწორება"),
+ ("Balance Ragged Lines", "უსწორმასწორო ხაზების დაბალანსება"),
+ ("Drop Caps and Nested Styles…", "საწყისი ასოები და ჩადგმული სტილები…"),
+ ("GREP Styles…", "GREP სტილები…"),
+ ("Restart/Continue Numbering", "ნუმერაციის თავიდან დაწყება/გაგრძელება"),
+ ("Convert Bullets and Numbering to Text", "მარკერებისა და ნუმერაციის ტექსტად გარდაქმნა"),
("Hyphenation…", "დამარცვლა…"),
("Hyphenation Settings", "დამარცვლის პარამეტრები"),
("hyphens", "დეფისი"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
index 6f530abe2..bf0c7600b 100644
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
+ ("Restart Numbering", "Zacznij numerowanie od nowa"),
+ ("Continue Numbering", "Kontynuuj numerowanie"),
+ ("Keep Options…", "Opcje utrzymywania…"),
+ ("Space Between Paragraphs Using Same Style", "Odstęp między akapitami o tym samym stylu"),
+ ("Ignore", "Ignoruj"),
+ ("Do Not Align to Baseline Grid", "Nie wyrównuj do siatki linii bazowych"),
+ ("Only Align First Line to Grid", "Wyrównaj do siatki tylko pierwszy wiersz"),
+ ("Balance Ragged Lines", "Wyrównaj nierówne wiersze"),
+ ("Drop Caps and Nested Styles…", "Inicjały i style zagnieżdżone…"),
+ ("GREP Styles…", "Style GREP…"),
+ ("Restart/Continue Numbering", "Zacznij od nowa/kontynuuj numerowanie"),
+ ("Convert Bullets and Numbering to Text", "Konwertuj wypunktowanie i numerowanie na tekst"),
("Hyphenation…", "Dzielenie wyrazów…"),
("Hyphenation Settings", "Ustawienia dzielenia wyrazów"),
("hyphens", "dywizy"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index b8174f7d8..4faf5cee5 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
+ ("Restart Numbering", "Reiniciar numeração"),
+ ("Continue Numbering", "Continuar numeração"),
+ ("Keep Options…", "Opções de manutenção…"),
+ ("Space Between Paragraphs Using Same Style", "Espaço entre parágrafos com o mesmo estilo"),
+ ("Ignore", "Ignorar"),
+ ("Do Not Align to Baseline Grid", "Não alinhar à grade de linha de base"),
+ ("Only Align First Line to Grid", "Alinhar somente a primeira linha à grade"),
+ ("Balance Ragged Lines", "Equilibrar linhas irregulares"),
+ ("Drop Caps and Nested Styles…", "Capitulares e estilos aninhados…"),
+ ("GREP Styles…", "Estilos GREP…"),
+ ("Restart/Continue Numbering", "Reiniciar/continuar numeração"),
+ ("Convert Bullets and Numbering to Text", "Converter marcadores e numeração em texto"),
("Hyphenation…", "Hifenização…"),
("Hyphenation Settings", "Configurações de hifenização"),
("hyphens", "hifens"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 854fd20a2..412faea7e 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -33,6 +33,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
+ ("Restart Numbering", "Начать нумерацию заново"),
+ ("Continue Numbering", "Продолжить нумерацию"),
+ ("Keep Options…", "Параметры удержания…"),
+ ("Space Between Paragraphs Using Same Style", "Интервал между абзацами одного стиля"),
+ ("Ignore", "Игнорировать"),
+ ("Do Not Align to Baseline Grid", "Не выравнивать по базовой сетке"),
+ ("Only Align First Line to Grid", "Выравнивать по сетке только первую строку"),
+ ("Balance Ragged Lines", "Сбалансировать рваные строки"),
+ ("Drop Caps and Nested Styles…", "Буквицы и вложенные стили…"),
+ ("GREP Styles…", "Стили GREP…"),
+ ("Restart/Continue Numbering", "Начать заново/продолжить нумерацию"),
+ ("Convert Bullets and Numbering to Text", "Преобразовать маркеры и нумерацию в текст"),
("Hyphenation…", "Расстановка переносов…"),
("Hyphenation Settings", "Параметры расстановки переносов"),
("hyphens", "дефисов"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
index 8cdf821d3..62c594fd1 100644
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
+ ("Restart Numbering", "Почати нумерацію знову"),
+ ("Continue Numbering", "Продовжити нумерацію"),
+ ("Keep Options…", "Параметри утримання…"),
+ ("Space Between Paragraphs Using Same Style", "Інтервал між абзацами одного стилю"),
+ ("Ignore", "Ігнорувати"),
+ ("Do Not Align to Baseline Grid", "Не вирівнювати за базовою сіткою"),
+ ("Only Align First Line to Grid", "Вирівнювати за сіткою лише перший рядок"),
+ ("Balance Ragged Lines", "Збалансувати нерівні рядки"),
+ ("Drop Caps and Nested Styles…", "Буквиці та вкладені стилі…"),
+ ("GREP Styles…", "Стилі GREP…"),
+ ("Restart/Continue Numbering", "Почати знову/продовжити нумерацію"),
+ ("Convert Bullets and Numbering to Text", "Перетворити маркери й нумерацію на текст"),
("Hyphenation…", "Перенесення…"),
("Hyphenation Settings", "Параметри перенесення"),
("hyphens", "дефісів"),
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index f33e40184..72c37fc83 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -798,6 +798,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(3.0, v), (5.0, v)]);
}
}
+ // Do Not Align to Baseline Grid: ragged lines of text, free of any grid.
+ "grid-none" => {
+ for (y, x1) in [(5.0, 17.0), (8.5, 12.0), (12.0, 17.0), (15.5, 14.0)] {
+ pen.line(&[(3.0, y), (x1, y)]);
+ }
+ }
"grid-baseline" => {
pen.rect(3.0, 3.0, 17.0, 17.0);
for y in [6.5, 10.0, 13.5] {
@@ -998,6 +1004,15 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]);
pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]);
}
+ // Space Between Paragraphs Using Same Style: two paragraphs and the space between them.
+ "space-same" => {
+ for y in [2.0, 5.0, 15.0, 18.0] {
+ pen.line(&[(8.0, y), (18.0, y)]);
+ }
+ pen.line(&[(4.0, 6.5), (4.0, 13.5)]);
+ pen.line(&[(2.5, 8.0), (4.0, 6.5), (5.5, 8.0)]);
+ pen.line(&[(2.5, 12.0), (4.0, 13.5), (5.5, 12.0)]);
+ }
"drop-lines" | "drop-chars" => {
pen.text(5.5, 8.0, "A", 14.0);
if name == "drop-chars" {
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 96233c418..0b2f0c828 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -108,6 +108,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"{tab?: border|shading} — border and shading of the selected paragraphs (type.para)",
),
("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"),
+ ("app.keepOptionsDialog", "Keep Options…", None, "{} — Keep with Next and Keep Lines Together of the selected paragraphs (type.para)"),
+ (
+ "app.nestedStylesDialog",
+ "Drop Caps and Nested Styles…",
+ None,
+ "{} — drop cap, nested styles and nested line styles of the selected paragraphs (type.para)",
+ ),
+ ("app.grepStylesDialog", "GREP Styles…", None, "{} — GREP styles of the selected paragraphs (type.para)"),
("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"),
(
"app.hyphenationDialog",
@@ -1283,11 +1291,19 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
+ "app.paragraphBordersDialog"
+ | "app.spanColumnsDialog"
+ | "app.bulletsNumberingDialog"
+ | "app.keepOptionsDialog"
+ | "app.nestedStylesDialog"
+ | "app.grepStylesDialog" => {
let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) };
let section = match id {
"app.spanColumnsDialog" => "span",
"app.bulletsNumberingDialog" => "bullets",
+ "app.keepOptionsDialog" => "keep",
+ "app.nestedStylesDialog" => "nested",
+ "app.grepStylesDialog" => "grep",
_ if p.get("tab").and_then(Value::as_str) == Some("shading") => "shading",
_ => "border",
};
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 469b23251..2de8d370f 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -1356,6 +1356,12 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
/// A percentage or other number field in one half of a two-field row, with its icon.
#[allow(clippy::too_many_arguments)]
fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option {
+ half_field_or_clear(ui, b, right, y, half, icon, tip, field).flatten()
+}
+
+/// [`half_field`] for a value that may be unset: emptying the field commits `Some(None)`.
+#[allow(clippy::too_many_arguments)]
+fn half_field_or_clear(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option> {
let t = Tokens::get(ui.ctx());
let x = if right { half + 10.0 } else { 0.0 };
let ir = sub(b, x, y + 1.0, 19.0, 19.0);
@@ -1364,7 +1370,7 @@ fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str,
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
let label = resp.id;
resp.on_hover_text(tip);
- place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show(ui))
+ place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show_or_clear(ui))
}
/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew,
@@ -1907,163 +1913,234 @@ pub fn show_typography_panel(app: &mut DesignApp) {
let _ = app.run("window.panel", json!({"panel": "typography"}));
}
+/// The paragraph alignments in InDesign's order, with their icons.
+const PARA_ALIGNS: [(Align, &str); 9] = [
+ (Align::Left, "palign-left"),
+ (Align::Center, "palign-center"),
+ (Align::Right, "palign-right"),
+ (Align::LeftJustified, "palign-justify-left"),
+ (Align::CenterJustified, "palign-justify-center"),
+ (Align::RightJustified, "palign-justify-right"),
+ (Align::FullyJustified, "palign-justify-all"),
+ (Align::TowardsSpine, "palign-spine-towards"),
+ (Align::AwayFromSpine, "palign-spine-away"),
+];
+
+/// Space Between Paragraphs Using Same Style's presets (pt), after Ignore.
+const SAME_STYLE_SPACING: &[f64] = &[0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0];
+
+/// The Paragraph panel, laid out as InDesign's: the alignment buttons in groups of 3, 4 and 2;
+/// indents, spacing and drop cap fields by their icons in two columns; Shading and Border with
+/// their colours; Hyphenate and the baseline grid buttons. The rest (composer, grid and ragged
+/// lines options, the paragraph dialogs) is in the panel menu.
pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let Some(a) = text_attrs(app) else {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame."));
return;
};
+ let lang_ui = app.ui.language.clone();
+ let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
let u = units(app);
let p = a["para"].clone();
- ui.horizontal(|ui| {
- crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong());
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- crate::menus::menu_button(ui, "☰", |ui| {
- // In the order of InDesign's Paragraph panel menu.
- for (label, id) in [
- ("Justification…", "app.justificationDialog"),
- ("Span Columns…", "app.spanColumnsDialog"),
- ("Hyphenation…", "app.hyphenationDialog"),
- ("Paragraph Rules…", "app.paragraphRulesDialog"),
- ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
- ("Bullets and Numbering…", "app.bulletsNumberingDialog"),
- ] {
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
- let _ = app.run(id, json!({}));
- ui.close();
- }
- }
- });
- });
- });
+ let fw = full_width(ui);
+ let half = (fw - 10.0) / 2.0;
+ // Alignment, and the panel menu at the right.
let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default();
- ui.horizontal(|ui| {
- for (al, icon) in [
- (Align::Left, "align-left"),
- (Align::Center, "align-center"),
- (Align::Right, "align-right"),
- (Align::LeftJustified, "align-justify"),
- (Align::FullyJustified, "align-justify-all"),
- ] {
- if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"align": al}}));
- }
+ const GAP: f32 = 8.0;
+ let pitch = ((fw - 24.0 - 2.0 * GAP) / 9.0).clamp(16.0, 24.0);
+ let b = block(ui, 21.0);
+ let mut x = 0.0;
+ for (i, (al, icon)) in PARA_ALIGNS.into_iter().enumerate() {
+ if i == 3 || i == 7 {
+ x += GAP;
}
- });
- // Paragraph Direction (World-Ready): switching also mirrors left/right alignment.
- let rtl = p["direction"] == "rightToLeft";
- ui.horizontal(|ui| {
- caption(ui, crate::i18n::tr(&app.ui.language, "Direction"));
- for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
- if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on {
- let align = match cur {
- Align::Left => Align::Right,
- Align::Right => Align::Left,
- a => a,
- };
- let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}}));
- }
- }
- });
- egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
- for (row, ((la, ka), (lb, kb))) in [
- (("Left", "leftIndent"), ("Right", "rightIndent")),
- (("First", "firstLineIndent"), ("Last", "lastLineIndent")),
- (("Before", "spaceBefore"), ("After", "spaceAfter")),
- ]
- .into_iter()
- .enumerate()
+ if place(ui, sub(b, x, 0.0, pitch, 21.0), |ui| widgets::icon_toggle_sized(ui, icon, cur == al, &tr(al.label()), vec2(pitch, 21.0))).clicked()
{
- caption(ui, crate::i18n::tr(&app.ui.language, la));
- if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) {
- let _ = app.run("type.para", json!({"attrs": {ka: v}}));
- }
- caption(ui, crate::i18n::tr(&app.ui.language, lb));
- if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) {
- let _ = app.run("type.para", json!({"attrs": {kb: v}}));
- }
- ui.end_row();
+ let _ = app.run("type.para", json!({"attrs": {"align": al}}));
}
- // Drop cap: number of lines and characters.
- for (label, key, max, id) in [
- ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"),
- ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"),
- ] {
- caption(ui, crate::i18n::tr(&app.ui.language, label));
- if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) {
- let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
- }
- }
- ui.end_row();
- });
- ui.horizontal(|ui| {
- let mut h = p["hyphenate"].as_bool().unwrap_or(true);
- if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
- let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
- }
- let mut grid = p["gridAlign"].as_str() == Some("allLines");
- if ui.checkbox(&mut grid, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Align to baseline grid"))).changed() {
- let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}}));
- }
- });
- let mut optical = p["opticalMargin"].as_bool().unwrap_or(false);
- if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() {
- let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}}));
+ x += pitch;
}
- ui.horizontal(|ui| {
- caption(ui, crate::i18n::tr(&app.ui.language, "Composer"));
- let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str());
- if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}}));
+ place(ui, sub(b, fw - 20.0, 0.0, 20.0, 21.0), |ui| crate::menus::menu_button(ui, "☰", |ui| paragraph_panel_menu(app, ui, &p)));
+ ui.add_space(6.0);
+ // Indents, spacing and drop cap, two to a row as in InDesign.
+ let row = FIELD_H + 5.0;
+ let b = block(ui, 5.0 * row - 5.0);
+ let rows = [
+ (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")),
+ (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
+ (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
+ ];
+ for (i, (l, r)) in rows.into_iter().enumerate() {
+ for (right, (icon, tip, key)) in [(false, l), (true, r)] {
+ // The first and last line indents may be negative (a hanging line), as InDesign's.
+ let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 };
+ let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
+ if let Some(v) = half_field(ui, b, right, i as f32 * row, half, icon, &tr(tip), field) {
+ let _ = app.run("type.para", json!({"attrs": {key: v}}));
+ }
}
- if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() {
- let _ = app.run("type.para", json!({"attrs": {"composer": single_line}}));
+ }
+ // Space Between Paragraphs Using Same Style: Ignore (unset) or a space that replaces the
+ // space after and before between paragraphs of the same style.
+ let ignore = tr("Ignore");
+ let field = NumField::measure("sameParaStyleSpacing", p["sameParaStyleSpacing"].as_f64(), u)
+ .spinner()
+ .presets(SAME_STYLE_SPACING)
+ .empty_text(&ignore)
+ .range(0.0, 10_000.0);
+ if let Some(v) = half_field_or_clear(ui, b, false, 3.0 * row, half, "space-same", &tr("Space Between Paragraphs Using Same Style"), field) {
+ let _ = app.run("type.para", json!({"attrs": {"sameParaStyleSpacing": v}}));
+ }
+ for (right, icon, tip, key, max) in [
+ (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
+ (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
+ ] {
+ let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64);
+ if let Some(v) = half_field(ui, b, right, 4.0 * row, half, icon, &tr(tip), field) {
+ let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
}
- });
- // Paragraph Border and Shading.
- let swatches: Vec =
- app.session.active().map(|d| d.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default();
- for (on_key, color_key, label) in [("shadingOn", "shadingColor", "Shading"), ("borderOn", "borderColor", "Border")] {
- ui.horizontal(|ui| {
- let mut on = p[on_key].as_bool().unwrap_or(false);
- if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
- let _ = app.run("type.para", json!({ on_key: on }));
- }
- if on {
- let cur = p[color_key].as_str().unwrap_or("[Black]").to_string();
- egui::ComboBox::from_id_salt(("pbs", label)).selected_text(&cur).width(110.0).show_ui(ui, |ui| {
- for w in &swatches {
- if ui.selectable_label(*w == cur, w).clicked() {
- let _ = app.run("type.para", json!({ color_key: w }));
- }
- }
- });
- if label == "Shading" {
- if let Some(v) = number(ui, "pshtint", p["shadingTint"].as_f64().map(|t| t * 100.0), "%", 46.0, 0) {
- let _ = app.run("type.para", json!({"shadingTint": (v / 100.0).clamp(0.0, 1.0)}));
- }
- } else if let Some(v) = number(ui, "pbw", p["borderWeights"][0].as_f64(), " pt", 50.0, 2) {
- let w = [v.max(0.0); 4];
- let _ = app.run("type.para", json!({ "borderWeights": w }));
- }
- }
- });
}
- if p["shadingOn"].as_bool().unwrap_or(false) || p["borderOn"].as_bool().unwrap_or(false) {
- ui.horizontal(|ui| {
- caption(ui, crate::i18n::tr(&app.ui.language, "Offsets"));
- let o = p["borderOffsets"][0].as_f64().or(p["shadingOffsets"][0].as_f64());
- if let Some(v) = number(ui, "pbo", o, " pt", 50.0, 2) {
- let o = [v; 4];
- let _ = app.run("type.para", json!({ "borderOffsets": o, "shadingOffsets": o }));
+ // Shading and Border: on or off at the left; at the right the Paragraph Borders and Shading
+ // dialog on its tab and the colour.
+ ui.add_space(10.0);
+ let doc = app.session.active().map(|d| d.doc.clone());
+ let b = block(ui, 2.0 * row - 5.0);
+ for (i, (on_key, color_key, label, icon, tab, name)) in [
+ ("shadingOn", "shadingColor", "Shading", "para-shading", "shading", "Paragraph Shading"),
+ ("borderOn", "borderColor", "Border", "para-border", "border", "Paragraph Border"),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ let y = i as f32 * row;
+ let mut on = p[on_key].as_bool().unwrap_or(false);
+ if place(ui, sub(b, 0.0, y, half, FIELD_H), |ui| widgets::checkbox(ui, &mut on, &tr(label))).changed() {
+ let _ = app.run("type.para", json!({"attrs": {on_key: on}}));
+ }
+ if place(ui, sub(b, half + 10.0, y, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")))
+ .clicked()
+ {
+ let _ = app.run("app.paragraphBordersDialog", json!({"tab": tab}));
+ }
+ if let Some(doc) = &doc {
+ let color = p[color_key].as_str().unwrap_or("[Black]").to_string();
+ let w = half - 24.0;
+ if let Some(sw) = place(ui, sub(b, half + 34.0, y, w, FIELD_H), |ui| crate::chrome::swatch_field(ui, doc, &color, w, &tr(name))) {
+ let _ = app.run("type.para", json!({"attrs": {color_key: sw}}));
}
- });
+ }
+ }
+ // Hyphenate, and Do Not Align / Align to Baseline Grid.
+ ui.add_space(10.0);
+ let b = block(ui, FIELD_H);
+ let mut h = p["hyphenate"].as_bool().unwrap_or(true);
+ if place(ui, sub(b, 0.0, 0.0, half, FIELD_H), |ui| widgets::checkbox(ui, &mut h, &tr("Hyphenate"))).changed() {
+ let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
+ }
+ let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly"));
+ for (i, (icon, tip, on, v)) in
+ [("grid-none", "Do Not Align to Baseline Grid", !grid, "none"), ("grid-baseline", "Align to Baseline Grid", grid, "allLines")]
+ .into_iter()
+ .enumerate()
+ {
+ if place(ui, sub(b, fw - 48.0 + i as f32 * 25.0, 0.0, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, on, &tr(tip))).clicked() && !on {
+ let _ = app.run("type.para", json!({"attrs": {"gridAlign": v}}));
+ }
+ }
+ ui.add_space(4.0);
+}
+
+/// The Paragraph panel menu, in the order of InDesign's.
+fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) {
+ let lang = app.ui.language.clone();
+ let item = |ui: &mut Ui, label: &str, check: Option, enabled: bool| -> bool {
+ let label = crate::i18n::tr(&lang, label);
+ let text = match check {
+ Some(true) => format!("✓ {label}"),
+ Some(false) => format!(" {label}"),
+ None => label.to_string(),
+ };
+ let clicked = ui.add_enabled(enabled, egui::Button::new(crate::rtl::widget(ui, text))).clicked();
+ if clicked {
+ ui.close();
+ }
+ clicked
+ };
+ let mut attrs = None;
+ let mut command = None;
+ let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str());
+ if item(ui, "Paragraph Composer", Some(!single), true) {
+ attrs = Some(json!({"composer": paragraph}));
+ }
+ if item(ui, "Single-line Composer", Some(single), true) {
+ attrs = Some(json!({"composer": single_line}));
+ }
+ ui.separator();
+ let grid = p["gridAlign"].as_str().unwrap_or("none");
+ if item(ui, "Only Align First Line to Grid", Some(grid == "firstLineOnly"), grid != "none") {
+ attrs = Some(json!({"gridAlign": if grid == "firstLineOnly" { "allLines" } else { "firstLineOnly" }}));
+ }
+ let balance = p["balanceRagged"].as_bool().unwrap_or(false);
+ if item(ui, "Balance Ragged Lines", Some(balance), true) {
+ attrs = Some(json!({"balanceRagged": !balance}));
+ }
+ let optical = p["opticalMargin"].as_bool().unwrap_or(false);
+ if item(ui, "Optical Margin Alignment", Some(optical), true) {
+ attrs = Some(json!({"opticalMargin": !optical}));
+ }
+ ui.separator();
+ for (label, id) in [
+ ("Justification…", "app.justificationDialog"),
+ ("Keep Options…", "app.keepOptionsDialog"),
+ ("Span Columns…", "app.spanColumnsDialog"),
+ ("Hyphenation…", "app.hyphenationDialog"),
+ ("Drop Caps and Nested Styles…", "app.nestedStylesDialog"),
+ ("GREP Styles…", "app.grepStylesDialog"),
+ ("Paragraph Rules…", "app.paragraphRulesDialog"),
+ ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
+ ] {
+ if item(ui, label, None, true) {
+ command = Some(id);
+ }
+ }
+ ui.separator();
+ if item(ui, "Bullets and Numbering…", None, true) {
+ command = Some("app.bulletsNumberingDialog");
+ }
+ // Restart Numbering at 1, or Continue Numbering from the paragraph before, for a numbered list.
+ let numbered = p["listType"].as_str() == Some("numbers");
+ let restarts = p["startAt"].is_u64();
+ let label = match (numbered, restarts) {
+ (false, _) => "Restart/Continue Numbering",
+ (true, false) => "Restart Numbering",
+ (true, true) => "Continue Numbering",
+ };
+ if item(ui, label, None, numbered) {
+ attrs = Some(json!({"startAt": if restarts { Value::Null } else { json!(1) }}));
+ }
+ item(ui, "Convert Bullets and Numbering to Text", None, false);
+ if item(ui, "Define Lists…", None, true) {
+ command = Some("list.define");
+ }
+ // Paragraph Direction (World-Ready): switching also mirrors left and right alignment.
+ ui.separator();
+ let rtl = p["direction"] == "rightToLeft";
+ for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
+ if item(ui, label, Some(on), true) && !on {
+ let align = match serde_json::from_value(p["align"].clone()).unwrap_or_default() {
+ Align::Left => Align::Right,
+ Align::Right => Align::Left,
+ a => a,
+ };
+ attrs = Some(json!({"direction": dir, "align": align}));
+ }
+ }
+ if let Some(attrs) = attrs {
+ let _ = app.run("type.para", json!({"attrs": attrs}));
+ }
+ if let Some(id) = command {
+ crate::menus::activate(app, id, &json!({}));
}
- let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string();
- let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0);
- ui.horizontal(|ui| {
- caption(ui, crate::i18n::tr(&app.ui.language, "Style"));
- super::para_style_picker(app, ui, &ps, ov > 0, 190.0);
- });
}
/// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour.
@@ -3310,6 +3387,84 @@ mod tests {
assert!(h.query_by_label("Small Caps").is_none());
}
+ #[test]
+ fn paragraph_panel_is_laid_out_as_indesigns_with_its_panel_menu() {
+ let mut h = panel_harness(text_app(), paragraph_panel);
+ h.run_steps(3);
+ for l in [
+ "Left Indent",
+ "Right Indent",
+ "First Line Left Indent",
+ "Last Line Right Indent",
+ "Space Before",
+ "Space After",
+ "Space Between Paragraphs Using Same Style",
+ "Drop Cap Number of Lines",
+ "Drop Cap One or More Characters",
+ "Paragraph Shading",
+ "Paragraph Border",
+ "Hyphenate",
+ "Do Not Align to Baseline Grid",
+ ] {
+ let _ = h.get_by_label(l);
+ }
+ let para = |h: &mut Harness<'static, DesignApp>| text_attrs(h.state_mut()).unwrap()["para"].clone();
+ // Space Between Paragraphs Using Same Style starts at Ignore; a value is set, and emptying
+ // the field ignores it again.
+ assert!(para(&mut h)["sameParaStyleSpacing"].is_null());
+ let field = h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap();
+ field.focus();
+ field.type_text("6 pt");
+ h.run_steps(1);
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ assert_eq!(para(&mut h)["sameParaStyleSpacing"], 6.0);
+ h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap().focus();
+ h.run_steps(1);
+ for _ in 0..8 {
+ h.key_press(egui::Key::Backspace);
+ }
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ assert!(para(&mut h)["sameParaStyleSpacing"].is_null(), "Ignore");
+ // Shading on, then the grid buttons and Only Align First Line to Grid from the menu.
+ h.get_by_label("Shading").click();
+ h.run_steps(3);
+ assert_eq!(para(&mut h)["shadingOn"], true);
+ h.get_by_label("Align to Baseline Grid").click();
+ h.run_steps(3);
+ assert_eq!(para(&mut h)["gridAlign"], "allLines");
+ h.get_by_label("☰").click();
+ h.run_steps(3);
+ for l in [
+ " Balance Ragged Lines",
+ "Keep Options…",
+ "Drop Caps and Nested Styles…",
+ "GREP Styles…",
+ "Define Lists…",
+ "Restart/Continue Numbering",
+ ] {
+ let _ = h.get_by_label(l);
+ }
+ h.get_by_label(" Only Align First Line to Grid").click();
+ h.run_steps(3);
+ assert_eq!(para(&mut h)["gridAlign"], "firstLineOnly");
+ h.get_by_label("Do Not Align to Baseline Grid").click();
+ h.run_steps(3);
+ assert_eq!(para(&mut h)["gridAlign"], "none");
+ // Keep Options… opens the selected paragraphs' Keep Options, and OK applies them.
+ h.get_by_label("☰").click();
+ h.run_steps(3);
+ h.get_by_label("Keep Options…").click();
+ h.run_steps(3);
+ let app = h.state_mut();
+ let d = app.ui.dialog.as_mut().expect("Keep Options");
+ assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "keep"));
+ d.fields.insert("p.keepWithNext".into(), json!(2));
+ crate::dialogs::confirm(app).unwrap();
+ assert_eq!(para(&mut h)["keepWithNext"], 2);
+ }
+
fn kerning(app: &mut DesignApp) -> Value {
text_attrs(app).unwrap()["chars"]["kerning"].clone()
}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index d8eb6c51e..e820e21a8 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -82,6 +82,8 @@ pub struct NumField<'a> {
parens: bool,
/// The widget naming the field (an icon or caption beside it), for accessibility.
label: Option,
+ /// What an unset value shows (InDesign's "Ignore"), and the first of the presets.
+ empty: Option<&'a str>,
}
impl<'a> NumField<'a> {
@@ -104,6 +106,7 @@ impl<'a> NumField<'a> {
enabled: true,
parens: false,
label: None,
+ empty: None,
}
}
/// Total width (spinner and preset segments included).
@@ -151,6 +154,13 @@ impl<'a> NumField<'a> {
self
}
+ /// An unset value shows `text` (dimmed), and the preset menu offers it first: picking it, or
+ /// emptying the field, commits `Some(None)` from [`NumField::show_or_clear`].
+ pub fn empty_text(mut self, text: &'a str) -> Self {
+ self.empty = Some(text);
+ self
+ }
+
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
pub fn show(self, ui: &mut Ui) -> Option {
@@ -214,13 +224,16 @@ impl<'a> NumField<'a> {
// Value.
ui.painter().rect_filled(text_rect, 0.0, bg);
let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default());
- let te = egui::TextEdit::singleline(&mut buf)
+ let mut te = egui::TextEdit::singleline(&mut buf)
.id(focused_id)
.frame(egui::Frame::NONE)
.font(label_font())
.text_color(fg)
.margin(egui::Margin::ZERO)
.vertical_align(egui::Align::Center);
+ if let Some(empty) = self.empty {
+ te = te.hint_text(egui::RichText::new(empty).font(label_font()).color(t.text_dim));
+ }
// The field already allocated its slot. A scoped allocation here would consume another
// grid cell and enlarge toolbar rows; the value editor must stay inside that same slot.
let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0));
@@ -283,6 +296,12 @@ impl<'a> NumField<'a> {
if let Some(resp) = preset_resp {
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(self.width);
+ if let Some(empty) = self.empty
+ && ui.selectable_label(self.value.is_none(), empty).clicked()
+ {
+ cleared = self.value.is_some();
+ ui.close();
+ }
for v in self.presets {
if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() {
out = Some(*v);
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 904484179..c3a5cbbaf 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -375,7 +375,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 ``/`` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
-- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
+- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel menu, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. Polish covers 1,717 catalog keys with the same category-first count captions and English fallback.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, `xmp:MetadataDate` and `xmpMM:VersionID` in XMP, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
From bc50eb5564af5d557b04fc914791995288eb8d56 Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 14:09:12 +0200
Subject: [PATCH 044/135] Hyphenate Across Column: no column or frame ends in a
hyphen when it's off
With Hyphenate Across Column off, a paragraph whose line ending at an
automatic hyphenation point would end a column or frame is re-laid
without that point, so the word moves on whole, as InDesign does. The
points left out are kept per paragraph across re-lays and cleared when an
earlier paragraph is re-laid. On by default, as in InDesign.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/compose/src/lib.rs | 100 ++++++++++++++++++++++++++++++++----
crates/compose/src/tests.rs | 39 ++++++++++++++
docs/typography-parity.md | 2 +-
4 files changed, 131 insertions(+), 11 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 8a2352e86..57b32ca0d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. On by default, as in InDesign, so existing layouts don't change. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 89ea89bd3..658fc116f 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -845,6 +845,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec> = vec![None; np];
+ // Hyphenate Across Column off: the hyphenation points (glyph indices) a paragraph is re-laid
+ // without, because a line ending at one of them ended a column or frame.
+ let mut no_hyph: Vec> = vec![Vec::new(); np];
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -884,7 +887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget)
{
balance_runs += 1;
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -894,7 +897,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget)
{
balance_runs += 1;
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -948,7 +951,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1218,7 +1221,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
apply_desired_spacing(&mut glyphs, &pp);
mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi);
- let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
+ let mut hyph_after =
+ hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
+ for &k in no_hyph.get(pi).into_iter().flatten() {
+ if let Some(h) = hyph_after.get_mut(k) {
+ *h = false;
+ }
+ }
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
// An empty paragraph takes its height from its paragraph mark's character format.
@@ -1325,7 +1334,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
trial = Some(t);
balance_runs += 1;
- rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(
+ key.1,
+ &snaps,
+ ¬e_snaps,
+ &mut notes,
+ &mut out,
+ &mut cur,
+ &mut list_counter,
+ &mut force_col,
+ &mut line_cap,
+ &mut no_hyph,
+ );
pi = key.1;
continue 'paras;
}
@@ -1361,6 +1381,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
+ // The glyph after which the last line set ended at an automatic hyphenation point.
+ let mut hyphen_at: Option = None;
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;
@@ -1378,7 +1400,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1605,6 +1627,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
0.0
};
let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped;
+ if !fits
+ && !pp.hyph_across_column
+ && line_no > col_first_line
+ && let Some(at) = hyphen_at
+ && restores < (4 * np + 64) * (balance_runs + 1)
+ {
+ restores += 1;
+ no_hyph[pi].push(at);
+ rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+ continue 'paras;
+ }
if !fits {
if cur.can_next_sub() {
g0 = s;
@@ -1626,7 +1659,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if let Some(j) = j {
split_runs += 1;
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(
+ j,
+ &snaps,
+ ¬e_snaps,
+ &mut notes,
+ &mut out,
+ &mut cur,
+ &mut list_counter,
+ &mut force_col,
+ &mut line_cap,
+ &mut no_hyph,
+ );
pi = j;
continue 'paras;
}
@@ -1659,7 +1703,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
j
}
};
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(
+ j,
+ &snaps,
+ ¬e_snaps,
+ &mut notes,
+ &mut out,
+ &mut cur,
+ &mut list_counter,
+ &mut force_col,
+ &mut line_cap,
+ &mut no_hyph,
+ );
pi = j;
continue 'paras;
}
@@ -1765,7 +1820,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
{
- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(
+ j,
+ &snaps,
+ ¬e_snaps,
+ &mut notes,
+ &mut out,
+ &mut cur,
+ &mut list_counter,
+ &mut force_col,
+ &mut line_cap,
+ &mut no_hyph,
+ );
pi = j;
continue 'paras;
}
@@ -1797,6 +1863,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
cur.grid_next = grid_end.map(|end| (baseline, end));
+ hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true));
line_no += 1;
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
@@ -1924,7 +1991,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
split_trial = Some(t);
split_runs += 1;
- rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ rewind(
+ key.2,
+ &snaps,
+ ¬e_snaps,
+ &mut notes,
+ &mut out,
+ &mut cur,
+ &mut list_counter,
+ &mut force_col,
+ &mut line_cap,
+ &mut no_hyph,
+ );
pi = key.2;
continue 'paras;
}
@@ -2282,9 +2360,11 @@ fn rewind(
list_counter: &mut ListCounter,
force_col: &mut [bool],
line_cap: &mut [Option],
+ no_hyph: &mut [Vec],
) {
force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);
line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None);
+ no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear);
if let Some(s) = snaps.get(j) {
s.restore(out, cur, list_counter);
}
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 15c412ada..6045d667e 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -1013,6 +1013,45 @@ fn hyphenation_zone_limits_ragged_hyphens() {
assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}");
}
+#[test]
+fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() {
+ let text = CORPUS.join(" ");
+ // Column-end lines that are hyphenated, over frame heights that end the first column at each
+ // line in turn.
+ let ends = |across: bool, composer: designcraft_doc::Composer| {
+ let mut ends = 0;
+ let mut lines = 0;
+ for h in (30..400).step_by(3) {
+ let para = ParaAttrs {
+ align: Some(Align::Left),
+ hyph_weight: Some(0.0),
+ hyph_across_column: Some(across),
+ composer: Some(composer),
+ ..Default::default()
+ };
+ let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para);
+ d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect();
+ ends += usize::from(col0.last().is_some_and(|l| l.hyphenated));
+ lines += col0.len();
+ // The frame's last line too, when the text goes on (overset).
+ let last = all_lines(&cs).into_iter().last();
+ ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para));
+ }
+ (ends, lines)
+ };
+ for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
+ let (on, lines_on) = ends(true, c);
+ let (off, lines_off) = ends(false, c);
+ // With it on, columns do end in hyphens here; with it off, none does, and the columns
+ // still fill.
+ assert!(on > 3, "{c:?}: {on}");
+ assert_eq!(off, 0, "{c:?}");
+ assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}");
+ }
+}
+
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
diff --git a/docs/typography-parity.md b/docs/typography-parity.md
index 68d7a5d9b..7408e3d4a 100644
--- a/docs/typography-parity.md
+++ b/docs/typography-parity.md
@@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated.
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
-| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
+| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
From 80cf719555d9115ee6a130a8b4482b17a6b9ee04 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 17:12:15 +0500
Subject: [PATCH 045/135] A type size outside 0.1 to 1296 pt is refused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nothing checked type size. `type.char {"size": 0}` set type with no size at
all, a negative size went in the same way, and 1e9 pt asked the composer for a
line a million times the height of the page — all of it silent, and all of it
saved to the document.
The range is the one this engine already applies to a ruby annotation's size,
0.1 to 1296 pt, which is InDesign's: "size 0: 0.1 to 1296 pt".
It sits in `validate_char_fill`'s place in the chain, so every command that
takes character attributes gets it: `type.char`, `find.change`, the four
`style.character` / `style.paragraph` create and edit commands, and the two
table paths that already validated a fill.
The test covers five refused sizes, the three in range including both ends, and
two of the style commands. Without the check, `{"size": 0}` applies again.
Not from a ticket: found probing `type.char`.
---
crates/engine/src/cmd/text.rs | 56 +++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index 12bd540b7..dbfbcfe15 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result {
/// A character fill must name a swatch of the document.
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
+ validate_char_size(attrs, command_id)?;
match &attrs.fill {
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
None => Ok(()),
}
}
+/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
+/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
+/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
+pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
+ match attrs.size {
+ Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
+ Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
+ }
+ _ => Ok(()),
+ }
+}
+
+/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
+pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
+pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
+
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
let _ = ItemId(rect);
}
}
+
+#[cfg(test)]
+mod size_bounds_tests {
+ use serde_json::json;
+
+ use crate::Session;
+
+ /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
+ /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
+ /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
+ #[test]
+ fn a_type_size_outside_the_range_is_refused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 1})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
+ let select = |s: &mut Session| {
+ s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
+ };
+ for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
+ select(&mut s);
+ let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
+ assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
+ }
+ // The sizes in range still apply, including the ends.
+ for size in [0.1, 12.0, 1296.0] {
+ select(&mut s);
+ s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
+ assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
+ }
+ // And the styles that carry character attributes are checked the same way.
+ for (command, params) in [
+ ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
+ ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
+ ] {
+ let e = s.execute(command, ¶ms).expect_err("refused").to_string();
+ assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
+ }
+ }
+}
From 2088cee52d13ff9186db44aa8b6e7bca50fe776e Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 14:55:59 +0200
Subject: [PATCH 046/135] Hyphenate Across Column: nested line styles and its
own re-lay bound
The points left out are recorded as story bytes, so the nested line styles
prepass (which indexes glyphs differently) leaves them out too, and a
style still covers exactly the lines it is set on. Their re-lays have a
bound of their own: each leaves out one more point of the paragraph, and
the keep options' budget no longer stops them.
Co-Authored-By: Claude Opus 5.5
---
crates/compose/src/lib.rs | 37 +++++++++++++++++++----------
crates/compose/src/tests.rs | 47 +++++++++++++++++++++++++++++++++++++
2 files changed, 72 insertions(+), 12 deletions(-)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 658fc116f..acff8cc97 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -845,9 +845,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec> = vec![None; np];
- // Hyphenate Across Column off: the hyphenation points (glyph indices) a paragraph is re-laid
- // without, because a line ending at one of them ended a column or frame.
+ // Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid
+ // without, because a line ending at one of them ended a column or frame. Each re-lay leaves
+ // out one more point of the paragraph, so they are bounded by its points; the cap only guards
+ // against keep options clearing them over and over.
let mut no_hyph: Vec> = vec![Vec::new(); np];
+ let mut hyph_relays = 0usize;
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -1086,7 +1089,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
apply_desired_spacing(&mut gl, &pp);
mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi);
- let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
+ let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
+ suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
let tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
@@ -1223,11 +1227,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi);
let mut hyph_after =
hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
- for &k in no_hyph.get(pi).into_iter().flatten() {
- if let Some(h) = hyph_after.get_mut(k) {
- *h = false;
- }
- }
+ suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
// An empty paragraph takes its height from its paragraph mark's character format.
@@ -1381,7 +1381,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
- // The glyph after which the last line set ended at an automatic hyphenation point.
+ // The story byte at which the last line set ended at an automatic hyphenation point.
let mut hyphen_at: Option = None;
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
@@ -1631,9 +1631,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& !pp.hyph_across_column
&& line_no > col_first_line
&& let Some(at) = hyphen_at
- && restores < (4 * np + 64) * (balance_runs + 1)
+ && hyph_relays < (story.text.len() + 64) * (restores + 1)
{
- restores += 1;
+ hyph_relays += 1;
no_hyph[pi].push(at);
rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
continue 'paras;
@@ -1863,7 +1863,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
cur.grid_next = grid_end.map(|end| (baseline, end));
- hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true));
+ hyphen_at =
+ e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
line_no += 1;
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
@@ -4064,6 +4065,18 @@ fn foreign_ranges(
.collect()
}
+/// Leave out the hyphenation points after the glyphs that end at story bytes `at`.
+fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) {
+ if at.is_empty() {
+ return;
+ }
+ for (h, g) in hy.iter_mut().zip(glyphs) {
+ if *h && at.contains(&(g.byte + g.len)) {
+ *h = false;
+ }
+ }
+}
+
fn hyphenation_points(
text: &str,
glyphs: &[Glyph],
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 6045d667e..f07fdc927 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -1052,6 +1052,53 @@ fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() {
}
}
+#[test]
+fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
+ // The first line in a character style of another colour (the same widths): when its hyphen is
+ // left out at a column end, the style still covers exactly the first line as set.
+ let text = CORPUS.join(" ");
+ let compose = |w: f64, h: f64, across: bool| {
+ let para = ParaAttrs {
+ align: Some(Align::Left),
+ hyph_weight: Some(0.0),
+ hyph_across_column: Some(across),
+ nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]),
+ ..Default::default()
+ };
+ let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para);
+ d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
+ let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() };
+ std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
+ name: "Lead".into(),
+ based_on: None,
+ chars,
+ shortcut: String::new(),
+ });
+ compose_story(&d, sid, &ComposeOptions::default())
+ };
+ let mut checked = 0;
+ for w in (160..320).step_by(10) {
+ for h in (12..40).step_by(2) {
+ // Where, with the setting on, a hyphenated first line ends column 1.
+ let on = compose(w as f64, h as f64, true);
+ let lines = all_lines(&on);
+ if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) {
+ continue;
+ }
+ let cs = compose(w as f64, h as f64, false);
+ let lines = all_lines(&cs);
+ assert!(!lines[0].hyphenated, "{w}×{h}");
+ let lead = |l: &Line| {
+ l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::>()
+ };
+ assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line");
+ assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line");
+ checked += 1;
+ }
+ }
+ assert!(checked > 3, "{checked}");
+}
+
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
From be549959adfc5b7c7bafdfc451b06003ec30bd0b Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 14:59:48 +0200
Subject: [PATCH 047/135] i18n: Hyphenate Capitalized Words means words with an
initial capital (it, pt-BR, cs)
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/i18n/cs.rs | 2 +-
crates/ui-egui/src/i18n/it.rs | 2 +-
crates/ui-egui/src/i18n/pt_br.rs | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index c9f223542..50af0c218 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -37,7 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Hyphenation Zone:", "Zóna dělení:"),
("Better Spacing", "Lepší mezery"),
("Fewer Hyphens", "Méně spojovníků"),
- ("Hyphenate Capitalized Words", "Dělit slova s velkými písmeny"),
+ ("Hyphenate Capitalized Words", "Dělit slova s velkým počátečním písmenem"),
("Hyphenate Last Word", "Dělit poslední slovo"),
("Hyphenate Across Column", "Dělit přes sloupce"),
("Reverse Path", "Obrátit cestu"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
index 65837d395..125f6013d 100644
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -34,7 +34,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Hyphenation Zone:", "Area di sillabazione:"),
("Better Spacing", "Spaziatura migliore"),
("Fewer Hyphens", "Meno trattini"),
- ("Hyphenate Capitalized Words", "Sillaba parole maiuscole"),
+ ("Hyphenate Capitalized Words", "Sillaba parole con iniziale maiuscola"),
("Hyphenate Last Word", "Sillaba ultima parola"),
("Hyphenate Across Column", "Sillaba tra colonne"),
("Paste Vector Art", "Incolla grafica vettoriale"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
index 4513a107f..5fa35b6b8 100644
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -37,7 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Hyphenation Zone:", "Zona de hifenização:"),
("Better Spacing", "Melhor espaçamento"),
("Fewer Hyphens", "Menos hifens"),
- ("Hyphenate Capitalized Words", "Hifenizar palavras em maiúsculas"),
+ ("Hyphenate Capitalized Words", "Hifenizar palavras iniciadas por maiúscula"),
("Hyphenate Last Word", "Hifenizar última palavra"),
("Hyphenate Across Column", "Hifenizar entre colunas"),
("Paste Vector Art", "Colar arte vetorial"),
From 99c47da919a38d5a5731fd1407afc240f11a813c Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 15:07:06 +0200
Subject: [PATCH 048/135] List buttons: a selection ending at a paragraph start
leaves it out; a style hanging indent stays
A selection ending right after a return (a whole paragraph, as a triple
click selects it) applies the list to that paragraph only, and the button
shows pressed for it; a whole frame still takes in its last, empty
paragraph. Turning a list off clears only a hanging indent set on the
paragraph itself: one the paragraph style gives stays.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/lists.rs | 70 ++++++++++++++++++++++++++++++++--
1 file changed, 67 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs
index cf89273f4..24578bc42 100644
--- a/crates/engine/src/cmd/lists.rs
+++ b/crates/engine/src/cmd/lists.rs
@@ -91,13 +91,16 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
- st.format_paras(t.range.clone(), |pf| {
+ let span = para_span(&st.text, &t.range);
+ st.format_paras(span, |pf| {
let (now, _) = styles.resolve_para(pf);
// Off takes away this list only: a paragraph with the other list keeps it.
if !on && now.list_type != kind {
return;
}
let (style, _) = styles.resolve_para_style(&pf.style);
+ // The hanging indent set on the paragraph itself (by a list button), not by its style.
+ let own_hang = pf.para.left_indent == Some(LIST_HANG) && pf.para.first_line_indent == Some(-LIST_HANG);
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
@@ -107,7 +110,8 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
- } else if !on && now.list_type == kind && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG {
+ } else if !on && now.list_type == kind && own_hang {
+ // Only the hanging indent set here: one the paragraph style gives stays.
set("leftIndent", json!(0.0), style.left_indent == 0.0);
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
}
@@ -117,11 +121,20 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
})
}
+/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after
+/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final
+/// return, stays in when the range runs to the end (a whole frame).
+fn para_span(text: &str, range: &std::ops::Range) -> std::ops::Range {
+ let end = range.end.max(range.start);
+ if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end }
+}
+
/// Whether every paragraph of `targets` has list `kind`.
fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
targets.iter().all(|t| {
d.text_story(t.story, t.cell).is_some_and(|st| {
- let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start)));
+ let span = para_span(&st.text, &t.range);
+ let (a, b) = (st.para_at(span.start), st.para_at(span.end));
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
})
})
@@ -274,4 +287,55 @@ mod tests {
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '4');
}
+
+ /// A selection ending right after a return (the whole first paragraph, as a triple click selects
+ /// it) applies the list to that paragraph only, and the button shows it pressed.
+ #[test]
+ fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() {
+ use designcraft_doc::ListType;
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let f = s
+ .execute(
+ "frame.create",
+ &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs
+Milk
+Flour
+"}),
+ )
+ .unwrap();
+ let sid = f["story"].as_u64().unwrap();
+ let kinds = |s: &Session| {
+ let d = &s.doc().unwrap().doc;
+ d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::>()
+ };
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
+ assert!(!s.selection_has_list(ListType::Bullets));
+ assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
+ assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]);
+ assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected");
+ // The whole frame still takes in its last, empty paragraph.
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap();
+ s.execute("list.numbers", &json!({"on": true})).unwrap();
+ assert_eq!(kinds(&s), [ListType::Numbers; 4]);
+ }
+
+ /// A hanging indent the paragraph style gives stays when the list goes off.
+ #[test]
+ fn a_style_hanging_indent_stays_when_the_list_goes_off() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
+ let sid = f["story"].as_u64().unwrap();
+ s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
+ s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap();
+ s.execute("list.bullets", &json!({"on": true})).unwrap();
+ s.execute("list.bullets", &json!({"on": false})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
+ let p = d.styles.resolve_para(pf).0;
+ assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
+ assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph");
+ }
}
From ebed0f7a05ff7d3ca570df8ed2d7d26c2438e82d Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 15:09:24 +0200
Subject: [PATCH 049/135] Docs: list buttons keep a style hanging indent and
stop at a selected return
Co-Authored-By: Claude Opus 5.5
---
docs/target-app-parity.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 904484179..c26b0384f 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -402,7 +402,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web).
- 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.
-- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if it is still as applied. One undo step; values equal to the paragraph style's are left to the style.
+- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it and it is still as applied (a hanging indent the paragraph style gives stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style.
- 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.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
From d2bdb0866fb73cf9aac012dc4c08a8854ae2b92e Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:26:43 -0400
Subject: [PATCH 050/135] Paste: other apps' text goes into a text caret after
copying objects (#1344)
* Paste: other apps' text goes into a text caret even after copying objects (#212)
* Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212)
---
crates/engine/src/cmd/anchored.rs | 2 +-
crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++++++++++++++--
crates/engine/src/cmd/mod.rs | 1 +
crates/engine/src/cmd/object.rs | 3 +-
crates/ui-egui/src/lib.rs | 31 +--------
5 files changed, 108 insertions(+), 38 deletions(-)
diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs
index dc7127459..6d09f89dd 100644
--- a/crates/engine/src/cmd/anchored.rs
+++ b/crates/engine/src/cmd/anchored.rs
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
-fn check_item(it: &Item) -> Result<()> {
+pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index e5a2d2296..fb8848a90 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result> {
Ok(Some(text))
}
+/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
+/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
+/// copied objects", not as text.
+pub fn copied_objects_text(s: &Session) -> String {
+ let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
+ let mut stories = Vec::new();
+ for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
+ it.walk(&mut |i| {
+ if let Some(tf) = i.text_frame()
+ && !stories.contains(&tf.story)
+ {
+ stories.push(tf.story);
+ }
+ });
+ }
+ let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n");
+ if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
+}
+
+/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
+/// it is their placeholder, so Paste stands for the copied objects.
+fn is_objects_placeholder(s: &Session, text: &str) -> bool {
+ s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
+}
+
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
+/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
+/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
+/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
- // Items copied last (no text copied since) paste into the text as anchored objects.
- if formatted
- && clip.is_none()
+ let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
+ if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
- if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
+ if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
- let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
+ // Fresh ids, story and asset copies for every paste, as on the page.
+ let mut stories = std::collections::HashMap::new();
+ let fresh: Vec = list
+ .iter()
+ .map(|i| {
+ let mut i = i.clone();
+ super::object::renumber(d, &items, &mut i, &mut stories);
+ if d.layer(i.layer).is_none() {
+ i.layer = d.default_layer();
+ }
+ i
+ })
+ .collect();
+ let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
+ // U+FFFC stands for objects with no text.
+ let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -327,7 +369,8 @@ pub fn specs() -> Vec {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
- if let Some(text) = text {
+ // The placeholder of an object copy pastes the copied objects.
+ if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -538,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
+ /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
+ #[test]
+ fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let story = frame(&mut s, "ab");
+ let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
+ s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
+ s.execute("edit.copy", &json!({})).unwrap();
+ let marker = copied_objects_text(&s);
+ s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
+ let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
+ assert!(r.get("anchored").is_none(), "{r}");
+ assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
+ // What the object copy put on the system clipboard still pastes the objects.
+ assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
+ // With no insertion point too: the rectangle, not a text frame holding the marker.
+ s.execute("edit.deselectAll", &json!({})).unwrap();
+ let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
+ let id = ItemId(r["ids"][0].as_u64().unwrap());
+ assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
+ s.doc().unwrap().doc.check().unwrap();
+ // Paste without Formatting inserts the text given; empty text is no text.
+ s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
+ assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
+ }
+
+ /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
+ #[test]
+ fn copied_text_frames_paste_at_a_caret_as_their_text() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let target = frame(&mut s, "ab");
+ let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
+ s.execute("selection.set", &json!({"ids": ids})).unwrap();
+ s.execute("edit.copy", &json!({})).unwrap();
+ s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
+ s.execute("edit.paste", &json!({"text": text})).unwrap()
+ };
+ let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
+ // The same text copied in another app, and the system clipboard read back as absent.
+ let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
+ assert!(r.get("anchored").is_none(), "{r}");
+ s.execute("edit.paste", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
+ // A group with a text frame inside, its own placeholder on the system clipboard.
+ let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
+ let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
+ let r = copy_and_paste(&mut s, json!([group]), Value::Null);
+ assert!(r.get("anchored").is_none(), "{r}");
+ assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
+ s.doc().unwrap().doc.check().unwrap();
+ }
+
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index a4d5413bb..1d6294ea6 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -17,6 +17,7 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
+pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 82f33a6d8..4ccb5c823 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
-fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) {
+/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
+pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 3674b3e81..0c155851f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -810,7 +810,7 @@ impl DesignApp {
Some(t.to_string())
}
None if !self.native_menu => {
- self.object_clip_text = Some(self.copied_objects_text());
+ self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session));
self.object_clip_text.clone()
}
None => {
@@ -910,35 +910,6 @@ impl DesignApp {
params
}
- /// Plain text for the system clipboard after copying objects: the copied text frames' stories,
- /// or U+FFFC (object replacement character) when none has text.
- fn copied_objects_text(&self) -> String {
- let mut stories = Vec::new();
- if let Some(clip) = &self.session.clipboard
- && let Some(sp) = clip.spreads.first()
- {
- for it in &sp.items {
- it.walk(&mut |i| {
- if let Some(tf) = i.text_frame()
- && !stories.contains(&tf.story)
- {
- stories.push(tf.story);
- }
- });
- }
- }
- let text = stories
- .iter()
- .filter_map(|id| self.session.clipboard.as_ref()?.story(*id))
- .map(|st| {
- st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::()
- })
- .filter(|t| !t.is_empty())
- .collect::>()
- .join("\n");
- if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
- }
-
/// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
pub fn after_engine(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
From 1500b444b57a20d7d3fa7194faab94f853788bee Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:26:51 -0400
Subject: [PATCH 051/135] Hidden characters: trailing spaces show their dots
before the paragraph mark (#1345)
* Hidden characters: trailing spaces show their dots before the paragraph mark (#594)
* Hidden characters: skip glyphs whose offset isn't a character boundary
---
crates/ui-egui/src/canvas.rs | 77 ++++++++++++++++++++++++++----------
1 file changed, 57 insertions(+), 20 deletions(-)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 9868d4d8b..ae6394bbd 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2716,33 +2716,47 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
for l in &ft.lines {
let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;
let font = egui::FontId::proportional(size);
- for g in &l.glyphs {
- if g.len == 0 {
- continue;
- }
- let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' ');
- let mark = match ch {
- ' ' => "·",
- '\t' => "»",
- '\u{2028}' => "¬",
- '\u{A0}' => "°",
- _ => continue,
- };
- let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline));
- let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
+ for (x, mark, centered) in hidden_marks(&story.text, l) {
+ let p = xf.to_screen(m * Point::new(x, l.baseline));
+ let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
painter.text(p, align, mark, font.clone(), col);
}
- if l.last_in_para {
- let end = l.range.end;
- let mark = if end >= story.text.len() { "#" } else { "¶" };
- let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline));
- painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col);
- }
}
}
}
}
+/// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and
+/// whether the mark is centered on x (spaces) or starts at it.
+fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> {
+ let mut out = Vec::new();
+ for g in &l.glyphs {
+ if g.len == 0 {
+ continue;
+ }
+ let Some(ch) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue };
+ let mark = match ch {
+ ' ' => "·",
+ '\t' => "»",
+ '\u{2028}' => "¬",
+ '\u{A0}' => "°",
+ _ => continue,
+ };
+ if ch == ' ' {
+ out.push((g.x + g.adv / 2.0, mark, true));
+ } else {
+ out.push((g.x, mark, false));
+ }
+ }
+ if l.last_in_para {
+ // The spaces trimmed off the line's end sit past `end_x`; the paragraph mark follows them.
+ let end = l.glyphs.iter().filter(|g| g.len > 0 && !g.visible && g.x >= l.end_x - 1e-9).map(|g| g.x + g.adv).fold(l.end_x, f64::max);
+ let mark = if l.range.end >= text.len() { "#" } else { "¶" };
+ out.push((end + 1.0, mark, false));
+ }
+ out
+}
+
/// View › Flattener Preview: red over every object that involves transparency (opacity, blend
/// modes, effects, knockout) — what flattening or export rasterising affects.
fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
@@ -5560,6 +5574,29 @@ mod tests {
assert_eq!(size, 18.0, "{key:?} applies the typed size");
}
}
+
+ #[test]
+ fn trailing_spaces_show_dots_before_the_paragraph_mark() {
+ let text = "ab \ncd ";
+ let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
+ let lid = d.default_layer();
+ let rect = DRect::new(0.0, 0.0, 300.0, 100.0);
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, Default::default()).unwrap();
+ let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default());
+ let lines: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).collect();
+ assert_eq!(lines.len(), 2);
+ for (l, end_mark) in lines.iter().zip(["¶", "#"]) {
+ let marks = hidden_marks(text, l);
+ let dots: Vec = marks.iter().filter(|m| m.1 == "·").map(|m| m.0).collect();
+ assert_eq!(dots.len(), 2, "a dot for each trailing space: {marks:?}");
+ let &(para_x, mark, _) = marks.last().unwrap();
+ assert_eq!(mark, end_mark);
+ // The dots sit past the last letter, and the mark comes after both spaces.
+ let space = l.glyphs.iter().rev().find(|g| g.len > 0).unwrap();
+ assert!(dots.iter().all(|&x| x > l.end_x && x < para_x), "{dots:?} then {mark} at {para_x}");
+ assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space");
+ }
+ }
}
#[cfg(test)]
From 291b39a1f30ee7b2df53dab6c18cc78e141468ac Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:27:00 -0400
Subject: [PATCH 052/135] Edit: the Paste in Place shortcut pastes in place
(#1346)
* Edit: the Paste in Place shortcut pastes in place
* Edit: the Paste in Place chord handling is native-only (web gets the key press)
---
crates/ui-egui/src/canvas.rs | 71 ++++++++++++++++++++++++++++++++++++
crates/ui-egui/src/menus.rs | 14 +++++++
2 files changed, 85 insertions(+)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ae6394bbd..318f82388 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1583,6 +1583,21 @@ fn field_focused_id(ctx: &egui::Context) -> egui::Id {
pub fn begin_frame(ctx: &egui::Context) {
let (id, focused) = (field_focused_id(ctx), ctx.text_edit_focused());
ctx.data_mut(|d| d.insert_temp(id, focused));
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let now = ctx.input(|i| i.modifiers);
+ // The modifiers last frame ended with are the ones this frame's events began with.
+ ctx.data_mut(|d| {
+ let (_, end) = d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id()).unwrap_or((now, now));
+ d.insert_temp(modifiers_id(), (end, now));
+ });
+ }
+}
+
+/// (held when this frame's events began, held after them).
+#[cfg(not(target_arch = "wasm32"))]
+fn modifiers_id() -> egui::Id {
+ egui::Id::new("canvas-frame-modifiers")
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
@@ -2546,7 +2561,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
// text must not delete the character before it).
let composing_at_start = app.session.tool_composing();
let evs = ui.input(|i| i.events.clone());
+ // Native only: egui-winit sends a ⌘+V chord as a paste without its key press, so the modifiers
+ // held at the paste tell Paste in Place from Paste. The web also gets the key press, and the
+ // shortcut table has already run the command. A chord's modifiers may be released in the same
+ // frame, hence the modifiers held at each event.
+ #[cfg(not(target_arch = "wasm32"))]
+ let mut held =
+ ui.data(|d| d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id())).map_or_else(|| ui.input(|i| i.modifiers), |(start, _)| start);
for e in evs {
+ #[cfg(not(target_arch = "wasm32"))]
+ let chord = {
+ if let egui::Event::ModifiersChanged(m) = &e {
+ held = *m;
+ }
+ if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, held) } else { None }
+ };
let composing = composing_at_start || app.session.tool_composing();
match e {
egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi),
@@ -2568,6 +2597,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
let _ = app.run(id, json!({}));
}
+ // Paste in Place's chord (⌘⌥⇧V unless rebound) arrives as a paste too.
+ #[cfg(not(target_arch = "wasm32"))]
+ egui::Event::Paste(_) if chord.is_some() => {
+ if let Some(id) = chord
+ && crate::menus::enabled(app, id)
+ {
+ crate::menus::activate(app, id, &serde_json::Value::Null);
+ }
+ }
// Without a native menu bar the copy and paste keys arrive only as these events.
egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => {
let (id, params) = match e {
@@ -4278,6 +4316,39 @@ mod tests {
assert!((it.bounds().center() - centre).hypot() < 0.01, "{:?} vs {centre:?}", it.bounds());
}
+ #[test]
+ fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
+ let original = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
+ app.run("edit.copy", json!({})).unwrap();
+ let clip = app.object_clip_text.clone().expect("the copy's placeholder text");
+ let mut h = harness(app);
+ let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| {
+ // As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the
+ // modifiers are released within the same frame.
+ let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers };
+ let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE);
+ h.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), egui::Event::Paste(clip.clone()), release, none]);
+ h.run_steps(2);
+ let st = h.state().session.doc().unwrap();
+ let pasted = *st.selection.items.first().expect("the copy is selected");
+ assert_ne!(pasted.0, id);
+ st.doc.item(pasted).unwrap().bounds()
+ };
+ let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
+ assert_eq!(paste(&mut h, linux), original, "Paste in Place");
+ let moved = paste(&mut h, egui::Modifiers::COMMAND);
+ assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}");
+ // The modifiers went down in an earlier frame than the paste.
+ h.event_modifiers(egui::Event::Paste(clip.clone()), linux);
+ h.run_steps(2);
+ let st = h.state().session.doc().unwrap();
+ let pasted = *st.selection.items.first().expect("the copy is selected");
+ assert_eq!(st.doc.item(pasted).unwrap().bounds(), original);
+ }
+
#[test]
fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() {
let services = crate::Services { clipboard_text: Some(Box::new(|| Some(ART.to_string()))), ..Default::default() };
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index fb43f8cb9..af2a91fc3 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -3050,6 +3050,20 @@ fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui
matches.then_some(k == key && m.shift == modifiers.shift)
}
+/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
+/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never
+/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when
+/// the chord is another one, or the native menu's accelerator takes it.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> {
+ let id = "edit.pasteInPlace";
+ if app.native_menu && !app.ui.shortcuts.contains_key(id) {
+ return None;
+ }
+ let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?;
+ (k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id)
+}
+
/// Global keyboard shortcuts: menu commands and single-key tool shortcuts.
pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
// Only a focused text field takes the keys: the canvas (or a button) having focus after a click
From 8f98f3dc8bfac09d69f949c39bf97f0861621b6c Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:28:11 -0400
Subject: [PATCH 053/135] Keyboard Shortcuts: import an InDesign shortcut set
(#1296)
* #1180: import an InDesign keyboard shortcut set
Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the
.txt that InDesign's Show Set writes (window.importShortcuts {path|text}).
Matched commands take the set's Default shortcut, or lose theirs when
every occurrence says [none defined]; other commands give up keys the
set takes. The report lists changes, cleared conflicts, and the items
that could not be matched or read. Tool keys stay fixed.
* #1180: read the set file through the checked reader
* #1180: when two items want the same keys, the later one takes them
---
ROADMAP.md | 1 +
apps/designcraft-web/src/web.rs | 2 +
apps/designcraft/src/main.rs | 1 +
crates/ui-egui/src/dialogs.rs | 13 +-
crates/ui-egui/src/lib.rs | 5 +
crates/ui-egui/src/menus.rs | 19 +-
crates/ui-egui/src/shortcut_import.rs | 611 ++++++++++++++++++++++++++
docs/file-format-parity.md | 2 +-
docs/target-app-parity.md | 2 +-
docs/ui-parity.md | 2 +-
10 files changed, 651 insertions(+), 7 deletions(-)
create mode 100644 crates/ui-egui/src/shortcut_import.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 8a2352e86..d3ef17e47 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs
index fe0f4a7fa..27d236a2f 100644
--- a/apps/designcraft-web/src/web.rs
+++ b/apps/designcraft-web/src/web.rs
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
+ } else if request.purpose == "shortcuts" {
+ rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
index b56878176..13416e4e0 100644
--- a/apps/designcraft/src/main.rs
+++ b/apps/designcraft/src/main.rs
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
+ "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..6b60b1296 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
}
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
- let _ = app.run("window.resetShortcuts", json!({}));
- }
+ ui.horizontal(|ui| {
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
+ let _ = app.run("window.resetShortcuts", json!({}));
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked()
+ && let Some(msg) = crate::shortcut_import::pick_and_import(app)
+ {
+ d.fields.insert("message".into(), json!(msg));
+ }
+ });
}
/// File › Print, with the printers the system knows.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 0c155851f..18fa402a5 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -27,6 +27,7 @@ mod prefs;
pub mod render_worker;
mod rtl;
mod section_options;
+mod shortcut_import;
pub mod story_editor;
pub mod taskbar;
pub mod theme;
@@ -1033,6 +1034,10 @@ impl DesignApp {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for file in files {
+ if file.request.purpose == "shortcuts" {
+ shortcut_import::import_delivered(self, &file.bytes);
+ continue;
+ }
if self.ui.dialog.is_some() {
self.status(
crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again")
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index af2a91fc3..2a271324a 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"),
("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"),
("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"),
+ (
+ "window.importShortcuts",
+ "Import InDesign Shortcut Set",
+ None,
+ "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}",
+ ),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
(
@@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
+ let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) {
+ (Some(text), _) => text.to_string(),
+ (None, Some(path)) => match crate::shortcut_import::read_file(path) {
+ Ok(text) => text,
+ Err(e) => return Some(Err(e)),
+ },
+ (None, None) => return Some(Err("give the set's path or text".into())),
+ };
+ crate::shortcut_import::import(app, &text)
+ }
"view.snapPreferences" => {
let b = |k: &str| p.get(k).and_then(Value::as_bool);
let ui = &mut app.ui;
@@ -3009,7 +3026,7 @@ pub fn all_menu_items() -> Vec<(String, String)> {
out
}
-fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
+pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
let mut m = egui::Modifiers::NONE;
let mut key = None;
for part in sc.split('+').filter(|s| !s.is_empty()) {
diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs
new file mode 100644
index 000000000..9ccbef1b9
--- /dev/null
+++ b/crates/ui-egui/src/shortcut_import.rs
@@ -0,0 +1,611 @@
+//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text
+//! (Show Set): `window.importShortcuts`.
+//!
+//! The file lists items under `Product area : ` headers, one per line:
+//! ` --- [none defined]` or ` --- : , : `. Item names
+//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no
+//! shortcut contexts); a type command with no Default entry takes its Text entry.
+
+use std::collections::{BTreeSet, HashMap};
+
+use serde_json::{Value, json};
+
+use crate::DesignApp;
+use crate::menus::{self, Item};
+
+/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16).
+pub const MAX_BYTES: usize = 8 << 20;
+/// Entries read per item: a real item has one or two per context.
+const MAX_ENTRIES: usize = 64;
+
+/// One `: ` entry of an item.
+struct Entry {
+ context: String,
+ keys: String,
+}
+
+struct SetItem {
+ area: String,
+ name: String,
+ entries: Vec,
+}
+
+struct ParsedSet {
+ name: String,
+ items: Vec,
+}
+
+/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or
+/// by the zero bytes of ASCII text without one).
+pub fn decode(bytes: &[u8]) -> Result {
+ if bytes.len() > MAX_BYTES {
+ return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20));
+ }
+ let utf16 = |rest: &[u8], le: bool| -> String {
+ let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
+ char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
+ };
+ Ok(match bytes {
+ [0xFF, 0xFE, rest @ ..] => utf16(rest, true),
+ [0xFE, 0xFF, rest @ ..] => utf16(rest, false),
+ [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
+ [a, 0, ..] if *a != 0 => utf16(bytes, true),
+ [0, b, ..] if *b != 0 => utf16(bytes, false),
+ _ => String::from_utf8_lossy(bytes).into_owned(),
+ })
+}
+
+/// Reads and decodes a set file, refusing one over [`MAX_BYTES`].
+pub fn read_file(path: &str) -> Result {
+ let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX);
+ let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?;
+ decode(&bytes)
+}
+
+/// `Label : value` with any spacing around the colon, when the line starts with `label`.
+fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> {
+ let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?;
+ rest.trim_start().strip_prefix(':').map(str::trim)
+}
+
+fn parse(text: &str) -> Result {
+ let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+ let mut set = ParsedSet { name: String::new(), items: Vec::new() };
+ let mut area = String::new();
+ let mut header_seen = false;
+ for line in text.lines() {
+ let line = line.trim_end_matches('\r');
+ let trimmed = line.trim();
+ if let Some(name) = header(trimmed, "Shortcut Set") {
+ set.name = name.to_string();
+ header_seen = true;
+ } else if let Some(a) = header(trimmed, "Product area") {
+ area = a.to_string();
+ header_seen = true;
+ } else if let Some((name, keys)) = trimmed.split_once(" --- ") {
+ let name = name.trim();
+ if name.is_empty() {
+ continue;
+ }
+ set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) });
+ }
+ }
+ if set.items.is_empty() && !header_seen {
+ return Err("not a shortcut set: no `Product area` or `- ---
` lines".into());
+ }
+ Ok(set)
+}
+
+/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a
+/// piece without `: ` belongs to the entry before it.
+fn entries(keys: &str) -> Vec {
+ let keys = keys.trim();
+ if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() {
+ return Vec::new();
+ }
+ let mut pieces: Vec = Vec::new();
+ for piece in keys.split(", ") {
+ match pieces.last_mut() {
+ Some(last) if !piece.contains(": ") => {
+ last.push_str(", ");
+ last.push_str(piece);
+ }
+ _ => pieces.push(piece.to_string()),
+ }
+ }
+ pieces
+ .into_iter()
+ .take(MAX_ENTRIES)
+ .filter_map(|p| {
+ let (context, keys) = p.split_once(": ")?;
+ Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() })
+ })
+ .collect()
+}
+
+/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the
+/// command modifier.
+fn is_mac_set(set: &ParsedSet) -> bool {
+ set.items.iter().flat_map(|i| &i.entries).any(|e| {
+ let k = e.keys.to_lowercase();
+ k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥')
+ })
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Mod {
+ Command,
+ Alt,
+ Shift,
+ Control,
+}
+
+/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a
+/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set.
+fn read_keys(keys: &str, mac: bool) -> Result {
+ const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)];
+ const WORDS: &[(&str, Mod)] = &[
+ ("command", Mod::Command),
+ ("cmd", Mod::Command),
+ ("option", Mod::Alt),
+ ("opt", Mod::Alt),
+ ("alt", Mod::Alt),
+ ("shift", Mod::Shift),
+ ("control", Mod::Control),
+ ("ctrl", Mod::Control),
+ ];
+ let mut m = egui::Modifiers::NONE;
+ let mut set = |md: Mod| match md {
+ Mod::Command => m.command = true,
+ Mod::Alt => m.alt = true,
+ Mod::Shift => m.shift = true,
+ Mod::Control if mac => m.ctrl = true,
+ Mod::Control => m.command = true,
+ };
+ let mut rest = keys.trim();
+ 'strip: loop {
+ for (sym, md) in SYMBOLS {
+ if let Some(r) = rest.strip_prefix(sym) {
+ set(*md);
+ rest = r.strip_prefix('+').unwrap_or(r).trim_start();
+ continue 'strip;
+ }
+ }
+ for (word, md) in WORDS {
+ let Some(head) = rest.get(..word.len()) else { continue };
+ let Some(after) = rest.get(word.len()..) else { continue };
+ // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name.
+ let mut chars = after.chars();
+ if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() {
+ set(*md);
+ rest = chars.as_str().trim_start();
+ continue 'strip;
+ }
+ }
+ break;
+ }
+ if m.ctrl {
+ return Err("Control-key shortcuts are not supported");
+ }
+ let key = key_named(rest.trim()).ok_or("unknown key")?;
+ let sc = menus::shortcut_string(m, key);
+ // Only a string the shortcut dispatcher reads back as these keys is imported.
+ match menus::parse_shortcut(&sc) {
+ Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc),
+ _ => Err("unknown key"),
+ }
+}
+
+fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool {
+ a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl
+}
+
+/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive.
+fn key_named(name: &str) -> Option {
+ use egui::Key;
+ if name.is_empty() {
+ return None;
+ }
+ let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase();
+ // The numeric keypad: egui doesn't tell its keys apart from the main ones.
+ let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed);
+ let key = match n {
+ "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp,
+ "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown,
+ "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft,
+ "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight,
+ "pageup" | "pgup" | "⇞" => Key::PageUp,
+ "pagedown" | "pgdn" | "⇟" => Key::PageDown,
+ "home" | "↖" => Key::Home,
+ "end" | "↘" => Key::End,
+ "esc" | "escape" | "⎋" => Key::Escape,
+ "tab" | "⇥" => Key::Tab,
+ "space" | "spacebar" => Key::Space,
+ "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter,
+ "backspace" | "⌫" => Key::Backspace,
+ "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete,
+ "insert" | "ins" | "help" => Key::Insert,
+ _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)),
+ };
+ Some(key)
+}
+
+/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and
+/// no Show/Hide in front (InDesign names a toggle by what choosing it does now).
+fn norm(s: &str) -> String {
+ let s = s.replace('…', "").replace("...", "").replace('&', "");
+ let s = s.split_whitespace().collect::>().join(" ").to_lowercase();
+ ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string)
+}
+
+/// DesignCraft's commands by menu path and label.
+#[derive(Default)]
+struct Index {
+ /// `menu\nsubmenu\nlabel` → ids, in the menu bar.
+ path: HashMap>,
+ /// The same from the engine commands' own menu placement, which a UI command (the one with
+ /// the dialog) may stand in for in the menu bar.
+ engine_path: HashMap>,
+ /// (menu, label) → ids, at any depth of that menu.
+ in_menu: HashMap<(String, String), BTreeSet>,
+ /// label → ids, anywhere.
+ label: HashMap>,
+}
+
+impl Index {
+ fn build() -> Self {
+ fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) {
+ for it in items {
+ match it {
+ Item::Sub(name, children) => {
+ path.push(norm(name));
+ walk(ix, children, path);
+ path.pop();
+ }
+ // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one.
+ Item::Cmd { label, id, params, .. } if params.is_null() => {
+ let label = norm(label);
+ let top = path.first().cloned().unwrap_or_default();
+ add_path(&mut ix.path, path, &label, id);
+ ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone());
+ ix.label.entry(label).or_default().insert(id.clone());
+ }
+ _ => {}
+ }
+ }
+ }
+ let mut ix = Index::default();
+ for (menu, entries) in menus::menu_tree() {
+ walk(&mut ix, &entries, &mut vec![norm(menu)]);
+ }
+ for c in designcraft_engine::command_specs() {
+ let label = norm(c.label);
+ if !c.menu.is_empty() {
+ let path: Vec = c.menu.iter().map(|m| norm(m)).collect();
+ add_path(&mut ix.engine_path, &path, &label, c.id);
+ }
+ ix.label.entry(label).or_default().insert(c.id.to_string());
+ }
+ for (id, label, _, _) in menus::UI_COMMANDS {
+ ix.label.entry(norm(label)).or_default().insert((*id).to_string());
+ }
+ ix
+ }
+
+ /// The command an item names, trying the most specific match first. Matches by label alone
+ /// only count when exactly one command has that label.
+ fn find(&self, area: &str, name: &str) -> Option {
+ let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned());
+ let segments: Vec = name.split(": ").map(norm).collect();
+ let last = segments.last().cloned().unwrap_or_default();
+ let area = norm(area);
+ if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) {
+ let mut key = menu.to_string();
+ for s in &segments {
+ key.push('\n');
+ key.push_str(s);
+ }
+ if let Some(id) = one(self.path.get(&key)) {
+ return Some(id);
+ }
+ if segments.len() > 1
+ && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" "))))
+ {
+ return Some(id);
+ }
+ if let Some(id) = one(self.engine_path.get(&key)) {
+ return Some(id);
+ }
+ if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) {
+ return Some(id);
+ }
+ // Menus are arranged differently: the item's own label, if no other command has it.
+ return one(self.label.get(&last));
+ }
+ if segments.len() == 1 { one(self.label.get(&last)) } else { None }
+ }
+}
+
+fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) {
+ let mut key = path.join("\n");
+ key.push('\n');
+ key.push_str(label);
+ map.entry(key).or_default().insert(id.to_string());
+}
+
+/// What the set says about one DesignCraft command, over every item matched to it.
+struct Decision {
+ id: String,
+ item: String,
+ /// The first readable shortcut.
+ shortcut: Option,
+ /// Every matched item said `[none defined]`.
+ all_none: bool,
+}
+
+fn command_label(id: &str) -> String {
+ menus::ui_label(id)
+ .map(|(l, _)| l.to_string())
+ .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string()))
+ .unwrap_or_else(|| id.to_string())
+}
+
+fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> {
+ menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k))
+}
+
+/// Imports a set: every matched command takes the set's shortcut (or none), another command
+/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report.
+pub fn import(app: &mut DesignApp, text: &str) -> Result {
+ if text.len() > MAX_BYTES {
+ return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20));
+ }
+ let set = parse(text)?;
+ let mac = is_mac_set(&set);
+ let ix = Index::build();
+ let mut decisions: Vec = Vec::new();
+ let mut unmatched = Vec::new();
+ let mut unmatched_without_shortcut = 0usize;
+ let mut unreadable = Vec::new();
+ for item in &set.items {
+ let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) };
+ let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", ");
+ let Some(id) = ix.find(&item.area, &item.name) else {
+ if item.entries.is_empty() {
+ unmatched_without_shortcut += 1;
+ } else {
+ let tool = norm(&item.area) == "tools"
+ && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name));
+ let mut row = json!({"item": shown, "keys": all_keys});
+ if tool {
+ row["reason"] = json!("tool keys are fixed");
+ }
+ unmatched.push(row);
+ }
+ continue;
+ };
+ let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>();
+ let text_command = id.starts_with("type.") || id.starts_with("text.");
+ let mut considered = in_context("Default");
+ if considered.is_empty() && text_command {
+ considered = in_context("Text");
+ }
+ let mut read = None;
+ let mut why = "";
+ for e in &considered {
+ match read_keys(&e.keys, mac) {
+ Ok(sc) => {
+ read = Some(sc);
+ break;
+ }
+ Err(w) => why = w,
+ }
+ }
+ if read.is_none() && !considered.is_empty() {
+ unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why}));
+ }
+ let pos = match decisions.iter().position(|d| d.id == id) {
+ Some(p) => p,
+ None => {
+ decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true });
+ decisions.len() - 1
+ }
+ };
+ let Some(d) = decisions.get_mut(pos) else { continue };
+ d.all_none &= item.entries.is_empty();
+ if d.shortcut.is_none()
+ && let Some(sc) = read
+ {
+ d.shortcut = Some(sc);
+ d.item = shown;
+ }
+ }
+
+ let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect();
+ let mut imported = 0usize;
+ let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new();
+ let mut skipped = Vec::new();
+ let mut decided: BTreeSet = BTreeSet::new();
+ // When two items in the set want the same keys, the later one takes them (the rule shared by
+ // the Craft apps' importers).
+ let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new();
+ for (i, d) in decisions.iter().enumerate() {
+ if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) {
+ winner.insert(keys, i);
+ }
+ }
+ for (i, d) in decisions.iter().enumerate() {
+ match &d.shortcut {
+ Some(sc) => {
+ let Some(keys) = keys_of(sc) else { continue };
+ if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) {
+ skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id}));
+ continue;
+ }
+ taken.push((keys, d.id.clone()));
+ if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) {
+ app.ui.shortcuts.remove(&d.id);
+ } else {
+ app.ui.shortcuts.insert(d.id.clone(), sc.clone());
+ }
+ }
+ None if d.all_none => {
+ if menus::default_shortcut(&d.id).is_none() {
+ app.ui.shortcuts.remove(&d.id);
+ } else {
+ app.ui.shortcuts.insert(d.id.clone(), String::new());
+ }
+ }
+ None => continue,
+ }
+ imported += 1;
+ decided.insert(d.id.clone());
+ }
+ let mut conflicts = Vec::new();
+ for (id, sc) in menus::effective_shortcuts(app) {
+ if decided.contains(&id) {
+ continue;
+ }
+ // A command sharing its default keys with the one taking them (file.new and the New
+ // Document dialog) keeps them: the dispatcher already prefers the other.
+ let twin = |by: &str| {
+ let default = |id: &str| menus::default_shortcut(id).and_then(keys_of);
+ default(&id).is_some() && default(&id) == default(by)
+ };
+ if let Some(keys) = keys_of(&sc)
+ && taken.iter().any(|(k, by)| *k == keys && !twin(by))
+ {
+ app.ui.shortcuts.insert(id.clone(), String::new());
+ conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc}));
+ }
+ }
+ let changed: Vec = decisions
+ .iter()
+ .filter(|d| decided.contains(&d.id))
+ .filter_map(|d| {
+ let from = before.get(&d.id).cloned().flatten();
+ let to = menus::shortcut_of(app, &d.id);
+ (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to}))
+ })
+ .collect();
+ Ok(json!({
+ "set": set.name,
+ "imported": imported,
+ "changed": changed,
+ "conflictsCleared": conflicts,
+ "skipped": skipped,
+ "unmatched": unmatched,
+ "unmatchedWithoutShortcut": unmatched_without_shortcut,
+ "unreadable": unreadable,
+ }))
+}
+
+/// One line for the Keyboard Shortcuts dialog about an import's report.
+pub fn summary(lang: &str, report: &Value) -> String {
+ let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string();
+ let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string();
+ crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.")
+ .replace("{count}", &n("imported"))
+ .replace("{set}", report["set"].as_str().unwrap_or_default())
+ .replace("{unmatched}", &len("unmatched"))
+ .replace("{unreadable}", &len("unreadable"))
+}
+
+/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and
+/// returns the summary; in a browser the chosen file arrives later through the import inbox.
+pub fn pick_and_import(app: &mut DesignApp) -> Option {
+ if let Some(pick) = app.services.pick_open.as_mut() {
+ let path = pick("shortcuts")?;
+ return Some(match app.run("window.importShortcuts", json!({"path": path})) {
+ Ok(report) => summary(&app.ui.language, &report),
+ Err(e) => e,
+ });
+ }
+ let request = app.import_request("shortcuts");
+ if let Some(open) = app.services.open_async.as_mut() {
+ open(request);
+ }
+ None
+}
+
+/// A set file chosen in the browser: import it and say how it went in the open Keyboard
+/// Shortcuts dialog, or the status bar.
+pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) {
+ let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) {
+ Ok(report) => summary(&app.ui.language, &report),
+ Err(e) => e,
+ };
+ match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") {
+ Some(d) => {
+ d.fields.insert("message".into(), json!(msg));
+ }
+ None => app.status(msg),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn app() -> DesignApp {
+ DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())
+ }
+
+ #[test]
+ fn imports_a_windows_set_and_reports_what_it_could_not_use() {
+ let mut app = app();
+ // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling.
+ menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap();
+ let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\
+ Product area : Edit Menu\r\n\
+ \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\
+ \tDuplicate --- [none defined]\r\n\
+ \tDuplicate --- [none defined]\r\n\
+ \tFrobnicate Everything --- Default: Ctrl+F12\r\n\
+ \tNever Heard Of It --- [none defined]\r\n\
+ Product area : File Menu\r\n\
+ \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\
+ \tSave --- Default: Ctrl+Hyperspace\r\n\
+ \tRevert --- Default: Shift+Ctrl+F9\r\n\
+ \tSave As... --- Default: Shift+Ctrl+F9\r\n\
+ garbage line without a separator\r\n";
+ let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
+ assert_eq!(r["set"], "My Set");
+ // A submenu item, with Ctrl as the command modifier.
+ assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K"));
+ assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5"));
+ // `[none defined]` in every occurrence clears a matched command.
+ assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None);
+ // The command that held the imported keys loses them.
+ assert_eq!(menus::shortcut_of(&app, "app.palette"), None);
+ assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}");
+ // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut.
+ assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}]));
+ assert_eq!(r["unmatchedWithoutShortcut"], 1);
+ assert_eq!(r["unreadable"][0]["item"], "File Menu › Save");
+ assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S"));
+ // Two items want the same keys: the later one takes them.
+ assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9"));
+ assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}");
+ assert_eq!(r["imported"], 4);
+ // Reset Shortcuts is the way back.
+ menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
+ assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
+ }
+
+ #[test]
+ fn hostile_input_returns_a_report_or_an_error() {
+ let mut app = app();
+ for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] {
+ let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap();
+ }
+ assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err());
+ assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err());
+ // UTF-16 with a BOM, and Mac modifier spellings.
+ let mut bytes = vec![0xFF, 0xFE];
+ bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes));
+ let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap();
+ assert_eq!(r["set"], "Mac");
+ assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W"));
+ }
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 507358dec..381498fb6 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in
| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ |
| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ |
| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) |
-| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) |
+| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) |
| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) |
| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index c7e0678d7..bb7c5eb80 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -417,7 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
-- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
+- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 2dcf4687a..b66fb585c 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux.
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
| IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) |
| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope |
-| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | |
+| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied |
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
From 9bd85a14b2857113259da4950f30736a1166791a Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:30:03 -0400
Subject: [PATCH 054/135] i18n: complete the French interface (#1347)
* i18n: complete the French interface (#644)
* i18n: French uses gabarit for parent pages; fixes
* i18n: French for the guide commands and the shortcut-set import
---
crates/ui-egui/src/i18n.rs | 174 +++++++++++++++++++++++----
crates/ui-egui/src/i18n/fr.rs | 220 ++++++++++++++++++++++++++++++++++
2 files changed, 371 insertions(+), 23 deletions(-)
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index acf70ff32..fec123a33 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -139,7 +139,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]),
(
"Parents on Top",
- ["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
+ ["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
),
("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]),
("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]),
@@ -150,8 +150,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]),
("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]),
("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]),
- ("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
- ("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]),
+ ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
+ ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Bulleted & Numbered Lists",
[
@@ -497,7 +497,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]),
("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]),
- ("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]),
+ ("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]),
(
"Use Paragraph Gyoudori",
["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"],
@@ -734,7 +734,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]),
("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]),
("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]),
- ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
+ ("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]),
("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]),
(
@@ -873,7 +873,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]),
- ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]),
+ ("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Apply Parent to Pages",
[
@@ -1388,12 +1388,12 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]),
(
"Default Fill and Stroke",
- ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
+ ["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
),
- ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
+ ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
(
"Toggle Fill and Stroke",
- ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
+ ["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
),
("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]),
("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]),
@@ -2061,7 +2061,13 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Show/Hide Document Grid",
- ["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"],
+ [
+ "Show/Hide Document Grid",
+ "Afficher/Masquer la grille du document",
+ "Show/Hide Document Grid",
+ "Show/Hide Document Grid",
+ "显示/隐藏文档网格",
+ ],
),
(
"Show/Hide Frame Edges",
@@ -2713,7 +2719,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Select an object or text to color its fill or stroke.",
[
"Select an object or text to color its fill or stroke.",
- "Select an object or text to color its fill or stroke.",
+ "Sélectionnez un objet ou du texte pour colorer son fond ou son contour.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"选择一个对象或文本以设置其填色或描边颜色。",
@@ -2922,7 +2928,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]),
- ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]),
+ ("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]),
@@ -3505,8 +3511,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]),
("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]),
("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]),
- ("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]),
- ("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]),
+ ("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]),
+ ("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]),
("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]),
("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]),
("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]),
@@ -3536,7 +3542,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Show Parent Page Overlay",
[
"Überlagerung der übergeordneten Seite anzeigen",
- "Afficher la superposition de la page parent",
+ "Afficher la superposition du gabarit",
"Mostrar superposición de página principal",
"親ページオーバーレイを表示",
"显示父页面叠加",
@@ -3544,7 +3550,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Controlled by Parent",
- ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"],
+ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"],
),
("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]),
(
@@ -4229,8 +4235,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Bullets and Numbering…",
["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"],
),
- ("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]),
- ("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]),
+ ("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]),
+ ("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]),
(
"Continue from Previous Number",
[
@@ -4717,7 +4723,7 @@ mod tests {
// `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
- let mut keys = std::collections::HashSet::new();
+ let mut keys = interface_strings();
for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
@@ -4738,6 +4744,121 @@ mod tests {
assert_eq!(tr("fr", "Group"), "Associer");
}
+ /// Interface strings that read the same in French: names, abbreviations, numbering samples, key
+ /// symbols and words French shares with English.
+ const SAME_IN_FRENCH: &[&str] = &[
+ " OK ",
+ " (auto)",
+ "+Bin",
+ "+LOC",
+ "A, B, C, D...",
+ "Angle",
+ "Articles",
+ "Bunri-Kinshi",
+ "Commits",
+ "Composition",
+ "Compression",
+ "Conditions",
+ "Cyan",
+ "DesignCraft",
+ "Direction",
+ "Distance",
+ "Document",
+ "Document…",
+ "Ellipse",
+ "Extras",
+ "Fractions",
+ "GREP",
+ "Hindi",
+ "Horizontal",
+ "I, II, III, IV...",
+ "Image",
+ "Index",
+ "Interface",
+ "Justification",
+ "Justification…",
+ "Kanji",
+ "Kenten",
+ "Lab",
+ "Ligatures",
+ "Magenta",
+ "Maximum",
+ "Menus",
+ "Menus…",
+ "Minimum",
+ "Mobile",
+ "Normal",
+ "Normal (W)",
+ "Notes",
+ "OK",
+ "OpenType",
+ "Options",
+ "Orange",
+ "Orientation",
+ "PDF/A-2b",
+ "PDF/X-4",
+ "Page",
+ "Pages",
+ "Pathfinder",
+ "Picas",
+ "Portrait",
+ "Position",
+ "Radial",
+ "Rectangle",
+ "Rensuuji",
+ "Rotation",
+ "Ruby",
+ "Ruby…",
+ "Satin",
+ "Saturation",
+ "Script",
+ "Scripts",
+ "Section",
+ "Serif",
+ "Shatai",
+ "Signature",
+ "Simple",
+ "Structure",
+ "Style",
+ "Styles",
+ "Tate-Chu-Yoko",
+ "Triangle",
+ "Version",
+ "Vertical",
+ "Violet",
+ "Warichu",
+ "Web",
+ "a, b, c, d...",
+ "document",
+ "i, ii, iii, iv...",
+ "pages",
+ "ΔLOC",
+ "⇧",
+ "−Bin",
+ "−LOC",
+ "⌃",
+ "⌘",
+ "⌥",
+ ];
+
+ #[test]
+ fn french_translates_every_interface_string() {
+ let keys = interface_strings();
+ // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships.
+ let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect();
+ untranslated.sort();
+ assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}");
+ for key in SAME_IN_FRENCH {
+ assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}");
+ assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}");
+ }
+ for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
+ assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id);
+ }
+ assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection");
+ assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer");
+ }
+
#[test]
fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
@@ -4937,10 +5058,9 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
- #[test]
- fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
- let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
+ /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool
+ /// tables and the list of UI-only labels know them.
+ fn interface_strings() -> std::collections::HashSet<&'static str> {
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
@@ -5033,6 +5153,14 @@ mod tests {
"{name}: import cancelled while a dialog is open; choose the file again",
"{name}: {count} step(s) done",
]);
+ keys
+ }
+
+ #[test]
+ fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
+ let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
+ assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
+ let keys = interface_strings();
// A new English string falls back to English until it is translated, so only stale
// entries (keys nothing passes to `tr` any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
index 78c5d5948..681230d84 100644
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -1,57 +1,118 @@
//! French translations of the strings the shared table in `i18n.rs` has no row for,
//! written from the English source using ordinary French desktop-publishing vocabulary.
pub(super) const TABLE: &[(&str, &str)] = &[
+ ("% of all commits", "% de tous les commits"),
+ ("(any)", "(quelconque)"),
+ ("(unchanged)", "(inchangé)"),
("90° CCW", "90° sens antihoraire"),
("90° CW", "90° sens horaire"),
+ ("About", "À propos"),
+ ("Activate Panel", "Activer le panneau"),
+ ("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"),
+ ("Add Tab", "Ajouter une tabulation"),
+ ("Align To", "Aligner sur"),
("Alignment", "Alignement"),
("All Documents", "Tous les documents"),
("Alpha Channel", "Couche alpha"),
+ ("Alternate layout", "Variante de mise en page"),
("Anchored Object Options", "Options d'objet ancré"),
+ ("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"),
+ ("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"),
("ArtCraft App Page", "Page de l'application ArtCraft"),
("ArtCraft Website", "Site web d'ArtCraft"),
("Auto Size", "Taille automatique"),
+ ("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"),
+ ("Binary assets added", "Fichiers binaires ajoutés"),
+ ("Binary assets removed", "Fichiers binaires supprimés"),
("Binding", "Reliure"),
+ ("Break Thread", "Rompre le chaînage"),
("Bullet Character", "Caractère de puce"),
("CMYK:", "CMJN :"),
+ ("Cancel Preview", "Annuler l'aperçu"),
+ ("Cell Number:", "Numéro de mobile :"),
+ ("Change Panel Layout", "Modifier la disposition des panneaux"),
+ ("Change Tab", "Modifier la tabulation"),
("Change To", "Remplacer par"),
+ ("Change fill to:", "Remplacer le fond par :"),
+ ("Character Style", "Style de caractère"),
("Check Spelling", "Vérifier l'orthographe"),
+ ("Chinese: Simplified", "Chinois : simplifié"),
+ ("Chinese: Traditional", "Chinois : traditionnel"),
+ ("Clear All Tabs", "Effacer toutes les tabulations"),
+ ("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"),
("Clear Rotation", "Effacer la rotation"),
+ ("Clipping Path Options…", "Options de masque…"),
+ ("Close Panel", "Fermer le panneau"),
+ ("Close Window", "Fermer la fenêtre"),
("Closed Path", "Tracé fermé"),
("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"),
("Color Blindness – Protanopia", "Daltonisme – Protanopie"),
+ ("Color Theme Tool", "Outil Thème de couleur"),
+ ("Color Theme…", "Thème de couleur…"),
("Colour", "Couleur"),
("Colour Picker", "Sélecteur de couleurs"),
("Column Break", "Saut de colonne"),
+ ("Commit Preview", "Valider l'aperçu"),
("Community", "Communauté"),
+ ("Company", "Entreprise"),
("Conditional Text", "Texte conditionnel"),
+ ("Content Collector Tool", "Outil Collecteur de contenu"),
+ ("Content Placer Tool", "Outil Placeur de contenu"),
+ ("Contributors", "Contributeurs"),
+ ("Convert Direction Point Tool", "Outil Conversion de point directeur"),
+ ("Convert Point", "Convertir le point"),
+ ("Convert Rows", "Convertir les lignes"),
("Copyright Symbol", "Symbole du copyright"),
("Corner Options", "Options d'arrondi"),
+ ("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"),
("Create Guides", "Créer des repères"),
("Dark", "Foncé"),
+ ("Data Fields", "Champs de données"),
+ ("Data Merge Options", "Options de fusion des données"),
("Define Cross-Reference Format", "Définir un format de référence croisée"),
("Define Lists", "Définir des listes"),
("Define Text Variable", "Définir une variable de texte"),
+ ("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"),
+ ("Delete Character Style", "Supprimer le style de caractère"),
+ ("Delete Document Preset", "Supprimer le paramètre prédéfini de document"),
+ ("Delete Parent", "Supprimer le gabarit"),
("Delete Type from Path", "Supprimer le texte curviligne"),
+ ("Deselect Guides", "Désélectionner les repères"),
("DesignCraft Website", "Site web de DesignCraft"),
("DesignCraft on GitHub", "DesignCraft sur GitHub"),
+ ("DesignCraft page", "Page DesignCraft"),
("Detect Edges", "Détecter les contours"),
+ ("Direct Selection Tool", "Outil Sélection directe"),
("Discretionary Hyphen", "Trait d'union conditionnel"),
+ ("Display name", "Nom affiché"),
+ ("Distribute Rows Evenly", "Répartir les lignes uniformément"),
("Document CMYK", "CMJN du document"),
("Document Endnote Options", "Options de notes de fin de document"),
("Document Footnote Options", "Options de notes de bas de page du document"),
("Document RGB", "RVB du document"),
+ ("Don't Save", "Ne pas enregistrer"),
("Done", "Terminé"),
("Double Left Quotation Marks", "Guillemets doubles ouvrants"),
("Double Right Quotation Marks", "Guillemets doubles fermants"),
+ ("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"),
("Edit/Interface Language", "Édition/Langue de l'interface"),
("Edit/Spelling", "Édition/Orthographe"),
("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"),
("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"),
+ ("Ellipse Frame Tool", "Outil Bloc elliptique"),
+ ("Ellipse Tool", "Outil Ellipse"),
("Ellipsis", "Points de suspension"),
("Em Dash", "Tiret cadratin"),
("Em Space", "Espace cadratin"),
+ ("Email Address:", "Adresse e-mail :"),
+ ("Email:", "E-mail :"),
("En Dash", "Tiret demi-cadratin"),
("En Space", "Espace demi-cadratin"),
+ (
+ "Equal fixed heights for the selected rows; cell text may become overset.",
+ "Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.",
+ ),
+ ("Erase Tool", "Outil Gomme"),
("Export EPUB", "Exporter en EPUB"),
("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"),
("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"),
@@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Export IDML", "Exporter en IDML"),
("Export PDF (Interactive)", "Exporter en PDF (interactif)"),
("Export Page as PNG", "Exporter la page en PNG"),
+ ("Export PowerPoint…", "Exporter en PowerPoint…"),
("Export Text", "Exporter le texte"),
("Export XML", "Exporter en XML"),
+ (
+ "Export a PDF, IDML or package to share — no cloud account needed.",
+ "Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.",
+ ),
+ ("Extract Color Theme", "Extraire le thème de couleur"),
+ ("Eyedropper Tool", "Outil Pipette"),
("Figure Space", "Espace chiffre"),
("File/Export", "Fichier/Exporter"),
("Find What", "Rechercher"),
+ ("Find fill:", "Rechercher le fond :"),
+ ("First commit", "Premier commit"),
("Flush Space", "Espace sans alinéa"),
("Forced Line Break", "Saut de ligne forcé"),
+ ("Formatting Affects Text", "La mise en forme agit sur le texte"),
+ ("Found", "Trouvé"),
("Frame Break", "Saut de bloc"),
+ ("Free Transform Tool", "Outil Transformation manuelle"),
("GREP", "GREP"),
+ ("Gap Tool", "Outil Espace"),
("Generate Index", "Générer l'index"),
("Generate Live Caption", "Générer une légende dynamique"),
+ ("Get Cell Stroke", "Obtenir le contour de cellule"),
+ ("Get Tabs", "Obtenir les tabulations"),
("Global Light", "Lumière globale"),
+ ("Grab bag", "Liste"),
+ ("Gradient Feather Tool", "Outil Contour progressif dégradé"),
+ ("Gradient Swatch Tool", "Outil Dégradé de nuance"),
("Graphic", "Graphique"),
+ ("Group with", "Grouper avec"),
("Hair Space", "Espace ultrafine"),
+ ("Hand Tool", "Outil Main"),
("Hide Guides", "Masquer les repères"),
("High Contrast", "Contraste élevé"),
("High Resolution", "Haute résolution"),
("I, II, III, IV...", "I, II, III, IV..."),
+ ("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"),
("Import XML", "Importer XML"),
+ ("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"),
("Indent", "Retrait"),
("Indent to Here", "Retrait jusqu'à ce point"),
("Ink Limit 300%", "Limite d'encre 300 %"),
("Insert Anchored Object", "Insérer un objet ancré"),
("Insert Cross-Reference", "Insérer une référence croisée"),
+ ("Insert Field", "Insérer un champ"),
("Insert Math Expression", "Insérer une expression mathématique"),
("Insert Pages", "Insérer des pages"),
("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"),
+ ("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"),
+ ("Join", "Joindre"),
("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"),
("Join the community", "Rejoindre la communauté"),
("Kerning", "Crénage"),
+ ("Kerning/Tracking Step", "Pas de crénage/d'approche"),
+ ("Key Object", "Objet clé"),
+ ("Last commit", "Dernier commit"),
("Layout/Index", "Page/Index"),
("Layout/Pages", "Page/Pages"),
("Legacy Macintosh RGB", "RVB Macintosh hérité"),
("Light", "Clair"),
+ ("Line Tool", "Outil Trait"),
+ ("Line delta", "Différence de lignes"),
+ ("Lines +/−", "Lignes +/−"),
+ ("Lines added", "Lignes ajoutées"),
+ ("Lines deleted", "Lignes supprimées"),
+ ("List Composite Fonts", "Lister les polices composites"),
+ ("List Styles in File", "Lister les styles du fichier"),
("Load DTD", "Charger la DTD"),
+ ("Load Styles…", "Charger les styles…"),
("Load Swatches", "Charger des nuances"),
("Low Resolution", "Basse résolution"),
("Margins:", "Marges :"),
+ ("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"),
+ ("Measure Tool", "Outil Mesure"),
("Medium Dark", "Moyennement foncé"),
("Medium Light", "Moyennement clair"),
("Medium Resolution", "Résolution moyenne"),
("Menus", "Menus"),
+ ("Merged PRs", "PR fusionnées"),
+ ("Message:", "Message :"),
+ ("Minimize Window", "Réduire la fenêtre"),
+ ("Models", "Modèles"),
("Monitor RGB", "RVB du moniteur"),
+ ("Move Column Guide", "Déplacer le repère de colonne"),
+ ("Move Guides", "Déplacer les repères"),
("Move Pages", "Déplacer des pages"),
+ ("Move Panel", "Déplacer le panneau"),
+ ("Move Tab", "Déplacer la tabulation"),
+ ("Move Window", "Déplacer la fenêtre"),
+ ("Name (A–Z)", "Nom (A–Z)"),
+ ("New Cell Style", "Nouveau style de cellule"),
("New Condition", "Nouvelle condition"),
("New Hyperlink", "Nouvel hyperlien"),
("New Hyperlink Destination", "Nouvelle destination d'hyperlien"),
@@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Page Reference", "Nouvelle référence de page"),
("New Parent", "Nouveau gabarit"),
("New Stroke Style", "Nouveau style de contour"),
+ ("New Table Style", "Nouveau style de tableau"),
("Next Page Number", "Numéro de la page suivante"),
+ ("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."),
+ ("No matches", "Aucune correspondance"),
+ ("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."),
("Nonbreaking Hyphen", "Trait d'union insécable"),
("Nonbreaking Space", "Espace insécable"),
("None (keep transparency)", "Aucun (conserver la transparence)"),
+ ("Note Tool", "Outil Note"),
("Number of Pages", "Nombre de pages"),
("Numbering & Section Options", "Options de numérotation et de section"),
("OK", "OK"),
+ ("Object Style", "Style d'objet"),
+ ("Object Style Options…", "Options de style d'objet…"),
("Object Styles", "Styles d'objet"),
("Object/Anchored Object", "Objet/Objet ancré"),
("Object/Arrange", "Objet/Disposition"),
@@ -132,64 +249,130 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Object/Transform", "Objet/Transformation"),
("Object/Transform Again", "Objet/Répéter la transformation"),
("On", "Activé"),
+ ("Organization:", "Organisation :"),
("Orientation", "Orientation"),
+ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"),
+ ("PRs", "PR"),
("Package", "Assemblage"),
+ ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"),
("Page Break", "Saut de page"),
+ ("Page Options", "Options de page"),
+ ("Page Tool", "Outil Page"),
("Panels", "Panneaux"),
("Paragraph Return", "Retour de paragraphe"),
+ ("Paragraph Style", "Style de paragraphe"),
("Paragraph Symbol", "Symbole de paragraphe"),
+ ("Parent Options…", "Options de gabarit…"),
+ ("Pen Tool", "Outil Plume"),
+ ("Pencil Tool", "Outil Crayon"),
+ ("Phone:", "Téléphone :"),
("Picas", "Picas"),
+ ("Pin Bar Position", "Épingler la position de la barre"),
("Place with Import Options", "Importer avec les options d'importation"),
+ ("Polygon Frame Tool", "Outil Bloc polygonal"),
+ ("Polygon Tool", "Outil Polygone"),
("Preflight", "Contrôle en amont"),
("Preflight Panel…", "Panneau Contrôle en amont…"),
+ ("Press keys…", "Appuyez sur des touches…"),
+ ("Preview Change", "Prévisualiser la modification"),
+ ("Preview Record", "Aperçu de l'enregistrement"),
("Previous Page Number", "Numéro de la page précédente"),
("Print Booklet", "Imprimer le cahier"),
("Punctuation Space", "Espace de ponctuation"),
("Quarter Space", "Quart d'espace"),
("Quick Apply", "Application rapide"),
("RGB:", "RVB :"),
+ ("Range:", "Plage :"),
+ ("Real name", "Nom réel"),
+ ("Recent Files", "Fichiers récents"),
+ ("Rectangle Frame Tool", "Outil Bloc rectangulaire"),
+ ("Rectangle Tool", "Outil Rectangle"),
+ ("Redefine Character Style", "Redéfinir le style de caractère"),
+ ("Redefine Paragraph Style", "Redéfinir le style de paragraphe"),
("Registered Trademark Symbol", "Symbole de marque déposée"),
("Relink", "Rééditer le lien"),
("Relink to Folder", "Rééditer le lien au dossier"),
+ ("Relinked {count}", "Liens rétablis : {count}"),
+ ("Reload Fonts", "Recharger les polices"),
+ ("Remove Data Source", "Supprimer la source de données"),
+ ("Remove Field", "Supprimer le champ"),
+ ("Remove Frame from Thread", "Retirer le bloc du chaînage"),
+ ("Reorder Objects", "Réorganiser les objets"),
("Report an Issue", "Signaler un problème"),
+ ("Report an issue", "Signaler un problème"),
+ ("Reset Bar Position", "Réinitialiser la position de la barre"),
+ ("Resize Row or Column", "Redimensionner la ligne ou la colonne"),
+ ("Resize Window", "Redimensionner la fenêtre"),
+ ("Resize panel window", "Redimensionner la fenêtre du panneau"),
+ ("Resize panels", "Redimensionner les panneaux"),
+ ("Reverse the order", "Inverser l'ordre"),
("Right Indent Tab", "Tabulation de retrait à droite"),
+ ("Rotate Tool", "Outil Rotation"),
("Rotation", "Rotation"),
+ ("Ruler Zero Point", "Point zéro des règles"),
("Save As", "Enregistrer sous"),
("Save Swatches for Exchange", "Enregistrer les nuances pour échange"),
("Save a Copy", "Enregistrer une copie"),
+ ("Scale Tool", "Outil Mise à l'échelle"),
+ ("Scissors Tool", "Outil Ciseaux"),
("Scripts", "Scripts"),
("Section Marker", "Marque de section"),
("Section Symbol", "Symbole de section"),
+ ("Select Guide", "Sélectionner le repère"),
+ ("Select Page", "Sélectionner la page"),
+ ("Select Paragraph", "Sélectionner le paragraphe"),
+ ("Select Sentence", "Sélectionner la phrase"),
+ ("Select Story", "Sélectionner l'article"),
+ ("Select Text Unit", "Sélectionner une unité de texte"),
("Selection", "Sélection"),
+ ("Selection Tool", "Outil Sélection"),
+ ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"),
("Sentence case", "Majuscule en début de phrase"),
+ ("Set Composite Font", "Définir la police composite"),
+ ("Set OpenType Features", "Définir les fonctions OpenType"),
+ ("Shear Tool", "Outil Déformation"),
("Simulate Paper Color", "Simuler la couleur du papier"),
("Single Left Quotation Mark", "Guillemet simple ouvrant"),
("Single Right Quotation Mark", "Guillemet simple fermant"),
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
+ ("Smooth Tool", "Outil Lissage"),
("Space After", "Espace après"),
("Space Before", "Espace avant"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
+ ("Stop Preview", "Arrêter l'aperçu"),
("Story Column Direction", "Sens des colonnes de l'article"),
+ ("Story Editor — {count} words", "Mode éditeur — {count} mots"),
+ ("Story {story} ← story {parent}", "Article {story} ← article {parent}"),
("Straight Double Quotation Marks", "Guillemets droits doubles"),
("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"),
+ ("Subject:", "Objet :"),
("Subscript:", "Indice :"),
("Superscript:", "Exposant :"),
+ ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
("Tab", "Tabulation"),
("Table of Contents", "Table des matières"),
("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
("Table/Delete", "Tableau/Supprimer"),
("Table/Insert", "Tableau/Insérer"),
("Table/Select", "Tableau/Sélectionner"),
+ ("Text Anchor", "Ancre de texte"),
+ ("Text:", "Texte :"),
("Thin Space", "Espace fine"),
("Third Space", "Tiers d'espace"),
+ ("Thread Text Frames", "Chaîner les blocs de texte"),
("Title Case", "Majuscule à chaque mot"),
+ ("To Body", "En lignes de corps"),
+ ("To Footer", "En lignes de pied"),
+ ("To Header", "En lignes d'en-tête"),
("Trademark Symbol", "Symbole de marque"),
("Transform Again Individually", "Répéter la transformation individuellement"),
("Transform Sequence Again", "Répéter la séquence de transformations"),
("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"),
+ ("Type Tool", "Outil Texte"),
("Type on a Path Options", "Options de texte curviligne"),
+ ("Type on a Path Tool", "Outil Texte curviligne"),
("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"),
("Type/Change Case", "Texte/Modifier la casse"),
("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"),
@@ -210,8 +393,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("URL:", "URL :"),
("Unassigned", "Non attribué"),
("Units", "Unités"),
+ ("Unsaved Changes", "Modifications non enregistrées"),
+ ("Update Data Source", "Mettre à jour la source de données"),
+ ("Username", "Nom d'utilisateur"),
+ ("Validate from Root Element", "Valider à partir de l'élément racine"),
+ ("Vertical Type Tool", "Outil Texte vertical"),
+ ("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"),
("View/Display Performance", "Affichage/Performances d'affichage"),
+ ("View/Extras", "Affichage/Extras"),
("View/Grids & Guides", "Affichage/Grilles et repères"),
+ ("View/Proof Setup", "Affichage/Format d'épreuve"),
+ ("View/Rotate Spread", "Affichage/Faire pivoter la planche"),
+ ("View/Screen Mode", "Affichage/Mode de l'écran"),
+ ("View/Separations Preview", "Affichage/Aperçu des séparations"),
+ ("View/Structure", "Affichage/Structure"),
("Window/Arrange", "Fenêtre/Disposition"),
("Window/Interactive", "Fenêtre/Interactif"),
("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"),
@@ -220,6 +415,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Window/Type & Tables", "Fenêtre/Texte et tableaux"),
("Window/Utilities", "Fenêtre/Utilitaires"),
("Working CMYK", "CMJN de travail"),
+ ("Zoom Tool", "Outil Zoom"),
+ ("[No Language]", "[Aucune langue]"),
("i, ii, iii, iv...", "i, ii, iii, iv..."),
+ ("image editing", "retouche d'images"),
+ ("layer", "calque"),
+ ("layers", "calques"),
("lowercase", "minuscules"),
+ ("motion graphics and visual effects", "animation graphique et effets visuels"),
+ ("photo library and raw development", "photothèque et développement raw"),
+ ("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"),
+ ("the default printer", "l'imprimante par défaut"),
+ ("vector illustration", "illustration vectorielle"),
+ ("video editing, color and sound", "montage vidéo, couleur et son"),
+ ("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"),
+ ("{count} conflict(s): {names}", "{count} conflit(s) : {names}"),
+ ("{count} match(es)", "{count} correspondance(s)"),
+ ("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"),
+ ("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"),
+ (
+ "{name}: import cancelled while a dialog is open; choose the file again",
+ "{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier",
+ ),
+ ("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"),
+ (
+ "“{}” has changes that are not saved. Save them before closing?",
+ "« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?",
+ ),
];
From e44662305f2367189e0fd11bac9d010e37e3f73b Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:25:46 -0400
Subject: [PATCH 055/135] i18n tests: the same checks for every language, none
fail on a missing translation (#1576)
* i18n: missing French strings don't fail tests
* i18n: drop the Czech tool-name translation check
* i18n tests: one catalog check for every language, no translated literals
One test checks every catalog (each language's table and context table, and
the shared tables' columns) for duplicate and stale keys, empty translations,
placeholders, padding, ellipses and label colons. One test selects, persists
and checks document data for every interface language. Context lookup,
fallback, built-in names and RTL are checked over every language against the
catalogs. Asserts on particular translated text, and checks that particular
strings are translated, are gone.
* i18n: drop a stale German entry
---
crates/ui-egui/src/chrome.rs | 16 +-
crates/ui-egui/src/dialogs.rs | 2 +-
crates/ui-egui/src/guide_selection_tests.rs | 5 -
crates/ui-egui/src/i18n.rs | 652 +++++---------------
crates/ui-egui/src/i18n/de.rs | 1 -
crates/ui-egui/src/lib.rs | 2 +-
crates/ui-egui/src/menus.rs | 167 +----
crates/ui-egui/src/panels/properties.rs | 15 +-
8 files changed, 205 insertions(+), 655 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 0df5361f9..e853e6793 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1155,7 +1155,7 @@ mod tests {
/// The application bar's search field (German interface).
fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> {
- h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen"))
+ h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search")))
}
/// A document with two frames, the Selection tool, German menus, and the search field clicked.
@@ -1187,12 +1187,12 @@ mod tests {
#[test]
fn the_search_field_finds_and_runs_menu_commands() {
let mut h = searching();
- h.event(egui::Event::Text("gruppier".into()));
+ let group = crate::i18n::tr("de", "Group");
+ h.event(egui::Event::Text(group.to_lowercase()));
h.run_steps(4);
- assert!(row(&h, "Gruppieren").is_some(), "the German label is listed");
- assert!(row(&h, "Gruppierung aufheben").is_some());
+ assert!(row(&h, group).is_some(), "the German label is listed");
// Nothing is selected, so Group is off and Return doesn't run it.
- assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled()));
+ assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled()));
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h), (2, 0), "a disabled command doesn't run");
@@ -1205,7 +1205,7 @@ mod tests {
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h).0, 1, "the two frames are grouped");
- assert!(row(&h, "Gruppieren").is_none(), "the matches closed");
+ assert!(row(&h, group).is_none(), "the matches closed");
assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back");
}
@@ -1227,11 +1227,11 @@ mod tests {
assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected");
h.event(egui::Event::Text("zz-no-such-command".into()));
h.run_steps(4);
- assert!(h.query_by_label("Keine passenden Befehle").is_some());
+ assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some());
// Escape closes the matches and clears the field without touching the document.
h.key_press(egui::Key::Escape);
h.run_steps(4);
- assert!(h.query_by_label("Keine passenden Befehle").is_none());
+ assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none());
assert!(!h.ctx.text_edit_focused());
assert_eq!(h.state().app.session.tool_id(), "rectangleFrame");
assert_eq!(items(&h), (2, 2));
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 6b60b1296..40787954f 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -8963,7 +8963,7 @@ mod tests {
assert_eq!(app.ui.language, "de");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
- let heading = visible_label(&labels, screen, "Sprache der Oberfläche");
+ let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language"));
let combo = visible_label(&labels, screen, "Deutsch");
// The names stay endonyms in the German interface.
diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs
index 6c41d0122..d69026712 100644
--- a/crates/ui-egui/src/guide_selection_tests.rs
+++ b/crates/ui-egui/src/guide_selection_tests.rs
@@ -190,11 +190,6 @@ fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() {
assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G"));
crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null);
assert_eq!(selected(&h), [0, 1, 2]);
- assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen");
- for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}");
- assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}");
- }
h.state_mut().ui.guides = false;
assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll"));
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index fec123a33..6057abc97 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -4598,332 +4598,198 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str {
#[cfg(test)]
mod tests {
use super::*;
+ use std::collections::HashSet;
+
+ /// Every per-language table `tr` looks up, by interface language.
+ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
+ ("ar", ar::TABLE),
+ ("cs", cs::TABLE),
+ ("de", de::TABLE),
+ ("fr", fr::TABLE),
+ ("it", it::TABLE),
+ ("ja", ja::TABLE),
+ ("ka", ka::TABLE),
+ ("pl", pl::TABLE),
+ ("pt-br", pt_br::TABLE),
+ ("ru", ru::TABLE),
+ ("uk", uk::TABLE),
+ ];
+
+ /// Every per-language table `tr_context` looks up.
+ const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] =
+ &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)];
+
+ /// The languages of the shared tables' columns, in column order.
+ const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"];
+
+ /// The German and Japanese tables translate the whole interface and win over the shared columns.
+ const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
+
+ #[test]
+ fn every_interface_language_has_a_catalog() {
+ for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
+ assert_eq!(column(lang), Some(i), "{lang}");
+ }
+ for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
+ assert!(!name.is_empty(), "{code}");
+ assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
+ }
+ }
#[test]
fn cached_lookup_preserves_every_translation_across_language_switches() {
+ let own = |lang: &str, key: &str| {
+ LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text)
+ };
for (key, translations) in TABLE {
- for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
- // `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns.
- if lang != "de" && lang != "ja" {
- assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
- }
+ for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
+ // A language's own table is looked up before the shared column.
+ assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
- for (key, expected) in de::TABLE {
- assert_eq!(tr("de", key), *expected, "de: {key}");
- }
- for (key, expected) in ar::TABLE {
- assert_eq!(tr("ar", key), *expected, "ar: {key}");
- }
- for (key, expected) in pt_br::TABLE {
- assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}");
- }
- for (key, expected) in it::TABLE {
- assert_eq!(tr("it", key), *expected, "it: {key}");
- }
- for (key, expected) in uk::TABLE {
- assert_eq!(tr("uk", key), *expected, "uk: {key}");
- }
- for (key, expected) in ru::TABLE {
- assert_eq!(tr("ru", key), *expected, "ru: {key}");
- }
- for (key, expected) in cs::TABLE {
- assert_eq!(tr("cs", key), *expected, "cs: {key}");
- }
- for (key, expected) in fr::TABLE {
- assert_eq!(tr("fr", key), *expected, "fr: {key}");
- }
- for (key, expected) in pl::TABLE {
- assert_eq!(tr("pl", key), *expected, "pl: {key}");
- }
- for (key, expected) in ka::TABLE {
- assert_eq!(tr("ka", key), *expected, "ka: {key}");
+ for (lang, table) in LANGUAGE_TABLES {
+ for (key, expected) in *table {
+ assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ assert_eq!(tr("", key), *key);
+ }
}
+ // Strings no catalog has stay as they are, in every language.
let unknown = String::from("A user-defined untranslated label");
- for lang in ["pl", "cs", "uk", "ru", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] {
- assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
+ for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) {
+ assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}");
+ // Fixed standard names stay identical.
+ assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}");
}
}
#[test]
- fn translates_known_strings_and_keeps_the_rest() {
- assert_eq!(tr("de", "File"), "Datei");
- assert_eq!(tr("ja", "Swatches"), "スウォッチ");
- assert_eq!(tr("fr", "Something new"), "Something new");
- assert_eq!(tr("", "File"), "File");
- assert_eq!(tr("ar", "File"), "ملف");
- assert_eq!(tr("ar", "Unknown label"), "Unknown label");
- assert_eq!(tr("pt-br", "File"), "Arquivo");
- assert_eq!(tr("pt-br", "New Document…"), "Novo documento…");
- assert_eq!(tr("pt-br", "Unknown label"), "Unknown label");
- assert_eq!(tr("it", "Edit"), "Modifica");
- assert_eq!(tr("it", "New Document…"), "Nuovo documento…");
- assert_eq!(tr("it", "Unknown label"), "Unknown label");
- assert!(is_rtl("ar"));
- for (i, (en, translation)) in ar::TABLE.iter().enumerate() {
- assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
+ fn context_captions_come_from_their_context_or_the_plain_translation() {
+ for (lang, table) in CONTEXT_TABLES {
+ for (key, scope, text) in *table {
+ assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
+ }
}
- for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() {
- assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
+ for (key, scope, row) in CONTEXT {
+ for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) {
+ // A language's own context table is looked up before the shared one.
+ let own = CONTEXT_TABLES
+ .iter()
+ .filter(|(code, _)| *code == lang)
+ .flat_map(|(_, table)| table.iter())
+ .find(|(k, s, _)| k == key && s == scope)
+ .map(|(_, _, text)| *text);
+ assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
+ }
}
- for (i, (en, translation)) in it::TABLE.iter().enumerate() {
- assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
- }
- // Italian entries are for keys the shared or Arabic table knows. A new English string
- // falls back to English until it is translated, so it doesn't fail here.
- for (en, _) in it::TABLE {
- assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}");
- }
- // Every row is unique and complete.
- for (i, (en, t)) in TABLE.iter().enumerate() {
- assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}");
- assert!(t.iter().all(|x| !x.is_empty()), "{en}");
+ // An unknown context, or a language without one, keeps the plain translation.
+ let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key)));
+ for key in keys {
+ for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) {
+ assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}");
+ }
+ assert_eq!(tr_context("", key, "count"), key);
}
}
+ /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
+ /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
+ /// to English, so missing translations don't fail here.
#[test]
- fn a_column_count_and_a_list_number_read_differently() {
- for (lang, count, numeral) in [
- ("de", "Anzahl:", "Zahl:"),
- ("ja", "数:", "番号:"),
- ("uk", "Кількість:", "Номер:"),
- ("ar", "العدد:", "الرقم:"),
- ("fr", "Nombre :", "Numéro:"),
- ("zh", "数量:", "编号:"),
- ] {
- assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}");
+ fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
+ // (language, key, translation) of every catalog, and every catalog's keys by name.
+ let mut entries: Vec<(&str, &str, &str)> = Vec::new();
+ let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
+ for (lang, table) in LANGUAGE_TABLES {
+ let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
+ assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
+ catalogs.push((*lang, keys));
+ entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
}
- assert_eq!(tr_context("en", "Number:", "count"), "Number:");
- assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:");
- // The Pages panel menu.
- for key in [
- "Insert Pages…",
- "Move Pages…",
- "New Parent…",
- "Override All Parent Page Items",
- "Remove All Local Overrides",
- "Detach All Objects from Parent",
- "Create Alternate Layout…",
- "Allow Selected Spread to Shuffle",
- ] {
- assert_ne!(tr("pt-br", key), key);
+ let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
+ assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
+ catalogs.push(("shared", shared.clone()));
+ for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
+ entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
+ entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
+ }
+ let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())];
+ for (lang, table) in CONTEXT_TABLES {
+ contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
+ entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
}
- assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages");
- }
- #[test]
- fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() {
- let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys");
- let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
- // `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
- let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
- assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
- let mut keys = interface_strings();
- for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
+ // A per-language table is looked up before the shared table, so a key in both hides the
+ // shared column, except in the tables that translate the whole interface.
+ for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
+ let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect();
+ assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}");
- let placeholders = |s: &str| {
- let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- names.sort();
+
+ // A key is current while the interface or another catalog has it.
+ let interface = interface_strings();
+ let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
+ for (name, keys) in &catalogs {
+ let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
+ stale.sort();
+ assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
+ }
+ for (name, keys) in &contexts {
+ let unique: HashSet<_> = keys.iter().collect();
+ assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
+ let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
+ assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
+ }
+
+ let placeholders = |s: &'static str| {
+ let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
+ names.sort_unstable();
names
};
- let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text)));
- for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
+ let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
+ let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
+ // Full-width punctuation carries its own spacing.
+ let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
+ for (lang, key, text) in entries {
+ assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
+ assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
+ // Dialog ellipses, label colons and padded captions stay as in English.
+ assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
+ assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
+ if !text.starts_with(full_width) {
+ assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
+ }
+ if !text.ends_with(full_width) {
+ assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
+ }
}
- assert_eq!(tr("fr", "Group"), "Associer");
- }
-
- /// Interface strings that read the same in French: names, abbreviations, numbering samples, key
- /// symbols and words French shares with English.
- const SAME_IN_FRENCH: &[&str] = &[
- " OK ",
- " (auto)",
- "+Bin",
- "+LOC",
- "A, B, C, D...",
- "Angle",
- "Articles",
- "Bunri-Kinshi",
- "Commits",
- "Composition",
- "Compression",
- "Conditions",
- "Cyan",
- "DesignCraft",
- "Direction",
- "Distance",
- "Document",
- "Document…",
- "Ellipse",
- "Extras",
- "Fractions",
- "GREP",
- "Hindi",
- "Horizontal",
- "I, II, III, IV...",
- "Image",
- "Index",
- "Interface",
- "Justification",
- "Justification…",
- "Kanji",
- "Kenten",
- "Lab",
- "Ligatures",
- "Magenta",
- "Maximum",
- "Menus",
- "Menus…",
- "Minimum",
- "Mobile",
- "Normal",
- "Normal (W)",
- "Notes",
- "OK",
- "OpenType",
- "Options",
- "Orange",
- "Orientation",
- "PDF/A-2b",
- "PDF/X-4",
- "Page",
- "Pages",
- "Pathfinder",
- "Picas",
- "Portrait",
- "Position",
- "Radial",
- "Rectangle",
- "Rensuuji",
- "Rotation",
- "Ruby",
- "Ruby…",
- "Satin",
- "Saturation",
- "Script",
- "Scripts",
- "Section",
- "Serif",
- "Shatai",
- "Signature",
- "Simple",
- "Structure",
- "Style",
- "Styles",
- "Tate-Chu-Yoko",
- "Triangle",
- "Version",
- "Vertical",
- "Violet",
- "Warichu",
- "Web",
- "a, b, c, d...",
- "document",
- "i, ii, iii, iv...",
- "pages",
- "ΔLOC",
- "⇧",
- "−Bin",
- "−LOC",
- "⌃",
- "⌘",
- "⌥",
- ];
-
- #[test]
- fn french_translates_every_interface_string() {
- let keys = interface_strings();
- // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships.
- let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect();
- untranslated.sort();
- assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}");
- for key in SAME_IN_FRENCH {
- assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}");
- assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}");
- }
- for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
- assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id);
- }
- assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection");
- assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer");
}
#[test]
- fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
- keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys no catalog knows any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in uk::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
+ fn only_arabic_lays_out_right_to_left() {
+ for (code, _) in LANGUAGES {
+ assert_eq!(is_rtl(code), *code == "ar", "{code}");
+ }
+ }
+
+ /// Built-in style and workspace names follow the interface language; names people give their
+ /// own styles and workspaces are document data and stay as typed.
+ #[test]
+ fn only_built_in_style_and_workspace_names_are_translated() {
+ for (code, _) in LANGUAGES {
+ for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] {
+ assert_eq!(style_name(code, name), tr(code, name), "{code}");
+ }
+ assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}");
+ for custom in ["Group", "Columns", "My layout style"] {
+ assert_eq!(style_name(code, custom), custom, "{code}");
+ assert_eq!(workspace_name(code, custom), custom, "{code}");
+ }
}
- assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці");
- assert_eq!(tr("uk", "Columns"), "Колонки");
- assert_eq!(tr_context("uk", "Group", "selection"), "Група");
- // Step and Repeat's grid columns are not a text frame's.
- assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:");
- assert_eq!(tr_context("ja", "Columns:", "grid"), "列:");
- assert_eq!(tr("ja", "Columns:"), "段:");
- assert_eq!(tr("uk", "Group"), "Згрупувати");
- assert_eq!(tr_context("", "Columns", "table"), "Columns");
- assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き");
- assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割");
- assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption");
- assert_eq!(tr("uk", "through"), "включно");
- assert_eq!(tr("uk", "up to"), "не включно");
- assert!(!is_rtl("uk"));
- assert_eq!(tr("uk", "Type"), "Текст");
- assert_eq!(tr("uk", "Spread"), "Розворот");
- assert_eq!(tr("uk", "Story"), "Текстовий матеріал");
- assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула");
- assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]");
- assert_eq!(style_name("uk", "My layout style"), "My layout style");
- assert_eq!(workspace_name("uk", "Essentials"), "Основне");
- assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
}
#[test]
- fn georgian_catalog_preserves_placeholders_and_counts() {
- let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys");
- let placeholders = |s: &str| {
- s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>()
- };
- for (key, translation) in ka::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- // Georgian has no capitals in running text: Mtavruli never appears in the interface.
- assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}");
- }
- assert_eq!(tr("ka", "File"), "ფაილი");
- assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…");
- assert_eq!(tr("ka", "Unknown label"), "Unknown label");
- assert!(!is_rtl("ka"));
+ fn georgian_counts_use_category_captions_for_every_number() {
for n in [1, 2, 5, 21] {
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
}
@@ -4941,62 +4807,6 @@ mod tests {
assert_eq!(count_label("", "objects", 2), "2 objects");
}
- #[test]
- fn russian_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
- keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
- keys.extend(it::TABLE.iter().map(|(key, _)| *key));
- keys.extend(ja::TABLE.iter().map(|(key, _)| *key));
- keys.extend(uk::TABLE.iter().map(|(key, _)| *key));
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys no catalog knows any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in ru::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- }
- }
-
- #[test]
- fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() {
- assert!(LANGUAGES.contains(&("ru", "Русский")));
- assert!(!is_rtl("ru"));
- assert_eq!(tr("ru", "File"), "Файл");
- assert_eq!(tr("ru", "Type"), "Текст");
- assert_eq!(tr("ru", "Spread"), "Разворот");
- assert_eq!(tr("ru", "Story"), "Материал");
- assert_eq!(tr("ru", "Unknown label"), "Unknown label");
- assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы");
- assert_eq!(tr("ru", "Columns"), "Колонки");
- assert_eq!(tr_context("ru", "Group", "selection"), "Группа");
- assert_eq!(tr("ru", "Group"), "Сгруппировать");
- assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption");
- assert_eq!(tr("ru", "through"), "включительно");
- assert_eq!(tr("ru", "up to"), "не включая");
- assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография");
- assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки");
- assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]");
- assert_eq!(style_name("ru", "My layout style"), "My layout style");
- assert_eq!(workspace_name("ru", "Essentials"), "Основное");
- assert_eq!(workspace_name("ru", "My workspace"), "My workspace");
- // Fixed standard names stay identical.
- assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4");
- }
-
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
@@ -5009,45 +4819,6 @@ mod tests {
}
}
- #[test]
- fn polish_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
- }
- // A new English string falls back to English until it is translated; only stale entries fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in pl::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- }
- assert!(LANGUAGES.contains(&("pl", "Polski")));
- assert!(!is_rtl("pl"));
- assert_eq!(tr("pl", "File"), "Plik");
- assert_eq!(tr("pl", "New Document…"), "Nowy dokument…");
- assert_eq!(tr("pl", "Spread"), "Rozkładówka");
- assert_eq!(tr("pl", "Unknown label"), "Unknown label");
- assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]");
- assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe");
- // A table's columns and grid columns aren't a text frame's; a selection's group isn't the action.
- assert_eq!(tr("pl", "Columns"), "Łamy");
- assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny");
- assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:");
- assert_eq!(tr("pl", "Group"), "Grupuj");
- assert_eq!(tr_context("pl", "Group", "selection"), "Grupa");
- }
-
#[test]
fn polish_counts_use_category_captions_for_every_number() {
for n in [0, 1, 2, 5, 12, 22, 25] {
@@ -5058,14 +4829,10 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
- /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool
- /// tables and the list of UI-only labels know them.
- fn interface_strings() -> std::collections::HashSet<&'static str> {
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
- }
+ /// Every English string the interface passes to `tr`, as far as the command and tool tables and
+ /// the list of UI-only labels know them.
+ fn interface_strings() -> HashSet<&'static str> {
+ let mut keys = HashSet::new();
// Command labels and menu names reach `tr` from the engine and the UI command table.
for spec in designcraft_engine::command_specs() {
keys.insert(spec.label);
@@ -5085,6 +4852,8 @@ mod tests {
"(unchanged)",
"[No Language]",
"Cell Number:",
+ "Differs from this document",
+ "Digits:",
"Change fill to:",
"Chinese: Simplified",
"Chinese: Traditional",
@@ -5120,6 +4889,7 @@ mod tests {
"Found",
"Grab bag",
"Key Object",
+ "Load Styles",
"layer",
"layers",
"Lines +/−",
@@ -5135,6 +4905,7 @@ mod tests {
"Redefine Document Style",
"Report an issue",
"Reverse the order",
+ "Sort Entries in Alphabetical Order",
"Story {story} ← story {parent}",
"Story Editor — {count} words",
"Text Anchor",
@@ -5156,48 +4927,6 @@ mod tests {
keys
}
- #[test]
- fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
- let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
- let keys = interface_strings();
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys nothing passes to `tr` any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in cs::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- // Dialog ellipses, label colons and padded button captions stay as in English.
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- }
- assert!(!is_rtl("cs"));
- assert_eq!(tr("cs", "File"), "Soubor");
- assert_eq!(tr("cs", "New Document…"), "Nový dokument…");
- assert_eq!(tr("cs", "Spread"), "Dvoustrana");
- assert_eq!(tr("cs", "Bleed"), "Spadávka");
- assert_eq!(tr("cs", "Leading"), "Proklad");
- assert_eq!(tr("cs", "Unknown label"), "Unknown label");
- assert_eq!(tr_context("cs", "Group", "selection"), "Skupina");
- assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno");
- assert_eq!(tr_context("cs", "Name", "unknown"), "Název");
- assert_eq!(tr("cs", "Group"), "Seskupit");
- assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns"));
- assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]");
- assert_eq!(style_name("cs", "My layout style"), "My layout style");
- assert_eq!(workspace_name("cs", "Essentials"), "Základní");
- assert_eq!(workspace_name("cs", "My workspace"), "My workspace");
- }
-
#[test]
fn czech_counts_use_the_plural_form_for_the_number() {
for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] {
@@ -5222,51 +4951,4 @@ mod tests {
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
}
}
-
- #[test]
- fn czech_translates_every_tool_name() {
- for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
- assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id);
- assert_eq!(tr("", tool.label), tool.label);
- }
- }
-
- #[test]
- fn japanese_vertical_type_commands_are_translated() {
- for (en, ja) in [
- ("Story Direction", "組み方向"),
- ("Horizontal", "横書き"),
- ("Vertical", "縦書き"),
- ("Tate-Chu-Yoko", "縦中横"),
- ("Ruby…", "ルビ…"),
- ("Kenten", "圏点"),
- ("Warichu", "割り注"),
- ] {
- assert_eq!(tr("ja", en), ja);
- }
- }
-
- #[test]
- fn pdf_export_dialog_strings_are_translated() {
- // Translated labels in every supported language.
- assert_ne!(tr("zh", "Crop Marks"), "Crop Marks");
- assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF");
- assert_ne!(tr("ar", "Include document bleed"), "Include document bleed");
- assert_ne!(tr("pt-br", "Compress images"), "Compress images");
- // English returns the key unchanged.
- assert_eq!(tr("", "Export PDF"), "Export PDF");
- // An unknown string still falls through unchanged.
- assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print");
- // Fixed standard names stay identical in every language.
- assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4");
- }
-
- #[test]
- fn style_row_menu_strings_are_translated_in_every_language() {
- for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] {
- assert_ne!(tr(code, key), key, "{code}: {key}");
- }
- }
- }
}
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index d47657b09..4f4177cbd 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -2037,7 +2037,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Print Layer", "Ebene drucken"),
("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"),
// Remaining interface strings.
- ("A custom name", "Ein eigener Name"),
("Close Panel", "Bedienfeld schließen"),
("Default Fill and Stroke", "Standardfläche und -kontur"),
("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"),
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 18fa402a5..535c87629 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2460,7 +2460,7 @@ mod tests {
assert_eq!(app.ui.status, "Table: No table selected");
app.ui.language = "cs".into();
app.status_error("Table", "No table selected");
- assert_eq!(app.ui.status, "Tabulka: No table selected");
+ assert_eq!(app.ui.status, format!("{}: No table selected", crate::i18n::tr("cs", "Table")));
}
#[test]
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 2a271324a..87537abd7 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -3367,16 +3367,12 @@ mod tests {
}
#[test]
- fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() {
+ fn screen_mode_shows_the_current_mode() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m }));
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false)));
app.ui.screen_mode = crate::ScreenMode::Preview;
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true)));
- // Every View submenu has its Japanese path (Edit › Menus shows them).
- for path in submenu_paths("View") {
- assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row");
- }
}
fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) {
@@ -3793,8 +3789,8 @@ mod tests {
run_ui(&mut app, "app.language", &json!({"lang": "de"})).unwrap().unwrap();
assert_eq!(app.ui.language, "de");
assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true));
- assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster");
assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err());
+ assert_eq!(app.ui.language, "de");
// The language is set in Preferences, not from a menu; without `lang` the command opens it.
let mut items = Vec::new();
for (_, entries) in menu_tree() {
@@ -3804,153 +3800,23 @@ mod tests {
run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
assert_eq!(app.ui.language, "de");
- app.ui.dialog = None;
-
- run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "zh");
- assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件");
-
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "ar");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ar", title), title, "{title}");
- }
- // Every menu title has a translation (Japanese never matches the English).
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ja", title), title, "{title}");
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "pt-br");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true));
- for (title, _) in menu_tree() {
- // "Layout" is the natural Brazilian Portuguese word, so it stays identical
- // to English; every other title must be translated.
- if title != "Layout" {
- assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}");
- }
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "it");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true));
- for (title, _) in menu_tree() {
- // "File" and "Layout" are the usual Italian menu names, so they stay identical
- // to English; every other title must be translated.
- if title != "File" && title != "Layout" {
- assert_ne!(crate::i18n::tr("it", title), title, "{title}");
- }
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "ka");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true));
- // Every menu title, item and submenu is translated into Georgian.
- let mut untranslated = Vec::new();
- for (title, entries) in menu_tree() {
- let mut labels = vec![title.to_string()];
- let mut stack: Vec<&Item> = entries.iter().collect();
- while let Some(entry) = stack.pop() {
- match entry {
- Item::Sub(label, children) => {
- labels.push(label.clone());
- stack.extend(children.iter());
- }
- Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()),
- _ => {}
- }
- }
- // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language.
- let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
- untranslated
- .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str()));
- }
- assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}");
}
#[test]
- fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() {
+ fn every_interface_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap();
+ app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова · Zażółć gęślą jaźń"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap();
- assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("uk", title), title, "{title}");
+ for (code, _) in crate::i18n::LANGUAGES {
+ run_ui(&mut app, "app.language", &json!({"lang": code})).unwrap().unwrap();
+ assert_eq!(app.ui.language, *code);
+ assert_eq!(checked(&app, "app.language", &json!({"lang": code})), Some(true), "{code}");
+ assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(), "{code}");
+ let bytes = serde_json::to_vec(&app.ui).unwrap();
+ let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(restored.language, *code);
}
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "uk");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name");
- }
-
- #[test]
- fn russian_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap();
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ru", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "ru");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name");
- }
-
- #[test]
- fn czech_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Příliš žluťoučký kůň"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "cs"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "cs");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "cs"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("cs", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "cs");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Soubor");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("cs", "Čeština")), "Preferences lists Czech by its own name");
- }
-
- #[test]
- fn polish_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 2, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "Zażółć gęślą jaźń"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "pl"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "pl");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "pl"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("pl", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "pl");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Plik");
- assert!(crate::i18n::LANGUAGES.contains(&("pl", "Polski")), "Preferences lists Polish by its own name");
}
#[test]
@@ -4993,9 +4859,10 @@ mod tests {
fn command_search_matches_labels_in_the_interface_language() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.language = "de".into();
- let items = command_search(&app, "gruppieren");
+ let label = crate::i18n::tr("de", "Group");
+ let items = command_search(&app, &label.to_lowercase());
let group = items.iter().find(|i| i.id == "object.group").unwrap();
- assert_eq!(group.label, "Gruppieren");
+ assert_eq!(group.label, label);
assert_eq!(group.shortcut.as_deref(), Some("Cmd+G"));
assert!(command_search(&app, "group").iter().any(|i| i.id == "object.group"), "English labels match too");
assert!(command_search(&app, " ").is_empty());
@@ -5652,9 +5519,6 @@ mod tests {
// Language follows Settings, as in the suite's other apps, titled in the interface language.
assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language));
assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep));
- for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}");
- }
let label = |command: &str| {
MAC_APP_MENU.iter().find_map(|m| match m {
AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label),
@@ -5662,7 +5526,6 @@ mod tests {
})
};
assert_eq!(label("app.preferences"), Some("Settings…"));
- assert_ne!(crate::i18n::tr("de", "Settings…"), "Settings…");
let ids = MAC_APP_MENU.iter().filter_map(|m| if let AppMenuItem::Cmd { id, .. } = m { Some(*id) } else { None }).collect::>();
assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}");
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 1cbd623a6..fd0094f9b 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -3148,8 +3148,19 @@ mod tests {
/// are translated, with a sample of each script's figures.
#[test]
fn digits_choices_use_the_reference_names() {
- let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label("fr", *o)).collect();
- assert_eq!(shown, ["Par défaut", "Arabe 0123", "Hindi ٠١٢٣", "Persan ۰۱۲۳", "Natif"].map(String::from));
+ assert_eq!(DIGIT_OPTIONS.map(|o| o.1), ["Default", "Arabic", "Hindi", "Farsi", "Native"]);
+ for (lang, _) in crate::i18n::LANGUAGES {
+ let tr = |s| crate::i18n::tr(lang, s);
+ let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label(lang, *o)).collect();
+ let expected = [
+ tr("Default").to_string(),
+ format!("{} 0123", tr("Arabic")),
+ format!("{} ٠١٢٣", tr("Hindi")),
+ format!("{} ۰۱۲۳", tr("Farsi")),
+ tr("Native").to_string(),
+ ];
+ assert_eq!(shown, expected, "{lang}");
+ }
}
#[test]
From 08b356cf44547f86767d2c198b2d577794654e9f Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 17:15:22 +0200
Subject: [PATCH 056/135] Hyphenation Settings: a mixed checkbox turns on
first; refused settings show why
A checkbox that differs across the selected paragraphs starts off, so the
first click turns it on for all (egui toggles the value under the
indeterminate look). Settings type.para refuses no longer count as
previewed: the paragraphs go back, the reason shows and they are not tried
again until they change; OK keeps the dialog open with the reason.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/dialogs.rs | 81 ++++++++++++++++++++++++++++++++---
1 file changed, 76 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index bf8dd66aa..0262bd396 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -2373,6 +2373,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
check(ui, &mut d, "preview", &preview);
});
hyphenation_preview(app, &mut d);
+ if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
+ ui.add_space(6.0);
+ crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color));
+ }
}
"insertXref" => insert_xref(app, ui, &mut d),
"findFont" => find_font(app, ui, &mut d),
@@ -3191,7 +3195,14 @@ pub fn confirm(app: &mut DesignApp) -> Result {
if attrs.is_empty() {
return Ok(Value::Null);
}
- app.run("type.para", json!({"attrs": attrs}))
+ app.run("type.para", json!({"attrs": attrs})).inspect_err(|e| {
+ // The dialog stays open, with the reason, to be corrected.
+ app.status(e.clone());
+ let mut d = d;
+ d.fields.remove(PREVIEWED);
+ d.fields.insert("status".into(), json!(e));
+ app.ui.dialog = Some(d);
+ })
}
"justification" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
@@ -5827,7 +5838,8 @@ fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, curr
};
let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
let v = cur(d, key);
- let mut on = v.as_bool().unwrap_or(true);
+ // Mixed shows indeterminate and starts off, so the first click turns it on for all.
+ let mut on = v.as_bool().unwrap_or(false);
if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() {
d.fields.insert(format!("{prefix}{key}"), json!(on));
}
@@ -5913,16 +5925,31 @@ fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) {
return;
}
let params = json!({"attrs": hyphenation_edits(d)});
- if shown.as_ref() == Some(¶ms) {
+ if shown.as_ref() == Some(¶ms) || d.fields.get(PREVIEW_FAILED) == Some(¶ms) {
return;
}
if shown.is_none() && app.session.begin_interaction("Hyphenation").is_err() {
return;
}
- let _ = app.session.preview("type.para", params.clone());
- d.fields.insert(PREVIEWED.into(), params);
+ match app.session.preview("type.para", params.clone()) {
+ Ok(_) => {
+ d.fields.remove("status");
+ d.fields.remove(PREVIEW_FAILED);
+ d.fields.insert(PREVIEWED.into(), params);
+ }
+ // Rejected settings: the paragraphs go back, and the reason shows until they change.
+ Err(e) => {
+ app.session.cancel_interaction();
+ d.fields.remove(PREVIEWED);
+ d.fields.insert("status".into(), json!(e.to_string()));
+ d.fields.insert(PREVIEW_FAILED.into(), params);
+ }
+ }
}
+/// The hyphenation settings a preview was refused for (not tried again until they change).
+const PREVIEW_FAILED: &str = "previewFailed";
+
fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) {
let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -7820,6 +7847,50 @@ mod tests {
assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph");
}
+ /// Hyphenate differs across the selection: it shows mixed, and the first click turns it on for
+ /// all. Settings the command refuses show why: a preview goes back and isn't retried until they
+ /// change, and OK keeps the dialog open.
+ #[test]
+ fn hyphenation_settings_mixed_checkbox_and_refused_settings() {
+ let (mut app, story) = two_paragraphs();
+ let ctx = dialog_context("en");
+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
+ app.run("type.para", json!({"attrs": {"hyphenate": false}})).unwrap();
+ app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap();
+ app.run("app.hyphenationDialog", json!({})).unwrap();
+ assert!(app.ui.dialog.as_ref().unwrap().fields["current"]["hyphenate"].is_null(), "mixed");
+ for _ in 0..3 {
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ }
+ let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
+ let at = labels.iter().find(|(t, _, _)| t == "Hyphenate").map(|(_, r, _)| r.left_center() - egui::vec2(10.0, 0.0)).unwrap();
+ click_dialog(&mut app, &ctx, screen, at);
+ assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphenate"), Some(&json!(true)), "the first click turns it on");
+ // A refused setting (as a control-channel caller could send it): no preview, the reason.
+ let before = undo_steps(&app);
+ let d = app.ui.dialog.as_mut().unwrap();
+ d.fields.insert("hyphWeight".into(), json!("heavy"));
+ d.fields.insert("preview".into(), json!(true));
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ let d = app.ui.dialog.as_ref().unwrap();
+ assert!(!d.fields.contains_key(PREVIEWED) && d.fields.contains_key("status"), "{:?}", d.fields);
+ assert!(app.session.doc().unwrap().interaction.is_none(), "the preview went back");
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ assert!(app.session.doc().unwrap().interaction.is_none(), "not tried again");
+ // OK with it: an error, and the dialog stays open with the reason.
+ assert!(confirm(&mut app).is_err());
+ let d = app.ui.dialog.as_ref().expect("the dialog stays open");
+ assert!(d.fields.contains_key("status"));
+ assert_eq!(undo_steps(&app), before);
+ // Corrected, it previews and applies.
+ app.ui.dialog.as_mut().unwrap().fields.insert("hyphWeight".into(), json!(0.9));
+ dialog_frame(&mut app, &ctx, screen, vec![]);
+ assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("status"));
+ confirm(&mut app).unwrap();
+ assert_eq!(para_attr(&mut app, &story, 0, "hyphenate"), true);
+ assert_eq!(para_attr(&mut app, &story, 4, "hyphWeight"), 0.9);
+ }
+
#[test]
fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() {
let (mut app, story) = two_paragraphs();
From b3896d812213ea904d5609cafe0e2742b26adebb Mon Sep 17 00:00:00 2001
From: moppeeer
Date: Thu, 22 Oct 2026 17:30:14 +0200
Subject: [PATCH 057/135] List buttons: track the hanging indent they set
(listHang)
A paragraph attribute records that the Bulleted or Numbered List button set
the hanging indent; turning the list off takes the indent away only then
(and while it is still as applied), and clears the mark. A paragraph whose
own indents were 18/-18 before the list keeps them.
Co-Authored-By: Claude Opus 5.5
---
crates/doc/src/attrs.rs | 3 +++
crates/engine/src/cmd/lists.rs | 36 ++++++++++++++++++++++++++++------
docs/target-app-parity.md | 2 +-
3 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs
index 077637672..8151c6846 100644
--- a/crates/doc/src/attrs.rs
+++ b/crates/doc/src/attrs.rs
@@ -1270,6 +1270,9 @@ attr_set! {
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
+ /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away
+ /// again with the list (an indent the paragraph had before stays).
+ list_hang: bool = false,
balance_ragged: bool = false,
optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs
index 24578bc42..74570f3fd 100644
--- a/crates/engine/src/cmd/lists.rs
+++ b/crates/engine/src/cmd/lists.rs
@@ -99,8 +99,8 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
return;
}
let (style, _) = styles.resolve_para_style(&pf.style);
- // The hanging indent set on the paragraph itself (by a list button), not by its style.
- let own_hang = pf.para.left_indent == Some(LIST_HANG) && pf.para.first_line_indent == Some(-LIST_HANG);
+ // The hanging indent this button set (and nothing has changed since).
+ let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG;
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
@@ -110,10 +110,15 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result {
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
- } else if !on && now.list_type == kind && own_hang {
- // Only the hanging indent set here: one the paragraph style gives stays.
- set("leftIndent", json!(0.0), style.left_indent == 0.0);
- set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
+ set("listHang", json!(true), false);
+ } else if !on && now.list_type == kind {
+ // Only the hanging indent set here: one the paragraph had before, or its style
+ // gives, stays.
+ if own_hang {
+ set("leftIndent", json!(0.0), style.left_indent == 0.0);
+ set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
+ }
+ set("listHang", Value::Null, true);
}
});
}
@@ -320,6 +325,25 @@ Flour
assert_eq!(kinds(&s), [ListType::Numbers; 4]);
}
+ /// A paragraph's own hanging indent, the same as the button's, stays when the list goes off:
+ /// the button takes away only the indent it set.
+ #[test]
+ fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
+ let sid = f["story"].as_u64().unwrap();
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
+ s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
+ s.execute("list.bullets", &json!({"on": true})).unwrap();
+ s.execute("list.bullets", &json!({"on": false})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
+ let p = d.styles.resolve_para(pf).0;
+ assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
+ assert_eq!(pf.para.list_hang, None, "nothing of the list left");
+ }
+
/// A hanging indent the paragraph style gives stays when the list goes off.
#[test]
fn a_style_hanging_indent_stays_when_the_list_goes_off() {
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index c26b0384f..a178320d6 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -402,7 +402,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web).
- 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.
-- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it and it is still as applied (a hanging indent the paragraph style gives stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style.
+- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it (`listHang`) and it is still as applied (a hanging indent the paragraph had before, or its style gives, stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style.
- 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.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
From bba31c17b6e3116916b46272cec906a90a054ff7 Mon Sep 17 00:00:00 2001
From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
Date: Wed, 14 Oct 2026 11:17:34 -0600
Subject: [PATCH 058/135] PDF: report visible unembedded fonts in PDF/X-4
(#909)
Track text state through graphics saves and drawn forms; exempt invisible text. Report font names and pages in export warnings and validation without embedding or outlining placed fonts.
---
ROADMAP.md | 6 +
apps/designcraft-cli/src/main.rs | 2 +-
crates/pdf/src/pdfx.rs | 116 ++++++++++++++++++-
crates/pdf/src/pdfx/fonts.rs | 172 +++++++++++++++++++++++++++++
crates/pdf/src/pdfx/fonts/tests.rs | 153 +++++++++++++++++++++++++
docs/agents.md | 10 ++
docs/file-format-parity.md | 11 +-
docs/gaps.md | 15 ++-
docs/target-app-parity.md | 12 +-
9 files changed, 489 insertions(+), 8 deletions(-)
create mode 100644 crates/pdf/src/pdfx/fonts.rs
create mode 100644 crates/pdf/src/pdfx/fonts/tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index d3ef17e47..b3cc76a63 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -354,6 +354,12 @@ Newest first.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
+- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
+ font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
+ Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
+ Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit
+ report incomplete checks instead of allowing excessive nested-form traversal.
+ No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
index 71e05a480..0c0a15267 100644
--- a/apps/designcraft-cli/src/main.rs
+++ b/apps/designcraft-cli/src/main.rs
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
-const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
+const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs
index 534886f1d..3303ca879 100644
--- a/crates/pdf/src/pdfx.rs
+++ b/crates/pdf/src/pdfx.rs
@@ -10,6 +10,8 @@ use hayro_syntax::page::Resources;
use crate::transitions::{find, int_after, object, rfind};
+mod fonts;
+
/// Escape a PDF literal string.
fn lit(s: &str) -> String {
let mut o = String::from("(");
@@ -246,6 +248,7 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec {
issues.push("Type 3 fonts aren't allowed".into());
}
issues.extend(device_rgb(pdf));
+ issues.extend(fonts::unembedded_fonts(pdf));
issues
}
@@ -480,7 +483,7 @@ mod tests {
use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report};
- fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) {
+ pub(super) fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) {
let asset = AssetId(d.alloc());
let mime = designcraft_images::mime(&data).into();
let pixels = designcraft_images::pixel_size(&data);
@@ -579,7 +582,7 @@ mod tests {
pdf.finish().unwrap()
}
- fn x4(d: &Document) -> ExportReport {
+ pub(super) fn x4(d: &Document) -> ExportReport {
export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap()
}
@@ -645,13 +648,18 @@ mod tests {
/// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its
/// resources, a group…) and `content`.
fn tiny_pdf(entries: &str, content: &str) -> Vec {
+ tiny_pdf_with(entries, content, "<>\nstream\n\nendstream")
+ }
+
+ /// [`tiny_pdf`] with `extra` as object 6 (an RGB profile in [`tiny_pdf`]).
+ pub(super) fn tiny_pdf_with(entries: &str, content: &str, extra: &str) -> Vec {
let objects = [
"<>]>>".to_string(),
"<>".to_string(),
format!("<>"),
"<>\nstream\n\nendstream".to_string(),
format!("<>\nstream\n{content}\nendstream", content.len()),
- "<>\nstream\n\nendstream".to_string(),
+ extra.to_string(),
];
let mut out = b"%PDF-1.6\n".to_vec();
let mut offsets = Vec::new();
@@ -682,6 +690,108 @@ mod tests {
assert!(!cmyk.iter().any(rgb), "{cmyk:?}");
}
+ /// Every font that draws visible text must be embedded (#909): a base-14 font with no font
+ /// file, a CID font whose descendant has none, and one drawn from inside a form are reported;
+ /// an embedded font, an unused one and invisible text (render mode 3, as in OCR layers) are not.
+ #[test]
+ fn unembedded_fonts_are_flagged() {
+ let fonts = |entries: &str, content: &str| -> Vec {
+ check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect()
+ };
+ let helvetica = "/Resources<>>>>>";
+ assert_eq!(
+ fonts(helvetica, "BT /F1 24 Tf 20 50 Td (Base14 text) Tj ET"),
+ ["the font Helvetica isn't embedded (page 1): PDF/X-4 needs every font used for visible text embedded"]
+ );
+ assert!(fonts(helvetica, "BT /F1 24 Tf 3 Tr 20 50 Td (OCR) Tj ET").is_empty(), "invisible text needs no embedded font");
+ assert!(fonts(helvetica, "0 0 0 1 k 0 0 10 10 re f").is_empty(), "an unused font is not reported");
+ let restored = fonts(helvetica, "BT /F1 24 Tf q 3 Tr Q 20 50 Td (shown) Tj ET");
+ assert_eq!(restored.len(), 1, "Q restores the visible render mode: {restored:?}");
+
+ let embedded = "/Resources<>>>>>>>";
+ assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty());
+ let cid = "/Resources<>>>]>>>>>>";
+ assert_eq!(fonts(cid, "BT /F1 12 Tf <0001> Tj ET").len(), 1);
+ let cid_embedded = cid.replace("/FontName/KozMin>>", "/FontName/KozMin/FontFile3 6 0 R>>");
+ assert!(fonts(&cid_embedded, "BT /F1 12 Tf <0001> Tj ET").is_empty());
+
+ let draw = "BT /T 9 Tf (in a form) Tj ET";
+ let form = format!(
+ "<>>>>>/Length {}>>\nstream\n{draw}\nendstream",
+ draw.len()
+ );
+ let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form);
+ let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("font")).collect();
+ assert_eq!(issues.len(), 1, "{issues:?}");
+ assert!(issues[0].starts_with("the font Times-Roman isn't embedded"), "{issues:?}");
+ }
+
+ /// A placed PDF that draws with an unembedded font: the PDF/X-4 export warns about it (#909).
+ #[test]
+ fn placed_pdf_with_an_unembedded_font_is_reported() {
+ let mut d = Document::new(&NewDocument::default());
+ let placed = tiny_pdf(
+ "/Resources<>>>>>",
+ "BT /F1 24 Tf 0 0 0 1 k 20 50 Td (Base14 text) Tj ET",
+ );
+ place(&mut d, "helvetica.pdf", placed, (100.0, 100.0), (72.0, 72.0));
+ let r = x4(&d);
+ assert!(r.warnings.iter().any(|w| w.starts_with("PDF/X-4: the font Helvetica isn't embedded (page 1)")), "{:?}", r.warnings);
+ }
+
+ #[test]
+ fn unembedded_fonts_are_reported_by_every_text_show_operator() {
+ let entries = "/Resources<>>>>>";
+ for show in ["(x) Tj", "[(x) 20 (y)] TJ", "(x) '", "0 0 (x) \""] {
+ let pdf = tiny_pdf(entries, &format!("BT /F1 12 Tf {show} ET"));
+ let issues = check_pdfx4(&pdf);
+ assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{show}: {issues:?}");
+ }
+ }
+
+ #[test]
+ fn unembedded_fonts_in_forms_inherit_and_restore_text_state() {
+ let entries = "/Resources<>>>/XObject<>>>";
+ let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET";
+ let form = format!("<>\nstream\n{draw}\nendstream", draw.len());
+ for (content, count) in [
+ ("/F1 12 Tf /X1 Do", 1),
+ ("/F1 12 Tf 3 Tr /X1 Do", 0),
+ ("/F1 12 Tf 3 Tr /X1 Do 0 Tr /X1 Do", 1),
+ ("/F1 12 Tf /X1 Do BT (still visible) Tj ET", 1),
+ ] {
+ let pdf = tiny_pdf_with(entries, content, &form);
+ let issues = check_pdfx4(&pdf);
+ assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), count, "{content}: {issues:?}");
+ }
+ }
+
+ #[test]
+ fn unembedded_fonts_in_recursive_forms_are_bounded() {
+ let draw = "BT /F1 12 Tf (x) Tj ET /X1 Do";
+ let form = format!(
+ "<>>>/XObject<>>>/Length {}>>\nstream\n{draw}\nendstream",
+ draw.len()
+ );
+ let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form);
+ let issues = check_pdfx4(&pdf);
+ assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{issues:?}");
+ }
+
+ #[test]
+ fn unembedded_fonts_are_reported_after_form_text_state_is_restored() {
+ let entries = "/Resources<>>>/XObject<>>>";
+ let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET";
+ let form = format!(
+ "<>>>>>/Length {}>>\nstream\n{draw}\nendstream",
+ draw.len()
+ );
+ let pdf = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do BT (visible) Tj ET", &form);
+ let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("isn't embedded")).collect();
+ assert_eq!(issues.len(), 1, "{issues:?}");
+ assert!(issues[0].starts_with("the font Helvetica isn't embedded"), "{issues:?}");
+ }
+
/// An RGB blending space on the page group and an RGB inline image are device RGB too.
#[test]
fn rgb_groups_and_inline_images_are_flagged() {
diff --git a/crates/pdf/src/pdfx/fonts.rs b/crates/pdf/src/pdfx/fonts.rs
new file mode 100644
index 000000000..5dc149b7f
--- /dev/null
+++ b/crates/pdf/src/pdfx/fonts.rs
@@ -0,0 +1,172 @@
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
+
+use hayro_syntax::content::UntypedIter;
+use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier};
+use hayro_syntax::page::Resources;
+
+use super::MAX_DEPTH;
+
+#[cfg(test)]
+mod tests;
+
+const MAX_OPERATIONS: usize = 100_000;
+const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete";
+const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete";
+
+pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec {
+ let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() };
+ let mut found: BTreeMap> = BTreeMap::new();
+ let mut scan = FontScan::default();
+ for (i, page) in file.pages().iter().enumerate() {
+ scan.font = None;
+ scan.mode = 0;
+ if let Some(content) = page.page_stream() {
+ scan.content(content, page.resources(), 0);
+ }
+ for name in std::mem::take(&mut scan.found) {
+ found.entry(name).or_default().push(i + 1);
+ }
+ if scan.limited {
+ break;
+ }
+ }
+ let mut warnings: Vec = found
+ .into_iter()
+ .map(|(name, pages)| {
+ let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect();
+ let more = if pages.len() > 10 { ", …" } else { "" };
+ let s = if pages.len() == 1 { "" } else { "s" };
+ format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", "))
+ })
+ .collect();
+ if scan.limited {
+ warnings.push(LIMIT_WARNING.to_string());
+ }
+ if scan.depth_limited {
+ warnings.push(DEPTH_WARNING.to_string());
+ }
+ warnings
+}
+
+fn font_embedded(font: &Dict<'_>) -> bool {
+ let descriptor = match font.get::>(b"Subtype").as_deref() {
+ Some(b"Type3") => return true,
+ Some(b"Type0") => {
+ font.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).and_then(|d| d.get::>(b"FontDescriptor"))
+ }
+ _ => font.get::>(b"FontDescriptor"),
+ };
+ descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3"))
+}
+
+#[derive(Default)]
+struct FontScan<'a> {
+ found: BTreeSet,
+ active: HashSet,
+ path: Vec,
+ completed: HashMap, BTreeSet>,
+ font: Option>,
+ mode: i32,
+ operations: usize,
+ limited: bool,
+ // A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is
+ // never cleared, so a memoized subtree that was cut short can't hide it.
+ depth_limited: bool,
+}
+
+// Forms inherit text state and missing resource names. Depth and ancestors also affect
+// which descendants the depth/cycle guards permit; only identical contexts may share results.
+#[derive(Hash, PartialEq, Eq)]
+struct FormContext<'a> {
+ form: ObjectIdentifier,
+ font: Option<&'a [u8]>,
+ mode: i32,
+ resources: Vec<(&'a [u8], &'a [u8])>,
+ ancestors: Vec,
+}
+
+impl<'a> FontScan<'a> {
+ fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ let mut saved = Vec::new();
+ let mut ops = UntypedIter::new(data);
+ while let Some(op) = ops.next() {
+ if self.operations == MAX_OPERATIONS {
+ self.limited = true;
+ return;
+ }
+ self.operations += 1;
+ let operator: &[u8] = op.operator;
+ match operator {
+ b"q" => saved.push((self.font.clone(), self.mode)),
+ b"Q" => {
+ if let Some(state) = saved.pop() {
+ (self.font, self.mode) = state;
+ }
+ }
+ b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)),
+ b"Tr" => {
+ if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) {
+ self.mode = mode;
+ }
+ }
+ b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => {
+ if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) {
+ let name = font.get::>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string());
+ self.found.insert(name);
+ }
+ }
+ b"Do" => {
+ let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n));
+ if let Some(form) = xo.filter(|x| x.dict().get::>(b"Subtype").as_deref() == Some(b"Form"))
+ && (depth < MAX_DEPTH || {
+ // A form already being scanned is a cycle: it can't hide new content.
+ self.depth_limited |= !self.active.contains(&form.obj_id());
+ false
+ })
+ && self.active.insert(form.obj_id())
+ {
+ let mut resources = Vec::new();
+ let mut parent = Some(res);
+ while let Some(r) = parent {
+ resources.push((r.fonts.data(), r.x_objects.data()));
+ parent = r.parent();
+ }
+ let key = FormContext {
+ form: form.obj_id(),
+ font: self.font.as_ref().map(Dict::data),
+ mode: self.mode,
+ resources,
+ ancestors: self.path.clone(),
+ };
+ if let Some(found) = self.completed.get(&key) {
+ self.found.extend(found.iter().cloned());
+ self.active.remove(&form.obj_id());
+ continue;
+ }
+ let inner = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), res.clone());
+ let state = (self.font.clone(), self.mode);
+ let outer_found = std::mem::take(&mut self.found);
+ self.path.push(form.obj_id());
+ if let Ok(data) = form.decoded() {
+ self.content(&data, &inner, depth + 1);
+ }
+ self.path.pop();
+ if !self.limited {
+ self.completed.insert(key, self.found.clone());
+ }
+ self.found.extend(outer_found);
+ (self.font, self.mode) = state;
+ self.active.remove(&form.obj_id());
+ if self.limited {
+ return;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+}
diff --git a/crates/pdf/src/pdfx/fonts/tests.rs b/crates/pdf/src/pdfx/fonts/tests.rs
new file mode 100644
index 000000000..c148cb076
--- /dev/null
+++ b/crates/pdf/src/pdfx/fonts/tests.rs
@@ -0,0 +1,153 @@
+use std::time::{Duration, Instant};
+
+use super::{DEPTH_WARNING, FontScan, LIMIT_WARNING, MAX_OPERATIONS, unembedded_fonts};
+use crate::check_pdfx4;
+use crate::pdfx::tests::{place, tiny_pdf_with, x4};
+use designcraft_doc::{Document, build::NewDocument};
+
+fn stream(entries: &str, content: &str) -> String {
+ format!("<<{entries}/Length {}>>\nstream\n{content}\nendstream", content.len())
+}
+
+fn fanout_pdf(levels: usize) -> Vec {
+ let mut objects = vec![
+ "<>".to_string(),
+ "<>".to_string(),
+ "<>>>/Contents 4 0 R>>".to_string(),
+ stream("", "/Next Do"),
+ ];
+ for level in 0..levels {
+ let resources = if level + 1 == levels {
+ "/Font<>>>".to_string()
+ } else {
+ format!("/XObject<>", level + 6)
+ };
+ let content = if level + 1 == levels { "BT /F1 12 Tf (bottom) Tj ET".to_string() } else { "/Next Do\n".repeat(10) };
+ objects.push(stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{resources}>>"), &content));
+ }
+ pdf_objects(&objects)
+}
+
+fn pdf_objects(objects: &[String]) -> Vec {
+ let mut bytes = b"%PDF-1.6\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = bytes.len();
+ bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
+ for offset in offsets {
+ bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
+ bytes
+}
+
+#[test]
+fn nested_form_fanout_is_memoized_and_reports_bottom_font() {
+ let bytes = fanout_pdf(16);
+ let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
+ let page = pdf.pages().first().unwrap();
+ let mut scan = FontScan::default();
+ let start = Instant::now();
+ scan.content(page.page_stream().unwrap(), page.resources(), 0);
+ assert!(start.elapsed() < Duration::from_millis(500));
+ assert_eq!(scan.operations, 155);
+ assert_eq!(scan.completed.len(), 16);
+ assert!(!scan.limited);
+ assert!(!scan.depth_limited);
+ assert_eq!(scan.found.into_iter().collect::>(), ["Helvetica"]);
+ assert!(check_pdfx4(&bytes).iter().any(|w| w.contains("Helvetica isn't embedded")));
+}
+
+#[test]
+fn placed_pdf_nested_form_fanout_reports_unembedded_font() {
+ let mut doc = Document::new(&NewDocument::default());
+ place(&mut doc, "fanout.pdf", fanout_pdf(14), (100.0, 100.0), (72.0, 72.0));
+ let report = x4(&doc);
+ assert!(report.warnings.iter().any(|w| w.contains("Helvetica isn't embedded")), "{:?}", report.warnings);
+}
+
+#[test]
+fn form_memo_resolves_resource_names_in_each_parent_context() {
+ let wrapper = |font: &str| {
+ stream(
+ &format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>"),
+ "/Shared Do",
+ )
+ };
+ let bytes = pdf_objects(&[
+ "<>".into(),
+ "<>".into(),
+ "<>>>/Contents 4 0 R>>".into(),
+ stream("", "/A Do /B Do"),
+ wrapper("Helvetica"),
+ wrapper("Times-Roman"),
+ stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", "BT /F1 12 Tf (shared) Tj ET"),
+ ]);
+ let issues = unembedded_fonts(&bytes);
+ assert_eq!(issues.len(), 2, "{issues:?}");
+ assert!(issues.iter().any(|w| w.contains("Helvetica isn't embedded")));
+ assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded")));
+}
+
+#[test]
+fn form_memo_replays_font_findings_on_each_page() {
+ let page = "<>>>/Contents 5 0 R>>";
+ let bytes = pdf_objects(&[
+ "<>".into(),
+ "<>".into(),
+ page.into(),
+ page.into(),
+ stream("", "/Shared Do"),
+ stream(
+ "/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>",
+ "BT /F1 12 Tf (shared) Tj ET",
+ ),
+ ]);
+ let issues = unembedded_fonts(&bytes);
+ assert_eq!(issues.len(), 1, "{issues:?}");
+ assert!(issues[0].contains("(pages 1, 2)"), "{issues:?}");
+}
+
+#[test]
+fn form_memo_keeps_inherited_font_and_resource_contexts_distinct() {
+ let draw = "BT (inherited) Tj /F1 12 Tf (resource) Tj ET";
+ let form = stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", draw);
+ let entries = "/Resources<>/F2<>>>/XObject<>>>";
+ let bytes = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do /F2 12 Tf /X1 Do", &form);
+ let issues = unembedded_fonts(&bytes);
+ assert_eq!(issues.len(), 2, "{issues:?}");
+ assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded")));
+}
+
+#[test]
+fn font_scan_operation_budget_warns_instead_of_silently_truncating() {
+ let content = "q Q\n".repeat(MAX_OPERATIONS);
+ let bytes = tiny_pdf_with("/Resources<<>>", &content, "<<>>");
+ assert_eq!(unembedded_fonts(&bytes), [LIMIT_WARNING]);
+}
+
+#[test]
+fn font_scan_depth_limit_warns_instead_of_silently_truncating() {
+ // The font is two levels below the deepest form followed, and every level draws the next
+ // ten times: memo replays of the cut-short subtree still give one warning.
+ assert_eq!(unembedded_fonts(&fanout_pdf(18)), [DEPTH_WARNING]);
+}
+
+#[test]
+fn font_scan_cycles_are_not_reported_as_incomplete() {
+ let font = "/Font<>>>";
+ let bytes = pdf_objects(&[
+ "<>".to_string(),
+ "<>".to_string(),
+ "<>>>/Contents 4 0 R>>".to_string(),
+ stream("", "/A Do"),
+ stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{font}/XObject<>>>"), "BT /F1 12 Tf (a) Tj ET /B Do"),
+ stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>", "/A Do /B Do"),
+ ]);
+ let warnings = unembedded_fonts(&bytes);
+ assert_eq!(warnings.len(), 1, "{warnings:?}");
+ assert!(warnings[0].contains("Helvetica isn't embedded"), "{warnings:?}");
+}
diff --git a/docs/agents.md b/docs/agents.md
index 2b8d6e51e..566357fbe 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -63,6 +63,16 @@ The image must contain at most 16 million pixels. PDF/SVG graphics are unsupport
not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme…
in the Swatches panel menu with one image selected. Cancel keeps the document unchanged.
+## PDF/X-4 validation
+
+`designcraft-cli validate-pdf FILE.pdf` prints JSON with `valid` and `issues`, and exits non-zero
+when a built-in check fails. Checks cover PDF version, output intent and profile, Info and XMP
+identification, image interpolation, encryption, trim boxes, Type 3 fonts, device RGB under a CMYK
+output intent, and unembedded fonts drawing visible text (including placed PDF forms). Render
+mode 3 (invisible OCR text) is exempt from the font-embedding check. PDF/X-4 export reports these
+issues as warnings; it does not embed or outline placed fonts. These checks are not a complete
+external PDF/X certification.
+
## Data merge
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 381498fb6..2a05973af 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
@@ -55,6 +55,14 @@ layer survive interchange; literal XML and round-trip tests are in
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
+PDF/X-4 export warns about unembedded fonts that draw visible text, including inside placed PDF
+forms; `designcraft-cli validate-pdf FILE.pdf` reports the same issues, with font names and pages
+(#909). Invisible text (render mode 3) is exempt from this font-embedding check. Detection does not
+automatically embed or outline fonts; embedding, substitution or outlining of placed-PDF fonts
+remains missing. A document-wide operation limit and a form-nesting depth limit report incomplete
+font checks rather than traversing pathological nesting. These built-in checks are not complete
+external PDF/X certification.
+
## Placed and imported content
| Format | Ext | InDesign | DesignCraft | Notes | Weight |
@@ -114,6 +122,7 @@ up to 30 points.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | PDF/X-4 export and `validate-pdf` report visible unembedded fonts by name and page, including placed forms; invisible text is exempt, operation- or depth-limited checks report incompleteness, and automatic font repair remains missing (#909) |
| 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) |
| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) |
| 2026-10-13 | minor | `.indd` and `.indt` read through storytold/indd-utils; INDD remaining work is a real-file corpus (10–30 h); score unchanged until it is measured |
diff --git a/docs/gaps.md b/docs/gaps.md
index 5738d14c4..1076f6218 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -123,6 +123,18 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
spot, marks and bleed, the options dialog with remembered settings (#196).
- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md)
+### 28. Placed-PDF font repair for PDF/X-4 (FF)
+- **Missing:** embedding, substitution or outlining of unembedded fonts in placed PDFs; export
+ does not automatically embed or outline them.
+- **Have:** PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible
+ text, including inside placed PDF forms, with font names and pages (#909). Invisible text
+ (render mode 3) is exempt from this font-embedding check. A document-wide operation limit and a
+ form-nesting depth limit report incomplete font checks rather than traversing pathological
+ nesting.
+- **Impact:** detection now ships, but a placed PDF with visible unembedded fonts still needs
+ font repair before it can meet PDF/X-4 requirements.
+- **Doc:** [file-format-parity.md](file-format-parity.md)
+
### 10. Printing (H, F)
- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping.
- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through
@@ -249,6 +261,7 @@ Cue. These are excluded from every percentage.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) |
| 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) |
| 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) |
| 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index bb7c5eb80..53c0f2842 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -217,12 +217,19 @@ table is the one summed.
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
-| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
+| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), with visible unembedded font detection (below); no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth |
| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) |
| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action |
| Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI |
+PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible text, including
+inside placed PDF forms, with font names and pages (#909). Invisible text (render mode 3) is exempt
+from the font-embedding check. Fonts are not embedded or outlined automatically; embedding,
+substitution or outlining of placed-PDF fonts remains missing ([gap 28](gaps.md#28-placed-pdf-font-repair-for-pdfx-4-ff)).
+A document-wide operation limit and a form-nesting depth limit report incomplete font checks rather
+than traversing pathological nesting. This is detection, not complete external PDF/X certification.
+
## Remaining effort and calibration
**Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10
@@ -463,6 +470,7 @@ Task notes that headed ROADMAP.md:
| Date | Change | Summary |
|---|---|---|
+| 2026-10-19 | minor | Output assessment records visible unembedded font detection in PDF/X-4 export and `validate-pdf`, the invisible-text exemption, incomplete-check reporting at the operation and nesting-depth limits, and remaining placed-PDF font repair (#909); readiness and hours unchanged |
| 2026-10-13 | minor | `.indd` and `.indt` open through storytold/indd-utils; INDD remaining work 10–30 h (real-file corpus); readiness unchanged until it is measured |
| 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged |
| 2026-10-11 | minor | Spring-loaded Zoom navigation added to the working feature inventory (DC-1); readiness estimates unchanged |
From d080a5635a7be3376b129551dd814b11348a23ea Mon Sep 17 00:00:00 2001
From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com>
Date: Thu, 22 Oct 2026 19:13:31 +0200
Subject: [PATCH 059/135] Swatches panel: a gradient dropped on a colour group
row is kept too
The drop zone held the ungrouped swatches only, so releasing the
gradient over a colour group's row did nothing.
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/src/panels/swatches.rs | 130 +++++++++++++++-----------
1 file changed, 76 insertions(+), 54 deletions(-)
diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs
index e5e9a934c..878904a5f 100644
--- a/crates/ui-egui/src/panels/swatches.rs
+++ b/crates/ui-egui/src/panels/swatches.rs
@@ -18,11 +18,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
- // A gradient dragged from the Gradient panel's thumbnail is dropped on the list.
+ // A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour
+ // groups included.
let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| {
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
}
+ for g in &doc.color_groups {
+ let open_id = egui::Id::new(("color_group_open", &g.name));
+ let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
+ let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
+ if resp.hovered() {
+ ui.painter().rect_filled(row, 0.0, t.hover);
+ }
+ icons::paint(
+ ui.painter(),
+ egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
+ if open { "chevron-down" } else { "chevron-right" },
+ t.text_dim,
+ );
+ // A folder.
+ let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
+ ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
+ ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
+ ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
+ // A double click's first click folded the group: it stays as it was.
+ if resp.clicked() {
+ open = !open;
+ ui.data_mut(|d| d.insert_temp(open_id, open));
+ }
+ if resp.double_clicked() {
+ let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
+ }
+ resp.context_menu(|ui| {
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
+ let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
+ ui.close();
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
+ let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
+ ui.close();
+ }
+ });
+ if open {
+ for n in &g.swatches {
+ if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
+ swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
+ }
+ }
+ }
+ }
});
// An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one.
if let Some(g) = dropped
@@ -30,51 +76,6 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
{
let _ = app.run("swatch.new", json!({"swatch": g.0}));
}
- for g in &doc.color_groups {
- let open_id = egui::Id::new(("color_group_open", &g.name));
- let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
- let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
- if resp.hovered() {
- ui.painter().rect_filled(row, 0.0, t.hover);
- }
- icons::paint(
- ui.painter(),
- egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
- if open { "chevron-down" } else { "chevron-right" },
- t.text_dim,
- );
- // A folder.
- let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
- ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
- ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
- ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
- // A double click's first click folded the group: it stays as it was.
- if resp.clicked() {
- open = !open;
- ui.data_mut(|d| d.insert_temp(open_id, open));
- }
- if resp.double_clicked() {
- let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
- }
- resp.context_menu(|ui| {
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
- let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
- ui.close();
- }
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
- let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
- ui.close();
- }
- });
- if open {
- for n in &g.swatches {
- if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
- swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
- }
- }
- }
- }
ui.add_space(6.0);
// InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked).
let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked));
@@ -1054,7 +1055,6 @@ mod gradient_panel_tests {
/// which keeps it as a gradient swatch.
#[test]
fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() {
- use egui::{Event, Modifiers, PointerButton};
let mut app = app_with_frame();
app.run("object.gradient", json!({})).unwrap();
app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
@@ -1064,10 +1064,23 @@ mod gradient_panel_tests {
let before = named(&app);
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
h.run_steps(6);
+ drag_gradient_to(&mut h, "[Black]");
+ let after = named(&h.state().app);
+ assert_eq!(after.len(), before.len() + 1);
+ assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
+ let d = &h.state().app.session.doc().unwrap().doc;
+ assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
+ let id = h.state().app.session.doc().unwrap().selection.items[0];
+ assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
+ }
+
+ /// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`.
+ fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) {
+ use egui::{Event, Modifiers, PointerButton};
// The thumbnail sits left of the Type row.
let ty = h.get_by_label("Type:").rect();
let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0);
- let to = h.get_by_label("[Black]").rect().center();
+ let to = h.get_by_label(label).rect().center();
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
@@ -1078,13 +1091,22 @@ mod gradient_panel_tests {
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(4);
- let after = named(&h.state().app);
- assert_eq!(after.len(), before.len() + 1);
- assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
+ }
+
+ /// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch.
+ #[test]
+ fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() {
+ let mut app = app_with_frame();
+ app.run("object.gradient", json!({})).unwrap();
+ let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string();
+ app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
+ // Tall enough to show the group's row below the swatches.
+ app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap();
+ let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
+ h.run_steps(6);
+ drag_gradient_to(&mut h, &group);
let d = &h.state().app.session.doc().unwrap().doc;
- assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
- let id = h.state().app.session.doc().unwrap().selection.items[0];
- assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
+ assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row");
}
}
From e6d78a71d05c015d39c87ce2b6cbeaeb4e184a09 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 16:09:27 +0500
Subject: [PATCH 060/135] A right-to-left paragraph's first-line indent is at
its right edge (#1448)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first-line indent was added to the left indent whatever the paragraph's
direction, so a right-to-left paragraph's first line moved the wrong way. With
the hanging indent a numbered list uses — right indent 10 mm, first line
−10 mm — the first line went 10 mm outside the frame on the left while the
wrapped lines sat where it should have been.
It is the paragraph's start-side indent, which is the right one in a
right-to-left paragraph; the drop-cap case beside it already made that
distinction.
Measured on a 200 pt column with that hanging indent:
first line right edge 200 (the frame's edge, as InDesign sets it)
wrapped right edge 171.65 (10 mm in)
every line left edge >= 0 (nothing outside the frame)
and the same paragraph left to right is unchanged, first line at 0 and wrapped
lines 10 mm in.
The test checks both directions; the right-to-left half fails without the
change. 359 composition tests pass unchanged.
---
crates/compose/src/lib.rs | 8 ++++++-
crates/compose/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 89ea89bd3..aaaee0802 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1674,10 +1674,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
}
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
+ let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
let (ind_l, ind_r) = match beside {
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
Some(w) => (pp.left_indent + w, pp.right_indent),
- None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
+ // The first-line indent is at the paragraph's start side, which is the right
+ // one in a right-to-left paragraph: applied on the left it moved the line the
+ // wrong way, out of the frame for the hanging indent a numbered list uses
+ // (#1448).
+ None if rtl => (pp.left_indent, pp.right_indent + first_line),
+ None => (pp.left_indent + first_line, pp.right_indent),
};
let lx0 = x0 + ind_l;
let lx1 = x1 - ind_r;
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 15c412ada..57a545b08 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -7310,3 +7310,45 @@ fn rule_above_offset_is_measured_from_the_baseline() {
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
+
+/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
+/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
+/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
+/// the first line should have been (#1448).
+#[test]
+fn an_rtl_first_line_indent_is_at_the_right_edge() {
+ const MM: f64 = 72.0 / 25.4;
+ // Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the
+ // placement that is being measured, so no Arabic font has to be present.
+ let text = LOREM;
+ let rect = Rect::new(0.0, 0.0, 200.0, 300.0);
+ let para = |dir, align, first, left, right| ParaAttrs {
+ direction: Some(dir),
+ align: Some(align),
+ first_line_indent: Some(first),
+ left_indent: Some(left),
+ right_indent: Some(right),
+ ..Default::default()
+ };
+ // The hanging indent of a numbered list, right to left.
+ let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ assert!(lines.len() >= 2, "the text wraps: {}", lines.len());
+ let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
+ let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min);
+ // The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it.
+ assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0]));
+ assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1]));
+ // And nothing leaves the frame on the left, which is where the first line used to go.
+ for (i, l) in lines.iter().enumerate() {
+ assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l));
+ }
+
+ // The same paragraph left to right is unchanged: the first line hangs on the left.
+ let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
+ assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
+}
From 6c251075080b4cdbf8c8d6aa6debaef66cf83d65 Mon Sep 17 00:00:00 2001
From: Davit Matchakhelidze
Date: Thu, 22 Oct 2026 21:19:02 +0400
Subject: [PATCH 061/135] ui: the Discord button widens to fit its label
instead of spilling out
The start screen's community card reserves the button's measured width, so
the line beside it wraps before the button. Georgian's label overflowed the
fixed 190 px.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
---
crates/ui-egui/src/about.rs | 18 +++++++++++++-----
crates/ui-egui/src/chrome.rs | 6 ++++--
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs
index 14745607a..75442b93c 100644
--- a/crates/ui-egui/src/about.rs
+++ b/crates/ui-egui/src/about.rs
@@ -77,16 +77,24 @@ pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32)
p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg);
}
-/// The big "Join our Discord" button. Returns true when clicked.
+/// The Discord button's size for `label`: `min`, widened when the label would not fit inside it.
+pub fn discord_button_size(ctx: &egui::Context, label: &str, min: egui::Vec2) -> egui::Vec2 {
+ let galley = crate::rtl::plain(ctx, label, semibold((min.y * 0.42).max(11.0)), Color32::WHITE);
+ egui::vec2(min.x.max(min.y * 0.5 + 8.0 + galley.size().x + min.y), min.y)
+}
+
+/// The big "Join our Discord" button. Returns true when clicked. `size` is the minimum: a longer label
+/// widens the button (see `discord_button_size`) rather than spilling out of it.
pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool {
- let (r, resp) = ui.allocate_exact_size(size, Sense::click());
- let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
- let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
- ui.painter().rect_filled(r, size.y / 2.0, fill);
+ let size = discord_button_size(ui.ctx(), label, size);
let icon = size.y * 0.5;
let font = semibold((size.y * 0.42).max(11.0));
let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE);
let total = icon + 8.0 + galley.size().x;
+ let (r, resp) = ui.allocate_exact_size(size, Sense::click());
+ let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
+ let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
+ ui.painter().rect_filled(r, size.y / 2.0, fill);
let x0 = r.center().x - total / 2.0;
paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE);
ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index e853e6793..be139a095 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1060,7 +1060,9 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
crate::about::paint_mark(ui, r, crate::about::BRAND);
ui.add_space(8.0);
- let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0);
+ let label = crate::i18n::tr(&app.ui.language, "Join our Discord");
+ let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON);
+ let text_width = (ui.available_width() - button.x - 16.0).max(120.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
@@ -1090,7 +1092,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) {
+ if crate::about::discord_button(ui, label, BUTTON) {
let _ = app.run("help.discord", json!({}));
}
});
From bbb95930a2670b30c4fd4463ad3a79356d540222 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 19:26:49 +0200
Subject: [PATCH 062/135] Skip control words and symbols used as RTF \uN
fallbacks
Found while testing crates/textimport/src/rtf.rs:197-243 (the \u handler at :235-242; control words never decrement `pending`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/rtf.rs | 21 ++++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs
index baf065c30..410455669 100644
--- a/crates/textimport/src/rtf.rs
+++ b/crates/textimport/src/rtf.rs
@@ -134,6 +134,11 @@ pub fn import(bytes: &[u8]) -> Result {
}
if !next.is_ascii_alphabetic() {
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
+ i += 2;
+ if pending > 0 {
+ pending -= 1;
+ continue;
+ }
let skip = stack.last().is_some_and(|s| s.skip);
if !skip {
match next {
@@ -149,7 +154,6 @@ pub fn import(bytes: &[u8]) -> Result {
_ => {}
}
}
- i += 2;
continue;
}
// A control word with an optional numeric parameter and one delimiting space.
@@ -172,6 +176,11 @@ pub fn import(bytes: &[u8]) -> Result {
k += 1;
}
i = k;
+ // A control word is one character of the `\uN` fallback (a following `\u` is not).
+ if pending > 0 && word != "u" {
+ pending -= 1;
+ continue;
+ }
let skip = stack.last().is_some_and(|s| s.skip);
if SKIP.contains(&word.as_str()) {
flush(&mut st, &mut buf, &fmt);
@@ -320,6 +329,16 @@ Caf\'e9 \u8364? price\tab tab\line next\par
assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab");
}
+ /// A control word or control symbol is one character of the `\uN` fallback, so it is skipped
+ /// like a plain character and does not eat the text after it.
+ #[test]
+ fn control_fallback_after_unicode_is_skipped() {
+ let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap();
+ assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y");
+ let i = import(br"{\rtf1 \u8220\{Hi}").unwrap();
+ assert_eq!(i.story.text, "\u{201C}Hi");
+ }
+
/// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
/// our own exporter and Word write). Each half was converted alone and dropped.
#[test]
From 2ddcf6d2a1207cccef8836454a65a4cdfec1e9b2 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 20:08:56 +0200
Subject: [PATCH 063/135] Decode UTF-16 plain text files by BOM on import
Found while testing crates/textimport/src/lib.rs:117
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/textimport/src/lib.rs | 12 +++++++++++-
crates/textimport/src/tagged.rs | 2 +-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs
index ba9cb92e3..56eac1a00 100644
--- a/crates/textimport/src/lib.rs
+++ b/crates/textimport/src/lib.rs
@@ -114,7 +114,7 @@ pub fn import(name: &str, bytes: &[u8]) -> Result {
if l.ends_with(".doc") {
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
}
- Ok(plain(&String::from_utf8_lossy(bytes)))
+ Ok(plain(&tagged::decode(bytes)))
}
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
@@ -136,4 +136,14 @@ mod tests {
assert!(is_text_file("Report.DOCX") && !is_text_file("a.png"));
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
}
+
+ #[test]
+ fn plain_text_utf16() {
+ let mut le = vec![0xFF, 0xFE];
+ le.extend("Hi there".encode_utf16().flat_map(u16::to_le_bytes));
+ assert_eq!(import("a.txt", &le).unwrap().story.text, "Hi there");
+ let mut be = vec![0xFE, 0xFF];
+ be.extend("Hi there".encode_utf16().flat_map(u16::to_be_bytes));
+ assert_eq!(import("a.txt", &be).unwrap().story.text, "Hi there");
+ }
}
diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs
index 9bc84b766..18704cbc4 100644
--- a/crates/textimport/src/tagged.rs
+++ b/crates/textimport/src/tagged.rs
@@ -72,7 +72,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
}
/// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark).
-fn decode(bytes: &[u8]) -> String {
+pub(crate) fn decode(bytes: &[u8]) -> String {
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
let le = bytes[0] == 0xFF;
let units: Vec = bytes[2..]
From 62c71d004a8e6188d9dfc64aaa0479457d3bbae9 Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 22 Oct 2026 21:03:00 +0200
Subject: [PATCH 064/135] Align towards/away from spine on parent-page folios
by page side (#1577)
---
crates/compose/src/lib.rs | 10 +++++++---
crates/compose/src/tests.rs | 13 +++++++++++++
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 89ea89bd3..959f5da37 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -679,10 +679,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
}
- (Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => {
+ (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
- let _ = pi;
- (Some(prefix), None, false)
+ // The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
+ let left = sp
+ .page_at_x(item.bounds().center().x)
+ .and_then(|pi| sp.pages.get(pi))
+ .is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
+ (Some(prefix), None, left)
}
_ => (None, None, false),
};
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 15c412ada..d210a4426 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -7310,3 +7310,16 @@ fn rule_above_offset_is_measured_from_the_baseline() {
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
+
+#[test]
+fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
+ // Folios on a facing-pages parent: towards/away from spine must differ between its left and right page.
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let w = d.parents[0].pages[0].width;
+ let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() };
+ let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap();
+ let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap();
+ assert!(frame_specs(&d, left_story)[0].left_page);
+ assert!(!frame_specs(&d, right_story)[0].left_page);
+}
From fd3d5087fc2021a59d12ac436f9e5d7db5736c5e Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 22 Oct 2026 21:02:24 +0200
Subject: [PATCH 065/135] Cursors: paint the tool cursors in the window on
Wayland, so they keep the system size (#1544)
---
crates/ui-egui/src/canvas.rs | 2 +-
crates/ui-egui/src/cursors.rs | 58 +++++++++++++++++++++++++++--------
2 files changed, 46 insertions(+), 14 deletions(-)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 318f82388..6ffe8166d 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -5317,7 +5317,7 @@ mod tests {
let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type"));
let ppp = h.ctx.pixels_per_point();
let images = &mut h.state_mut().cursors;
- match crate::cursors::MODE {
+ match crate::cursors::mode() {
crate::cursors::Mode::Os => {
assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen");
assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection");
diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs
index 566fe65f9..28f98a5e0 100644
--- a/crates/ui-egui/src/cursors.rs
+++ b/crates/ui-egui/src/cursors.rs
@@ -3,10 +3,10 @@
//! what a click does (add, delete, close, convert, continue), and a curved double arrow for rotating.
//! Cursors without a glyph stay system cursors.
//!
-//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so
-//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
-//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The
-//! web build keeps the system cursors.
+//! Linux (X11) and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself,
+//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
+//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own
+//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors.
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2};
@@ -27,13 +27,32 @@ pub enum Mode {
System,
}
-pub const MODE: Mode = if cfg!(target_arch = "wasm32") {
- Mode::System
-} else if cfg!(target_os = "macos") {
- Mode::Painted
-} else {
- Mode::Os
-};
+/// The mode for a platform: `wayland` is true on a Linux Wayland session.
+fn mode_for(wasm: bool, macos: bool, wayland: bool) -> Mode {
+ if wasm {
+ Mode::System
+ } else if macos || wayland {
+ Mode::Painted
+ } else {
+ Mode::Os
+ }
+}
+
+/// Whether the environment describes a Wayland session: a Wayland display, unless the session says X11.
+fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool {
+ wayland_display.is_some_and(|d| !d.is_empty()) && !session_type.is_some_and(|t| t.eq_ignore_ascii_case("x11"))
+}
+
+/// How cursor glyphs are shown on this system. On Wayland the compositor sizes a cursor bitmap by
+/// its own scale, so the OS glyph came out far too big; there the canvas paints it, as on macOS.
+pub fn mode() -> Mode {
+ static MODE: std::sync::OnceLock = std::sync::OnceLock::new();
+ *MODE.get_or_init(|| {
+ let wayland = cfg!(target_os = "linux")
+ && is_wayland(std::env::var("WAYLAND_DISPLAY").ok().as_deref(), std::env::var("XDG_SESSION_TYPE").ok().as_deref());
+ mode_for(cfg!(target_arch = "wasm32"), cfg!(target_os = "macos"), wayland)
+ })
+}
/// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts
/// through another part's ink.
@@ -289,9 +308,9 @@ pub fn system(c: Cursor) -> egui::CursorIcon {
}
/// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint
-/// the glyph per [`MODE`], and return the cursor icon to set.
+/// the glyph per [`mode`], and return the cursor icon to set.
pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon {
- match MODE {
+ match mode() {
Mode::Os => {
// The glyphs keep the system cursors' size whatever the interface scale.
let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01);
@@ -359,6 +378,19 @@ mod tests {
assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none());
}
+ #[test]
+ fn wayland_paints_the_glyphs_and_x11_keeps_os_cursors() {
+ assert_eq!(mode_for(false, false, true), Mode::Painted);
+ assert_eq!(mode_for(false, false, false), Mode::Os);
+ assert_eq!(mode_for(false, true, false), Mode::Painted);
+ assert_eq!(mode_for(true, false, true), Mode::System);
+ assert!(is_wayland(Some("wayland-0"), Some("wayland")));
+ assert!(is_wayland(Some("wayland-0"), None));
+ assert!(!is_wayland(Some("wayland-0"), Some("x11")));
+ assert!(!is_wayland(Some(""), Some("wayland")));
+ assert!(!is_wayland(None, Some("wayland")));
+ }
+
#[test]
fn the_selection_arrows_differ_in_fill() {
// A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct.
From ce486fda63d64445bd4ca413b97ebada9b19ec8e Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 22 Oct 2026 21:03:10 +0200
Subject: [PATCH 066/135] Fix slice panics in the PDF post-passes on malformed
page trees and metadata (#1591)
---
crates/pdf/src/forms.rs | 4 ++--
crates/pdf/src/pdfx.rs | 14 +++++++++++++-
crates/pdf/src/transitions.rs | 35 ++++++++++++++++++++++++++++++++++-
3 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs
index 39aa597ec..07f4cf04c 100644
--- a/crates/pdf/src/forms.rs
+++ b/crates/pdf/src/forms.rs
@@ -8,7 +8,7 @@ use std::fmt::Write as _;
use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
use crate::export::Sheet;
-use crate::transitions::{int_after, object, rfind};
+use crate::transitions::{bracketed, int_after, object, rfind};
/// A field placed on an output page: (page index, PDF rectangle, field).
pub(crate) type Placed = (usize, [f64; 4], FormField);
@@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
let pages_id = int_after(&cat, "/Pages")?;
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
let kids_str = &pages[pages.find("/Kids")? + 5..];
- let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
+ let kids_str = bracketed(kids_str)?;
let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec = nums.chunks(2).map(|c| c[0]).collect();
diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs
index 534886f1d..74c385e73 100644
--- a/crates/pdf/src/pdfx.rs
+++ b/crates/pdf/src/pdfx.rs
@@ -94,7 +94,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option>
let s0 = find(body, b"stream", 0)? + b"stream".len();
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
let s1 = rfind(body, b"endstream")?;
- let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
+ let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string();
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
if dict.contains("/Filter") {
return None;
@@ -668,6 +668,18 @@ mod tests {
out
}
+ /// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic.
+ #[test]
+ fn metadata_with_only_endstream_does_not_panic() {
+ let objects = ["<>", "<>", "<>endstream"];
+ let mut out = b"%PDF-1.6\n".to_vec();
+ for (i, o) in objects.iter().enumerate() {
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n");
+ assert!(super::make_pdfx4(&out, "t", None).is_none());
+ }
+
/// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed.
#[test]
fn default_rgb_makes_device_rgb_managed() {
diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs
index cbfbe27cc..d37581afd 100644
--- a/crates/pdf/src/transitions.rs
+++ b/crates/pdf/src/transitions.rs
@@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option {
rest[..end].parse().ok()
}
+/// The text between the first `[` and the first `]` after it. `None` when there is no such pair (a `]`
+/// before the `[` included).
+pub(crate) fn bracketed(s: &str) -> Option<&str> {
+ let open = s.find('[')?;
+ let close = s[open..].find(']')? + open;
+ s.get(open + 1..close)
+}
+
/// Byte range of object `id`'s body (between `obj` and `endobj`).
pub(crate) fn object(pdf: &[u8], id: usize) -> Option> {
// The latest copy (incremental updates re-state objects further on).
@@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option>
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
let kids_at = pages.find("/Kids")?;
let kids_str = &pages[kids_at + 5..];
- let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
+ let kids_str = bracketed(kids_str)?;
let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec = nums.chunks(2).map(|c| c[0]).collect();
let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid)));
@@ -199,6 +207,31 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option> {
Some(out)
}
+#[cfg(test)]
+mod bracket_tests {
+ use super::{add_page_entries, bracketed};
+
+ #[test]
+ fn bracketed_ignores_a_close_before_the_open() {
+ assert_eq!(bracketed(" [3 0 R 4 0 R] "), Some("3 0 R 4 0 R"));
+ assert_eq!(bracketed("] ["), None);
+ assert_eq!(bracketed("] [3 0 R]"), Some("3 0 R"));
+ assert_eq!(bracketed("[]"), Some(""));
+ }
+
+ /// `]` before the `[` of /Kids (a spoofed page tree) is refused, not a slice panic.
+ #[test]
+ fn kids_closed_before_opened_is_refused() {
+ let objects = ["<>", "<>"];
+ let mut out = b"%PDF-1.6\n".to_vec();
+ for (i, o) in objects.iter().enumerate() {
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n");
+ assert!(add_page_entries(&out, &[Some("/Rotate 0".to_string())]).is_none());
+ }
+}
+
#[cfg(test)]
mod object_index_tests {
use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts};
From d292dd2acd0cff545ab7144bee46c0464abad2fa Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 22 Oct 2026 21:04:39 +0200
Subject: [PATCH 067/135] IDML export: write the graphic placed in a table cell
(#1551)
---
crates/idml/src/export.rs | 32 +++++++++++++++++++++++++++++++-
crates/idml/src/tests.rs | 33 +++++++++++++++++++++++++++++++++
docs/target-app-parity.md | 2 +-
3 files changed, 65 insertions(+), 2 deletions(-)
diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs
index 99dcb383e..eb30d533c 100644
--- a/crates/idml/src/export.rs
+++ b/crates/idml/src/export.rs
@@ -2797,7 +2797,23 @@ impl<'a> Ex<'a> {
ce.set(&format!("{side}StrokePriority"), priority);
}
}
- for psr in self.story_paras(&cell.text) {
+ let mut paras = self.story_paras(&cell.text);
+ // A graphic cell holds its image as an inline object in the cell's first paragraph.
+ if let Some(g) = &cell.graphic
+ && let Some(object) = self.cell_graphic_el(g)
+ {
+ let mut range = self.csr_el(&CharFormat::default());
+ range.push(object);
+ match paras.first_mut() {
+ Some(first) => first.push(range),
+ None => {
+ let mut psr = self.psr_el(&ParaFormat::default());
+ psr.push(range);
+ paras.push(psr);
+ }
+ }
+ }
+ for psr in paras {
ce.push(psr);
}
el.push(ce);
@@ -2806,6 +2822,20 @@ impl<'a> Ex<'a> {
el
}
+ /// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
+ /// when the graphic's asset is missing.
+ fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option {
+ self.d.assets.get(&g.asset)?;
+ let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
+ let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
+ let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
+ item.content = Content::Graphic(g.clone());
+ let mut el = self.item_el(&item, Affine::IDENTITY);
+ el.set("Self", self.fresh());
+ el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
+ Some(el)
+ }
+
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();
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 37fb2af50..7fce003f4 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -4368,3 +4368,36 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
}
+
+/// A graphic placed in a table cell is written into the cell, not dropped.
+#[test]
+fn exports_graphic_in_table_cell() {
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap();
+ let asset = designcraft_doc::AssetId(d.alloc());
+ let id = d.add_asset(designcraft_doc::Asset {
+ id: asset,
+ name: "tick.png".into(),
+ mime: "image/png".into(),
+ data: std::sync::Arc::new(vec![1, 2, 3]),
+ ..Default::default()
+ });
+ let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
+ t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
+ asset: id,
+ size: (24.0, 24.0),
+ xf: designcraft_geom::Affine::IDENTITY,
+ clip: None,
+ auto_fit: Default::default(),
+ fit_align: 4,
+ crop: [0.0; 4],
+ wrap: Default::default(),
+ fill: Default::default(),
+ });
+ d.story_mut(sid).unwrap().insert_table(5, t);
+ let story = zip_text(&export_idml(&d), "Stories/");
+ let cell = story.split("").next().unwrap_or_default();
+ assert!(cell.contains("
Date: Thu, 22 Oct 2026 15:38:12 -0400
Subject: [PATCH 068/135] Release 0.12.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 42 +++++++++++++++++++++---------------------
Cargo.toml | 2 +-
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0b022ecfe..e75e8d5a5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 0e728e87f..bc5982898 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.0"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 2caa39e1304492b6176aa1d4c79e093a322df174 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Thu, 22 Oct 2026 22:46:08 +0200
Subject: [PATCH 069/135] Autoflow: fill the pages that follow before adding
pages at the end
Autoflow (Shift-click, autoflow on Place, text.thread with autoflow) inserted
new pages right after the story's last frame, in front of the document's own
pages. As in InDesign, it now threads one frame per column on each page that
follows, whatever else the page holds, and only then adds pages at the end of
the document. It fills whole pages, so an estimate that stops inside a page
can't leave its later columns out of the thread. Frames it set past the end
of the text are removed again; the pages they were on stay. Smart Text Reflow
of the primary text frame keeps adding pages after the story's last page.
Shrinks gap 26: flowing into the primary text frame of the following pages
is left.
---
ROADMAP.md | 3 +-
crates/engine/src/cmd/place_text.rs | 192 +++++++++++++++++++++++++---
crates/engine/src/cmd/thread.rs | 2 +-
docs/gaps.md | 15 +--
docs/target-app-parity.md | 6 +-
5 files changed, 190 insertions(+), 28 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index d3ef17e47..89a9bd324 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -42,7 +42,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
-- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
+- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads frames on the pages that follow, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again, and the pages they were on stay. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs
index c54b55084..1053fafc0 100644
--- a/crates/engine/src/cmd/place_text.rs
+++ b/crates/engine/src/cmd/place_text.rs
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
}
/// Thread new frames until the story fits: first into the columns after the last frame's column
-/// on its page (when it sits in one), then one frame per column on each page it adds. With
-/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
-/// live area with the page's columns instead. Empty frames it made at the end of the thread are
-/// removed again, except the first on a page it didn't add; a page it added that holds only such
-/// a frame goes with it. Returns pages added.
+/// on its page (when it sits in one), then one frame per column on each page that follows it,
+/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
+/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
+/// the last frame's page, each with one frame on the live area with the page's columns instead.
+/// Empty frames it made at the end of the thread are removed again; a page it added that holds
+/// only such a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result {
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
+ // The next page of the document autoflow may set frames on; it only moves forward.
+ let mut following = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { break };
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let rects = flow_rects(d, dir, vertical, page);
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
- for r in rest.iter().take(want) {
+ // Whole pages: an estimate that stops inside one would leave its later columns out
+ // of the thread for good. The cleanup below takes the frames the text doesn't reach.
+ for r in rest {
prev = add_flow_frame(d, si, *r, layer, prev)?;
made.push(prev);
}
continue;
}
}
+ following = following.max(page + 1);
+ if !primary && following < d.page_count() {
+ let mut left = want;
+ while left > 0 && following < d.page_count() {
+ let Some((si, _)) = d.page_loc(following) else { break };
+ for r in flow_rects(d, dir, vertical, following) {
+ prev = add_flow_frame(d, si, r, layer, prev)?;
+ made.push(prev);
+ left = left.saturating_sub(1);
+ }
+ following += 1;
+ }
+ continue;
+ }
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
+ // Past the following pages, `page` is the document's last: autoflow adds pages at its end.
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
- // The first frame on its page: the estimate added more pages than the text needs.
- // Drop the page too when autoflow added it and the frame is all it holds.
- let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
- let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
- if !alone {
- break;
+ // The first frame on its page: the estimate asked for more pages than the text needs.
+ // Drop the page too when autoflow added it and the frame is all it holds; a page the
+ // document had keeps whatever else is on it.
+ match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
+ Some(pg) => {
+ let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
+ if !alone {
+ break;
+ }
+ d.remove_item(f)?;
+ d.delete_pages(&[pg])?;
+ added = added.saturating_sub(1);
+ }
+ None => {
+ d.remove_item(f)?;
+ }
}
- d.remove_item(f)?;
- d.delete_pages(&[pg])?;
- added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
@@ -568,6 +595,141 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// A facing-pages document with two columns, its inside margin wider than the outside one, so
+ /// left and right pages have their columns in different places. A graphic frame sits on the
+ /// page `marker` (0-based). Returns the session and the marker's id.
+ fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
+ let mut s = Session::new();
+ let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
+ s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
+ .unwrap();
+ let d = &s.doc().unwrap().doc;
+ let (si, _) = d.page_loc(marker).unwrap();
+ let m = d.page(marker).unwrap().margin_rect();
+ let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
+ let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ // Selected, an empty frame would take the placed text.
+ s.execute("edit.deselectAll", &json!({})).unwrap();
+ (s, id)
+ }
+
+ /// Every frame of the story on the column of its page, the story running through the pages in
+ /// order, two frames a page.
+ fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
+ for (i, f) in frames.iter().enumerate() {
+ let page = d.page_of_item(*f).unwrap();
+ assert_eq!(page, i / 2, "frame {i}");
+ assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
+ }
+ }
+
+ #[test]
+ fn autoflow_fills_the_pages_that_follow_before_adding_any() {
+ // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
+ // pages the document already had. InDesign threads frames on those first.
+ let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
+ let (mut s, marker) = facing_document(6, 2);
+ let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ let sid = StoryId(r["story"].as_u64().unwrap());
+ let frames = d.story(sid).unwrap().frames.clone();
+ // Past page 3, which holds the marker: frames go on a page whatever else it holds.
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
+ assert_frames_in_columns(d, &frames);
+ // The text ends before the document does: no empty frame after it.
+ let cs = designcraft_compose::compose_story(d, sid, &Default::default());
+ assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
+ assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_adds_pages_at_the_end_of_the_document() {
+ let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
+ let (mut s, marker) = facing_document(3, 2);
+ let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert!(d.page_count() > 4, "{r}");
+ assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
+ // The document's own pages come first, the added ones after the last of them.
+ assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
+ let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
+ assert_frames_in_columns(d, &frames);
+ assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
+ // One undo step takes the frames, the pages and the story away.
+ s.execute("edit.undo", &json!({})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
+ assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_fills_every_column_of_a_following_page() {
+ // A first frame across the whole live area holds twice what a column does, so autoflow
+ // asks for half the frames it needs. Asked page by page, it left the later columns of a
+ // page out of the thread and went on to the next page.
+ let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
+ let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
+ assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
+ for (k, f) in frames[1..].iter().enumerate() {
+ let page = d.page_of_item(*f).unwrap();
+ assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
+ assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
+ // A small first frame makes autoflow ask for more frames than the columns need; the ones
+ // it set on the following pages past the end of the text go again, the pages stay.
+ let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert_eq!(d.page_count(), 8);
+ let sid = StoryId(r["story"].as_u64().unwrap());
+ let frames = &d.story(sid).unwrap().frames;
+ assert!(frames.len() >= 2, "{r}");
+ let cs = designcraft_compose::compose_story(d, sid, &Default::default());
+ assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
+ assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs
index d5056d463..51bab42db 100644
--- a/crates/engine/src/cmd/thread.rs
+++ b/crates/engine/src/cmd/thread.rs
@@ -18,7 +18,7 @@ pub fn specs() -> Vec {
"Thread Text Frames",
[],
None,
- "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
+ "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
diff --git a/docs/gaps.md b/docs/gaps.md
index 5738d14c4..d3587a2aa 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -206,13 +206,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
parallel story composition.
- **Estimate:** 40–80 h.
-### 26. Autoflow fills the following pages before it adds pages (F)
-- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the
- story's last frame, one margin-sized frame each. InDesign first threads a frame per column on
- the existing pages that follow, then adds pages at the end of the document.
-- **Impact:** autoflowing into a document that already has pages inserts new pages in the
- middle of it instead of using the empty pages.
-- **Estimate:** 4–8 h.
+### 26. Autoflow flows into the primary text frame of the pages that follow (F)
+- **Missing:** autoflow threads a frame per column on the pages that follow, then adds pages at
+ the end of the document, as InDesign does. On a page whose parent has a primary text frame it
+ still sets a new frame instead of flowing into the primary one.
+- **Estimate:** 2–4 h.
## Lower
@@ -249,6 +247,7 @@ Cue. These are excluded from every percentage.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document |
| 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) |
| 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) |
| 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index bb7c5eb80..24c94283d 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -211,7 +211,7 @@ table is the one summed.
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
-| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
+| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
@@ -299,7 +299,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
-- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column and adds pages until the text fits; Remove Styles option (`designcraft-textimport`).
+- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column, on the pages that follow first, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
From 079b5bea820291d6f219fb5db2cf3f967481ff69 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:31:37 +0000
Subject: [PATCH 070/135] CI: dev profile keeps line tables only
---
Cargo.toml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/Cargo.toml b/Cargo.toml
index bc5982898..8a83e0463 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
+# Line tables keep file and line in panics and backtraces; full debug info
+# for every test binary and dependency would multiply the target dir size.
+# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
opt-level = 3
From 40e86d48a521d15d80f370705e13f9362c8bfb00 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:35:30 +0000
Subject: [PATCH 071/135] CI: xtask runs tests with nextest, doctests apart,
wasm in one check
---
xtask/src/main.rs | 71 ++++++++++++++++++++++++++++++++---------------
1 file changed, 49 insertions(+), 22 deletions(-)
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index e730feb51..5000860cc 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -12,7 +12,7 @@ mod stats;
mod version;
use std::path::{Path, PathBuf};
-use std::process::{Command, ExitCode};
+use std::process::{Command, ExitCode, Stdio};
const USAGE: &str = "\
usage: cargo xtask
@@ -21,8 +21,10 @@ commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
layers enforce the crate dependency layering (plan/architecture.md §3)
pdf-deps verify the pinned PDF backend, including Krilla dependencies
- wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
- ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
+ wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
+ test workspace tests (cargo nextest when installed, else cargo test), then doctests
+ ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm
+ (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@@ -39,6 +41,7 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
+ Some("test") => cmd_test().and_then(|()| cmd_doctest()),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -134,21 +137,51 @@ fn wasm_set() -> Result, String> {
.collect())
}
+/// One `cargo check` for the whole set: each crate is checked once, and
+/// `--keep-going` still reports every failing crate. Features unify across the
+/// set, so each crate is checked with the union of the features the set enables.
fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
- let mut results = Vec::new();
+ if set.is_empty() {
+ return Err("no wasm-safe crates found in the workspace metadata".into());
+ }
+ let mut c = cargo();
+ c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
- let mut c = cargo();
- c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
- let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
- results.push((pkg.clone(), ok));
+ c.args(["-p", pkg]);
}
- println!("\nwasm32-unknown-unknown check:");
- for (p, ok) in &results {
- println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
- }
- let failed = results.iter().filter(|r| !r.1).count();
- if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
+ run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))?;
+ println!("\nwasm32-unknown-unknown check: ok ({} crates: {})", set.len(), set.join(", "));
+ Ok(())
+}
+
+/// Test targets for the test build. Examples are left out so they aren't
+/// linked; clippy `--all-targets` still compiles them.
+const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"];
+
+fn nextest_available() -> bool {
+ cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
+}
+
+fn cmd_test() -> Result<(), String> {
+ let mut c = cargo();
+ let tool = if nextest_available() {
+ c.args(["nextest", "run"]);
+ "nextest run"
+ } else {
+ eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
+ c.arg("test");
+ "test"
+ };
+ c.args(TEST_TARGETS);
+ run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
+}
+
+/// nextest does not run doctests.
+fn cmd_doctest() -> Result<(), String> {
+ let mut c = cargo();
+ c.args(["test", "--workspace", "--doc"]);
+ run(c, "cargo test --workspace --doc")
}
fn cmd_ci() -> Result<(), String> {
@@ -171,14 +204,8 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
- (
- "test",
- Box::new(|| {
- let mut c = cargo();
- c.args(["test", "--workspace"]);
- run(c, "cargo test --workspace")
- }),
- ),
+ ("test", Box::new(cmd_test)),
+ ("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
From 15e4fdd1243377628a6278cc5e4ecfbe0d9ca81d Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Thu, 22 Oct 2026 17:57:36 -0600
Subject: [PATCH 072/135] #329: test that the Help menu works on the start
screen
The Help menu's links were greyed out when no document was open, because UI commands outside app. and window. needed a document. The test checks every help.* UI command is enabled with an empty session while another UI command (view.rulers) stays disabled. Without the help. exemption it fails on "help.discord must work from the start screen".
---
crates/ui-egui/src/menus.rs | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 87537abd7..41da5a69e 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -5642,4 +5642,17 @@ mod tests {
assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}");
assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}");
}
+
+ // Regression test for #329: on the start screen (no document) the Help menu's links were greyed out.
+ #[test]
+ fn help_links_are_enabled_without_a_document() {
+ let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ assert!(app.session.active().is_none());
+ let ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).filter(|id| id.starts_with("help.")).collect();
+ assert!(ids.contains(&"help.discord") && ids.contains(&"help.github"), "{ids:?}");
+ for id in ids {
+ assert!(menu_enabled(&app, id), "{id} must work from the start screen");
+ }
+ assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document");
+ }
}
From fecf5906c369ccf217fc88cbf01ec8303f40c75a Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Thu, 22 Oct 2026 17:57:36 -0600
Subject: [PATCH 073/135] #248: test that the start screen offers every print
preset including A6
The start screen showed only the first ten presets and had no A6 sheet. The test checks that the start screen paints a card for each print preset, that A6 exists at 105 x 148 mm, and that the Properties page-size list agrees. Without the fix it fails because the B5 card is cut off by take(10) and there is no A6.
---
crates/ui-egui/src/menus.rs | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 41da5a69e..91c294521 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -5655,4 +5655,19 @@ mod tests {
}
assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document");
}
+
+ // Regression test for #248: the start screen offered no A6 sheet (it listed only the first ten presets).
+ #[test]
+ fn start_screen_offers_every_print_preset_including_a6() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let texts = start_screen_texts(&mut app);
+ for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
+ assert!(texts.iter().any(|t| t == p.name), "start screen lacks the {} card: {texts:?}", p.name);
+ }
+ assert!(texts.iter().any(|t| t == "A6"), "{texts:?}");
+ let a6 = designcraft_doc::build::PRESETS.iter().find(|p| p.name == "A6").unwrap();
+ assert!((a6.width - 105.0 * 72.0 / 25.4).abs() < 0.01 && (a6.height - 148.0 * 72.0 / 25.4).abs() < 0.01, "{a6:?}");
+ let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6");
+ assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}");
+ }
}
From d9477695e2011372e91d1dceaa049da1edb064f1 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Thu, 22 Oct 2026 17:57:36 -0600
Subject: [PATCH 074/135] #249: test that menu rows are selectable across their
full width
A menu row only reacted over its text because the column used a plain top-down layout. The test renders a menu column with a short Preferences row and clicks near the right end of the row, expecting the dialog to open. Without the justified layout the click misses and the test fails.
---
crates/ui-egui/src/menus.rs | 38 +++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 91c294521..1ddc7a705 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -5670,4 +5670,42 @@ mod tests {
let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6");
assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}");
}
+
+ // Regression test for #249: a menu row only reacted over its text, not across the whole row.
+ #[test]
+ fn menu_rows_are_selectable_across_the_full_width() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0));
+ let entries = vec![cmd_item("app.preferences", Value::Null)];
+ let mut tick = 0;
+ let mut frame = |app: &mut DesignApp, events: Vec| {
+ tick += 1;
+ let mut row = egui::Rect::NOTHING;
+ let mut output =
+ ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
+ egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| {
+ let before = ui.cursor().min;
+ menu_column(app, ui, &[("Test", entries.clone())]);
+ row = egui::Rect::from_min_max(before, ui.min_rect().max);
+ });
+ });
+ output.textures_delta.clear();
+ row
+ };
+ frame(&mut app, vec![]);
+ let row = frame(&mut app, vec![]);
+ assert!(row.width() >= 239.0, "the column is at least 240 wide: {row:?}");
+ let far = egui::pos2(row.right() - 6.0, row.top() + 8.0);
+ for pressed in [true, false] {
+ frame(
+ &mut app,
+ vec![
+ egui::Event::PointerMoved(far),
+ egui::Event::PointerButton { pos: far, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
+ ],
+ );
+ }
+ assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it");
+ }
}
From 3ee11391aec7434d318654369a7b7b276882f668 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:39:10 +0000
Subject: [PATCH 075/135] CI: one integration-test binary per crate
---
ASSETS.md | 12 +++++-----
apps/designcraft-cli/tests/{ => it}/cli.rs | 0
apps/designcraft-cli/tests/it/main.rs | 1 +
crates/engine/tests/{ => it}/book_safety.rs | 2 --
crates/engine/tests/{ => it}/browser_print.rs | 1 -
.../character_style_from_selection.rs | 0
.../engine/tests/{ => it}/close_retention.rs | 0
.../{ => it}/collection_state_atomicity.rs | 2 --
.../tests/{ => it}/combined_close_glyph.rs | 0
.../engine/tests/{ => it}/distribute_locks.rs | 0
.../tests/{ => it}/export_page_input.rs | 0
.../tests/{ => it}/insert_pages_bounds.rs | 0
crates/engine/tests/it/main.rs | 23 +++++++++++++++++++
crates/engine/tests/{ => it}/param_keys.rs | 0
crates/engine/tests/{ => it}/pdf_backend.rs | 0
.../tests/{ => it}/preflight_resolution.rs | 0
.../tests/{ => it}/primary_frame_columns.rs | 0
.../tests/{ => it}/save_state_atomicity.rs | 2 --
.../tests/{ => it}/stroke_tint_range.rs | 0
.../engine/tests/{ => it}/text_decorations.rs | 0
.../tests/{ => it}/text_insert_memory.rs | 0
.../tests/{ => it}/text_thread_regressions.rs | 0
.../tests/{ => it}/thread_color_safety.rs | 0
.../epub/tests/{ => it}/export_tag_styles.rs | 0
.../tests/{ => it}/grouped_reading_order.rs | 0
crates/epub/tests/it/main.rs | 2 ++
crates/fonts/tests/it/main.rs | 4 ++++
crates/fonts/tests/{ => it}/system_fonts.rs | 2 --
crates/images/tests/it/main.rs | 1 +
.../{ => it}/rgba_conversion_contract.rs | 0
crates/mcp/tests/{ => it}/automation_roots.rs | 2 --
crates/mcp/tests/it/main.rs | 4 ++++
crates/pdf/tests/image_content_cache.rs | 5 ----
.../tests/{ => it}/gradient_color_spaces.rs | 2 +-
crates/pdf/tests/it/image_content_cache.rs | 3 +++
crates/pdf/tests/it/main.rs | 6 +++++
.../{ => it}/page_entry_search_contract.rs | 0
.../render/tests/{ => it}/blend_over_paper.rs | 0
crates/render/tests/{ => it}/colorize.rs | 0
crates/render/tests/{ => it}/effects.rs | 2 +-
.../render/tests/{ => it}/glyph_retention.rs | 0
crates/render/tests/{ => it}/gradient_inks.rs | 0
crates/render/tests/{ => it}/gradients.rs | 0
crates/render/tests/it/main.rs | 12 ++++++++++
crates/render/tests/{ => it}/pdf_geometry.rs | 0
crates/render/tests/{ => it}/perf.rs | 2 +-
crates/render/tests/{ => it}/placed_pdf.rs | 0
.../render/tests/{ => it}/stroke_alignment.rs | 0
.../tests/{ => it}/text_frame_bottom.rs | 0
.../tests/{ => it}/text_stroke_behind.rs | 0
crates/ui-egui/tests/it/main.rs | 2 ++
.../{ => it}/native_job_lifetime_control.rs | 1 -
52 files changed, 67 insertions(+), 26 deletions(-)
rename apps/designcraft-cli/tests/{ => it}/cli.rs (100%)
create mode 100644 apps/designcraft-cli/tests/it/main.rs
rename crates/engine/tests/{ => it}/book_safety.rs (99%)
rename crates/engine/tests/{ => it}/browser_print.rs (97%)
rename crates/engine/tests/{ => it}/character_style_from_selection.rs (100%)
rename crates/engine/tests/{ => it}/close_retention.rs (100%)
rename crates/engine/tests/{ => it}/collection_state_atomicity.rs (98%)
rename crates/engine/tests/{ => it}/combined_close_glyph.rs (100%)
rename crates/engine/tests/{ => it}/distribute_locks.rs (100%)
rename crates/engine/tests/{ => it}/export_page_input.rs (100%)
rename crates/engine/tests/{ => it}/insert_pages_bounds.rs (100%)
create mode 100644 crates/engine/tests/it/main.rs
rename crates/engine/tests/{ => it}/param_keys.rs (100%)
rename crates/engine/tests/{ => it}/pdf_backend.rs (100%)
rename crates/engine/tests/{ => it}/preflight_resolution.rs (100%)
rename crates/engine/tests/{ => it}/primary_frame_columns.rs (100%)
rename crates/engine/tests/{ => it}/save_state_atomicity.rs (98%)
rename crates/engine/tests/{ => it}/stroke_tint_range.rs (100%)
rename crates/engine/tests/{ => it}/text_decorations.rs (100%)
rename crates/engine/tests/{ => it}/text_insert_memory.rs (100%)
rename crates/engine/tests/{ => it}/text_thread_regressions.rs (100%)
rename crates/engine/tests/{ => it}/thread_color_safety.rs (100%)
rename crates/epub/tests/{ => it}/export_tag_styles.rs (100%)
rename crates/epub/tests/{ => it}/grouped_reading_order.rs (100%)
create mode 100644 crates/epub/tests/it/main.rs
create mode 100644 crates/fonts/tests/it/main.rs
rename crates/fonts/tests/{ => it}/system_fonts.rs (86%)
create mode 100644 crates/images/tests/it/main.rs
rename crates/images/tests/{ => it}/rgba_conversion_contract.rs (100%)
rename crates/mcp/tests/{ => it}/automation_roots.rs (99%)
create mode 100644 crates/mcp/tests/it/main.rs
delete mode 100644 crates/pdf/tests/image_content_cache.rs
rename crates/pdf/tests/{ => it}/gradient_color_spaces.rs (98%)
create mode 100644 crates/pdf/tests/it/image_content_cache.rs
create mode 100644 crates/pdf/tests/it/main.rs
rename crates/pdf/tests/{ => it}/page_entry_search_contract.rs (100%)
rename crates/render/tests/{ => it}/blend_over_paper.rs (100%)
rename crates/render/tests/{ => it}/colorize.rs (100%)
rename crates/render/tests/{ => it}/effects.rs (99%)
rename crates/render/tests/{ => it}/glyph_retention.rs (100%)
rename crates/render/tests/{ => it}/gradient_inks.rs (100%)
rename crates/render/tests/{ => it}/gradients.rs (100%)
create mode 100644 crates/render/tests/it/main.rs
rename crates/render/tests/{ => it}/pdf_geometry.rs (100%)
rename crates/render/tests/{ => it}/perf.rs (98%)
rename crates/render/tests/{ => it}/placed_pdf.rs (100%)
rename crates/render/tests/{ => it}/stroke_alignment.rs (100%)
rename crates/render/tests/{ => it}/text_frame_bottom.rs (100%)
rename crates/render/tests/{ => it}/text_stroke_behind.rs (100%)
create mode 100644 crates/ui-egui/tests/it/main.rs
rename crates/ui-egui/tests/{ => it}/native_job_lifetime_control.rs (98%)
diff --git a/ASSETS.md b/ASSETS.md
index 40e8a97b4..407a26c92 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
-| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/it/cli.rs
similarity index 100%
rename from apps/designcraft-cli/tests/cli.rs
rename to apps/designcraft-cli/tests/it/cli.rs
diff --git a/apps/designcraft-cli/tests/it/main.rs b/apps/designcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..26710c101
--- /dev/null
+++ b/apps/designcraft-cli/tests/it/main.rs
@@ -0,0 +1 @@
+mod cli;
diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/it/book_safety.rs
similarity index 99%
rename from crates/engine/tests/book_safety.rs
rename to crates/engine/tests/it/book_safety.rs
index 74c96815a..01c527be5 100644
--- a/crates/engine/tests/book_safety.rs
+++ b/crates/engine/tests/it/book_safety.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/it/browser_print.rs
similarity index 97%
rename from crates/engine/tests/browser_print.rs
rename to crates/engine/tests/it/browser_print.rs
index fda224b90..208a6cde8 100644
--- a/crates/engine/tests/browser_print.rs
+++ b/crates/engine/tests/it/browser_print.rs
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
-#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/it/character_style_from_selection.rs
similarity index 100%
rename from crates/engine/tests/character_style_from_selection.rs
rename to crates/engine/tests/it/character_style_from_selection.rs
diff --git a/crates/engine/tests/close_retention.rs b/crates/engine/tests/it/close_retention.rs
similarity index 100%
rename from crates/engine/tests/close_retention.rs
rename to crates/engine/tests/it/close_retention.rs
diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/it/collection_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/collection_state_atomicity.rs
rename to crates/engine/tests/it/collection_state_atomicity.rs
index b73eefa7e..cb6a0db94 100644
--- a/crates/engine/tests/collection_state_atomicity.rs
+++ b/crates/engine/tests/it/collection_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/combined_close_glyph.rs b/crates/engine/tests/it/combined_close_glyph.rs
similarity index 100%
rename from crates/engine/tests/combined_close_glyph.rs
rename to crates/engine/tests/it/combined_close_glyph.rs
diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/it/distribute_locks.rs
similarity index 100%
rename from crates/engine/tests/distribute_locks.rs
rename to crates/engine/tests/it/distribute_locks.rs
diff --git a/crates/engine/tests/export_page_input.rs b/crates/engine/tests/it/export_page_input.rs
similarity index 100%
rename from crates/engine/tests/export_page_input.rs
rename to crates/engine/tests/it/export_page_input.rs
diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs
similarity index 100%
rename from crates/engine/tests/insert_pages_bounds.rs
rename to crates/engine/tests/it/insert_pages_bounds.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 000000000..14555d3ee
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,23 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod book_safety;
+#[cfg(target_arch = "wasm32")]
+mod browser_print;
+mod character_style_from_selection;
+mod close_retention;
+#[cfg(not(target_arch = "wasm32"))]
+mod collection_state_atomicity;
+mod combined_close_glyph;
+mod distribute_locks;
+mod export_page_input;
+mod insert_pages_bounds;
+mod param_keys;
+mod pdf_backend;
+mod preflight_resolution;
+mod primary_frame_columns;
+#[cfg(not(target_arch = "wasm32"))]
+mod save_state_atomicity;
+mod stroke_tint_range;
+mod text_decorations;
+mod text_insert_memory;
+mod text_thread_regressions;
+mod thread_color_safety;
diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs
similarity index 100%
rename from crates/engine/tests/param_keys.rs
rename to crates/engine/tests/it/param_keys.rs
diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs
similarity index 100%
rename from crates/engine/tests/pdf_backend.rs
rename to crates/engine/tests/it/pdf_backend.rs
diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/it/preflight_resolution.rs
similarity index 100%
rename from crates/engine/tests/preflight_resolution.rs
rename to crates/engine/tests/it/preflight_resolution.rs
diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/it/primary_frame_columns.rs
similarity index 100%
rename from crates/engine/tests/primary_frame_columns.rs
rename to crates/engine/tests/it/primary_frame_columns.rs
diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/it/save_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/save_state_atomicity.rs
rename to crates/engine/tests/it/save_state_atomicity.rs
index ac0f30f58..2ced20ef0 100644
--- a/crates/engine/tests/save_state_atomicity.rs
+++ b/crates/engine/tests/it/save_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use std::path::Path;
use std::sync::Arc;
diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/it/stroke_tint_range.rs
similarity index 100%
rename from crates/engine/tests/stroke_tint_range.rs
rename to crates/engine/tests/it/stroke_tint_range.rs
diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/it/text_decorations.rs
similarity index 100%
rename from crates/engine/tests/text_decorations.rs
rename to crates/engine/tests/it/text_decorations.rs
diff --git a/crates/engine/tests/text_insert_memory.rs b/crates/engine/tests/it/text_insert_memory.rs
similarity index 100%
rename from crates/engine/tests/text_insert_memory.rs
rename to crates/engine/tests/it/text_insert_memory.rs
diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/it/text_thread_regressions.rs
similarity index 100%
rename from crates/engine/tests/text_thread_regressions.rs
rename to crates/engine/tests/it/text_thread_regressions.rs
diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/it/thread_color_safety.rs
similarity index 100%
rename from crates/engine/tests/thread_color_safety.rs
rename to crates/engine/tests/it/thread_color_safety.rs
diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs
similarity index 100%
rename from crates/epub/tests/export_tag_styles.rs
rename to crates/epub/tests/it/export_tag_styles.rs
diff --git a/crates/epub/tests/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs
similarity index 100%
rename from crates/epub/tests/grouped_reading_order.rs
rename to crates/epub/tests/it/grouped_reading_order.rs
diff --git a/crates/epub/tests/it/main.rs b/crates/epub/tests/it/main.rs
new file mode 100644
index 000000000..69af15eaa
--- /dev/null
+++ b/crates/epub/tests/it/main.rs
@@ -0,0 +1,2 @@
+mod export_tag_styles;
+mod grouped_reading_order;
diff --git a/crates/fonts/tests/it/main.rs b/crates/fonts/tests/it/main.rs
new file mode 100644
index 000000000..1737c00b9
--- /dev/null
+++ b/crates/fonts/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod system_fonts;
diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/it/system_fonts.rs
similarity index 86%
rename from crates/fonts/tests/system_fonts.rs
rename to crates/fonts/tests/it/system_fonts.rs
index 62310c34b..66db0f5b1 100644
--- a/crates/fonts/tests/system_fonts.rs
+++ b/crates/fonts/tests/it/system_fonts.rs
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
diff --git a/crates/images/tests/it/main.rs b/crates/images/tests/it/main.rs
new file mode 100644
index 000000000..d2f76fb71
--- /dev/null
+++ b/crates/images/tests/it/main.rs
@@ -0,0 +1 @@
+mod rgba_conversion_contract;
diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/it/rgba_conversion_contract.rs
similarity index 100%
rename from crates/images/tests/rgba_conversion_contract.rs
rename to crates/images/tests/it/rgba_conversion_contract.rs
diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs
similarity index 99%
rename from crates/mcp/tests/automation_roots.rs
rename to crates/mcp/tests/it/automation_roots.rs
index 9380b1987..871945920 100644
--- a/crates/mcp/tests/automation_roots.rs
+++ b/crates/mcp/tests/it/automation_roots.rs
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs
new file mode 100644
index 000000000..be744719e
--- /dev/null
+++ b/crates/mcp/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod automation_roots;
diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs
deleted file mode 100644
index dd3d413d0..000000000
--- a/crates/pdf/tests/image_content_cache.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-//! Run public contracts with the production content-cache hasher.
-#[path = "support/image_content_cache_contract.rs"]
-mod contracts;
-#[path = "support/image_fixtures.rs"]
-mod image_fixtures;
diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/it/gradient_color_spaces.rs
similarity index 98%
rename from crates/pdf/tests/gradient_color_spaces.rs
rename to crates/pdf/tests/it/gradient_color_spaces.rs
index 05806a120..d906413e1 100644
--- a/crates/pdf/tests/gradient_color_spaces.rs
+++ b/crates/pdf/tests/it/gradient_color_spaces.rs
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
- .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
+ .args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
diff --git a/crates/pdf/tests/it/image_content_cache.rs b/crates/pdf/tests/it/image_content_cache.rs
new file mode 100644
index 000000000..ec0f666c0
--- /dev/null
+++ b/crates/pdf/tests/it/image_content_cache.rs
@@ -0,0 +1,3 @@
+//! Run public contracts with the production content-cache hasher.
+#[path = "../support/image_content_cache_contract.rs"]
+mod contracts;
diff --git a/crates/pdf/tests/it/main.rs b/crates/pdf/tests/it/main.rs
new file mode 100644
index 000000000..e2e092e2f
--- /dev/null
+++ b/crates/pdf/tests/it/main.rs
@@ -0,0 +1,6 @@
+#[path = "../support/image_fixtures.rs"]
+mod image_fixtures;
+
+mod gradient_color_spaces;
+mod image_content_cache;
+mod page_entry_search_contract;
diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/it/page_entry_search_contract.rs
similarity index 100%
rename from crates/pdf/tests/page_entry_search_contract.rs
rename to crates/pdf/tests/it/page_entry_search_contract.rs
diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/it/blend_over_paper.rs
similarity index 100%
rename from crates/render/tests/blend_over_paper.rs
rename to crates/render/tests/it/blend_over_paper.rs
diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/it/colorize.rs
similarity index 100%
rename from crates/render/tests/colorize.rs
rename to crates/render/tests/it/colorize.rs
diff --git a/crates/render/tests/effects.rs b/crates/render/tests/it/effects.rs
similarity index 99%
rename from crates/render/tests/effects.rs
rename to crates/render/tests/it/effects.rs
index a9420fbcb..230913173 100644
--- a/crates/render/tests/effects.rs
+++ b/crates/render/tests/it/effects.rs
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
-//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
+//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
diff --git a/crates/render/tests/glyph_retention.rs b/crates/render/tests/it/glyph_retention.rs
similarity index 100%
rename from crates/render/tests/glyph_retention.rs
rename to crates/render/tests/it/glyph_retention.rs
diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/it/gradient_inks.rs
similarity index 100%
rename from crates/render/tests/gradient_inks.rs
rename to crates/render/tests/it/gradient_inks.rs
diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/it/gradients.rs
similarity index 100%
rename from crates/render/tests/gradients.rs
rename to crates/render/tests/it/gradients.rs
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
new file mode 100644
index 000000000..52000ae2d
--- /dev/null
+++ b/crates/render/tests/it/main.rs
@@ -0,0 +1,12 @@
+mod blend_over_paper;
+mod colorize;
+mod effects;
+mod glyph_retention;
+mod gradient_inks;
+mod gradients;
+mod pdf_geometry;
+mod perf;
+mod placed_pdf;
+mod stroke_alignment;
+mod text_frame_bottom;
+mod text_stroke_behind;
diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/it/pdf_geometry.rs
similarity index 100%
rename from crates/render/tests/pdf_geometry.rs
rename to crates/render/tests/it/pdf_geometry.rs
diff --git a/crates/render/tests/perf.rs b/crates/render/tests/it/perf.rs
similarity index 98%
rename from crates/render/tests/perf.rs
rename to crates/render/tests/it/perf.rs
index 1f23f88f4..46f33ab62 100644
--- a/crates/render/tests/perf.rs
+++ b/crates/render/tests/it/perf.rs
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
-//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
+//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;
diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs
similarity index 100%
rename from crates/render/tests/placed_pdf.rs
rename to crates/render/tests/it/placed_pdf.rs
diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/it/stroke_alignment.rs
similarity index 100%
rename from crates/render/tests/stroke_alignment.rs
rename to crates/render/tests/it/stroke_alignment.rs
diff --git a/crates/render/tests/text_frame_bottom.rs b/crates/render/tests/it/text_frame_bottom.rs
similarity index 100%
rename from crates/render/tests/text_frame_bottom.rs
rename to crates/render/tests/it/text_frame_bottom.rs
diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/it/text_stroke_behind.rs
similarity index 100%
rename from crates/render/tests/text_stroke_behind.rs
rename to crates/render/tests/it/text_stroke_behind.rs
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 000000000..2d07c9629
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,2 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod native_job_lifetime_control;
diff --git a/crates/ui-egui/tests/native_job_lifetime_control.rs b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
similarity index 98%
rename from crates/ui-egui/tests/native_job_lifetime_control.rs
rename to crates/ui-egui/tests/it/native_job_lifetime_control.rs
index 2cf82e5bd..b7fa87214 100644
--- a/crates/ui-egui/tests/native_job_lifetime_control.rs
+++ b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
@@ -1,5 +1,4 @@
//! Own a real native Job across last close. This does not run the asynchronous worker.
-#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
From 0fd6e06ad6a7921c0d5df08b3d45434910827d52 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 23 Oct 2026 01:17:55 +0000
Subject: [PATCH 076/135] CI: install nextest for the test step
---
.github/workflows/ci.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72bac7ef7..c27fdfe10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,6 +57,10 @@ jobs:
--target wasm32-unknown-unknown
rustup default stable
+ - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
+ with:
+ tool: cargo-nextest@0.9.145
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
From 62148670bd6ee3522c116cc1b78e2d6be0f16765 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 23 Oct 2026 02:54:21 +0000
Subject: [PATCH 077/135] CI: apt packages through craft-common-actions' cached
install
---
.github/workflows/ci.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c27fdfe10..d5b820d24 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,10 +45,10 @@ jobs:
persist-credentials: false
- name: Install Linux build dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
+ uses: storytold/craft-common-actions/actions/install-apt-packages@main
+ with:
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
From e7d16aeaaae68e61dda386776f62c02f67fc41a4 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 11:40:25 +0200
Subject: [PATCH 078/135] docs: autoflow drops the pages it added for unused
frames; threading into grouped frames is no longer a gap
---
ROADMAP.md | 2 +-
docs/target-app-parity.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 89a9bd324..f46ddb4db 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,7 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
-- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again, and the pages they were on stay. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
+- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 24c94283d..9fc2fbfa4 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -211,7 +211,7 @@ table is the one summed.
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
-| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
+| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
From bd7fb76d4c6fdda501651df1674bc34dbffc6ec4 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 09:29:39 -0400
Subject: [PATCH 079/135] fix: annotate package output permissions (SonarCloud
rust:S2612)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New packages are created with the ordinary 0666 request; the process umask
decides the effective mode. Annotated with that reason so the analyzer's
world-writable warning does not change the intended sharing behaviour.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
crates/engine/src/cmd/package.rs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs
index f2d33ccfa..d0ed58b03 100644
--- a/crates/engine/src/cmd/package.rs
+++ b/crates/engine/src/cmd/package.rs
@@ -135,8 +135,9 @@ impl OutputRoot {
{
use std::os::unix::fs::PermissionsExt;
- // Match fs::write for a new output: 0666 restricted by the caller's umask.
- builder.permissions(std::fs::Permissions::from_mode(0o666));
+ // Like fs::write for a new output: request 0666 and let the process umask decide the
+ // effective mode (0644 with the usual 022, 0664 with 002).
+ builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
From 5344faf9f04f1c4b0094705bb25e74caabe7000c Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:08:12 +0200
Subject: [PATCH 080/135] IDML export: clip a table cell's graphic to the cell
(#1551)
---
crates/idml/src/export.rs | 2 ++
crates/idml/src/tests.rs | 1 +
2 files changed, 3 insertions(+)
diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs
index eb30d533c..df5a1a243 100644
--- a/crates/idml/src/export.rs
+++ b/crates/idml/src/export.rs
@@ -2802,6 +2802,8 @@ impl<'a> Ex<'a> {
if let Some(g) = &cell.graphic
&& let Some(object) = self.cell_graphic_el(g)
{
+ // The cell clips its graphic on screen and in PDF; IDML does so only when asked.
+ ce.set("ClipContentToCell", bool_s(true));
let mut range = self.csr_el(&CharFormat::default());
range.push(object);
match paras.first_mut() {
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 7fce003f4..1a9734a6b 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -4400,4 +4400,5 @@ fn exports_graphic_in_table_cell() {
let cell = story.split("").next().unwrap_or_default();
assert!(cell.contains("
Date: Fri, 23 Oct 2026 16:08:36 +0200
Subject: [PATCH 081/135] Lay out the parent-page side lookup so rustfmt passes
(#1577)
---
crates/compose/src/lib.rs | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 959f5da37..e95ebd4b6 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -682,10 +682,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
// The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
- let left = sp
- .page_at_x(item.bounds().center().x)
- .and_then(|pi| sp.pages.get(pi))
- .is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
+ let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi));
+ let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
(Some(prefix), None, left)
}
_ => (None, None, false),
From c764454ed3f7d6f0db6dac381504699d37854bc2 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 16:22:52 +0200
Subject: [PATCH 082/135] Align: a click on one of several selected objects
makes it the key object (fixes #1637)
---
crates/engine/src/cmd/edit.rs | 13 ++++++
crates/engine/src/cmd/object.rs | 16 +++++--
crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++
crates/tools/src/select.rs | 22 +++++++--
crates/ui-egui/src/canvas.rs | 3 ++
crates/ui-egui/src/panels/properties.rs | 9 +++-
6 files changed, 114 insertions(+), 10 deletions(-)
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index fb8848a90..2b89d24f4 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -262,12 +262,25 @@ pub fn specs() -> Vec {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
+ if st.selection.key == Some(id) {
+ st.selection.key = None;
+ }
} else {
st.selection.items.push(id);
}
st.bump_revision();
ok()
}),
+ cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
+ let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
+ let st = s.doc_mut()?;
+ if !st.selection.items.contains(&id) {
+ return Err(super::bad("selection.key", "the key object must be selected"));
+ }
+ st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
+ st.bump_revision();
+ ok()
+ }),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 4ccb5c823..f0606e379 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -686,7 +686,7 @@ pub fn specs() -> Vec {
"Align",
["Window", "Object & Layout", "Align"],
None,
- "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
+ "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
@@ -3074,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option)
fn align(s: &mut Session, p: &Value) -> Result {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
- let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
- let key = s.doc()?.selection.key;
+ // A key object counts only while it is one of the items aligned. With one, InDesign's Align
+ // To switches to Key Object, so it is also the default target.
+ let key = s.doc()?.selection.key.filter(|k| ids.contains(k));
+ let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
+ if key.is_some() {
+ "keyObject".into()
+ } else if ids.len() > 1 {
+ "selection".into()
+ } else {
+ "page".into()
+ }
+ });
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 42f57b3a1..ff5eb7193 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1651,3 +1651,64 @@ fn a_line_fits_when_its_baseline_does() {
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
}
+
+/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
+/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
+#[test]
+fn a_click_in_the_selection_makes_the_key_object() {
+ use designcraft_tools::{PointerEvent, PointerKind};
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.set_tool("selection");
+ let v = ViewInfo::at_zoom(1.0);
+ // On the part of the key object that the aligned object never covers, away from the handles.
+ let click = |s: &mut Session| {
+ s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
+ };
+ click(&mut s);
+ assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
+ // The key is the right-hand object, so aligning left edges to the selection would move it.
+ s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
+ let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
+ assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
+ assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
+ // The Align panel and the Control panel send no target: the key object is the default one.
+ s.execute("object.align", &json!({"edge": "top"})).unwrap();
+ let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
+ assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
+ assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
+ click(&mut s);
+ assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
+}
+
+/// Dragging one of several selected objects moves them all and makes no key object.
+#[test]
+fn dragging_the_selection_makes_no_key_object() {
+ use designcraft_tools::{PointerEvent, PointerKind};
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.set_tool("selection");
+ let v = ViewInfo::at_zoom(1.0);
+ s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+}
+
+/// Shift-clicking the key object out of the selection drops the key.
+#[test]
+fn deselecting_the_key_object_drops_the_key() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
+ s.execute("selection.toggle", &json!({"id": b})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+}
diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs
index 866003102..c437484c2 100644
--- a/crates/tools/src/select.rs
+++ b/crates/tools/src/select.rs
@@ -1,6 +1,7 @@
//! Selection tool (V) and Direct Selection tool (A).
//!
-//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
+//! Click selects the frontmost item (Shift toggles; a click on one of several selected items makes
+//! it the key object for Align, a second click drops it), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no
//! object), double-clicking a text frame switches to the Type tool and
@@ -223,6 +224,9 @@ pub struct SelectionTool {
guides: Vec,
/// Selected item to toggle if this Shift press never becomes a drag.
shift_release: Option,
+ /// One of several selected items to make (or stop being) the key object for Align if this
+ /// press never becomes a drag.
+ key_release: Option,
/// The loaded text cursor.
loaded: Option,
/// Frame whose live corners are being edited (diamonds shown).
@@ -231,7 +235,7 @@ pub struct SelectionTool {
impl SelectionTool {
pub fn new(direct: bool) -> Self {
- Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None }
+ Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None }
}
/// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the
@@ -735,6 +739,7 @@ impl Tool for SelectionTool {
}
PointerKind::Down => {
self.shift_release = None;
+ self.key_release = None;
// Type on a path: a bracket moves where the text starts or ends.
if !ev.mods.shift
&& let Some((id, end)) = self.bracket_at(cx, p)
@@ -825,6 +830,10 @@ impl Tool for SelectionTool {
}
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
+ } else if !self.direct && cx.selection.items.len() > 1 {
+ // InDesign: a click on one of several selected objects makes it the
+ // key object, on release so that a drag still moves them all.
+ self.key_release = Some(id.0);
}
let content = self.direct || on_selected_content(cx, p);
self.drag = Drag::Pending { start: p, hit: true, content };
@@ -852,6 +861,7 @@ impl Tool for SelectionTool {
return vec![];
}
self.shift_release = None;
+ self.key_release = None;
if hit && !cx.selection.items.is_empty() {
// Direct Selection on items without content moves (or duplicates) them whole.
let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content));
@@ -1124,6 +1134,7 @@ impl Tool for SelectionTool {
PointerKind::Up => {
self.guides.clear();
let release = self.shift_release.take();
+ let key = self.key_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. }
@@ -1135,9 +1146,10 @@ impl Tool for SelectionTool {
| Drag::Bracket { .. } => {
vec![Action::Commit]
}
- Drag::Pending { .. } => match release {
- Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
- None => vec![],
+ Drag::Pending { .. } => match (release, key) {
+ (Some(id), _) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
+ (None, Some(id)) => vec![Action::Exec("selection.key".into(), json!({"id": id}))],
+ (None, None) => vec![],
},
Drag::Marquee { start, cur } => {
let r = Rect::from_points(start, cur);
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 318f82388..a1739b811 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1251,6 +1251,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
if sel.items.len() == 1 && sel.text.is_none() {
// InDesign draws a 1 pt bounding box around the selection.
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
+ } else if sel.key == Some(*id) {
+ // The key object for Align: a thicker bounding box, as in InDesign.
+ painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(3.0, color), StrokeKind::Middle);
}
let b = xf.rect(a.transform_rect_bbox(it.bounds()));
union = Some(union.map_or(b, |u| u.union(b)));
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index fd0094f9b..a31cfe4ec 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -982,7 +982,9 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
- let to = app.ui.align_to.clone();
+ // A key object switches Align To to Key Object; choosing another target drops the key.
+ let key = app.session.active().and_then(|st| st.selection.key);
+ let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() };
let to_label = match to.as_str() {
"selection" => "Selection",
"keyObject" => "Key Object",
@@ -1001,7 +1003,10 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
("Align to Page", "page"),
("Align to Spread", "spread"),
] {
- if ui.selectable_label(app.ui.align_to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
+ if ui.selectable_label(to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
+ if let Some(k) = key.filter(|_| id != "keyObject") {
+ let _ = app.run("selection.key", json!({"id": k.0}));
+ }
app.ui.align_to = id.into();
ui.close();
}
From 8eab33eba118dbb7f20e9f9a1dbdba0aff12dcd7 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 16:54:21 +0200
Subject: [PATCH 083/135] Align: no key object once the selection is down to
one object (#1637)
---
crates/engine/src/cmd/edit.rs | 3 ++-
crates/engine/src/cmd/object.rs | 6 ++---
crates/engine/src/tests.rs | 30 +++++++++++++++++++++++++
crates/ui-egui/src/panels/properties.rs | 2 +-
4 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 2b89d24f4..2daa24d5a 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -262,7 +262,8 @@ pub fn specs() -> Vec {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
- if st.selection.key == Some(id) {
+ // A key object needs another object to align to it.
+ if st.selection.key == Some(id) || st.selection.items.len() < 2 {
st.selection.key = None;
}
} else {
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index f0606e379..65f120622 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -3074,9 +3074,9 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option)
fn align(s: &mut Session, p: &Value) -> Result {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
- // A key object counts only while it is one of the items aligned. With one, InDesign's Align
- // To switches to Key Object, so it is also the default target.
- let key = s.doc()?.selection.key.filter(|k| ids.contains(k));
+ // A key object counts only while it is one of several items aligned. With one, InDesign's
+ // Align To switches to Key Object, so it is also the default target.
+ let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
if key.is_some() {
"keyObject".into()
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ff5eb7193..43bc767c0 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1712,3 +1712,33 @@ fn deselecting_the_key_object_drops_the_key() {
s.execute("selection.toggle", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
}
+
+/// With the selection down to the key object alone, Align no longer has a key object: a single
+/// object aligns to the page, as before.
+#[test]
+fn a_lone_key_object_aligns_to_the_page() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ s.execute("selection.toggle", &json!({"id": a})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+ s.execute("object.align", &json!({"edge": "left"})).unwrap();
+ let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
+ assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
+}
+
+/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
+/// the page.
+#[test]
+fn aligning_the_key_object_alone_takes_the_page() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
+ let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
+ assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
+}
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index a31cfe4ec..0422936e3 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -983,7 +983,7 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// A key object switches Align To to Key Object; choosing another target drops the key.
- let key = app.session.active().and_then(|st| st.selection.key);
+ let key = app.session.active().and_then(|st| st.selection.key.filter(|_| st.selection.items.len() > 1));
let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() };
let to_label = match to.as_str() {
"selection" => "Selection",
From df1db1646b05f4e3f12ca88f3e0f1ed7c2df0164 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 11:16:49 -0400
Subject: [PATCH 084/135] web: let the browser zoom the page (SonarCloud
Web:S7926) (#1641)
The viewport no longer sets user-scalable=no, so people who need to zoom
the page can (WCAG 1.4.4). The canvas takes touch-action: none, so pinch
and pan gestures on it still go to the app's own zoom, as eframe's touch
handlers expect, instead of zooming the page.
---
apps/designcraft-web/index.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/designcraft-web/index.html b/apps/designcraft-web/index.html
index b05be3936..da7ccc5da 100644
--- a/apps/designcraft-web/index.html
+++ b/apps/designcraft-web/index.html
@@ -2,12 +2,12 @@
-
+
DesignCraft
From 2e36dcf909627c8f0e5662fe6b4825b1c1c24420 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 11:16:54 -0400
Subject: [PATCH 085/135] packaging: harden shell scripts (SonarCloud
S7688/S7679/S7682/S131) (#1640)
- Use bash's [[ ]] instead of [ ] for conditional tests (S7688).
- Copy positional parameters into named locals in render(), has() and
sha256() (S7679).
- End the env.sh helper functions with an explicit return, which keeps
the last command's status (S7682).
- Give the appimagetool checksum case a default branch for architectures
without a pinned download (S131).
No behaviour change; shellcheck -x is clean.
---
packaging/env.sh | 19 ++++++++++++-------
packaging/freebsd/package.sh | 14 +++++++-------
packaging/icons.sh | 5 ++++-
packaging/linux/flatpak-bundle.sh | 8 ++++----
packaging/linux/package.sh | 23 ++++++++++++++---------
packaging/macos/import-cert.sh | 6 +++---
packaging/macos/package.sh | 24 ++++++++++++------------
packaging/web/package.sh | 4 ++--
8 files changed, 58 insertions(+), 45 deletions(-)
diff --git a/packaging/env.sh b/packaging/env.sh
index 0eb58029b..55ff3943c 100644
--- a/packaging/env.sh
+++ b/packaging/env.sh
@@ -19,10 +19,11 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
+ return
}
VERSION="${DESIGNCRAFT_VERSION:-$(workspace_version)}"
-if [ -z "$VERSION" ]; then
+if [[ -z "$VERSION" ]]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
@@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
-if [ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]; then
+if [[ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]]; then
DESIGNCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export DESIGNCRAFT_BUILD_SHA
@@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
- if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ return
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
- if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
+ if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
+ return
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
# the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR.
copy_font_licences() {
local dest="$1" f
- [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
+ [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0
for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
- if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi
+ if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi
done
}
# Portable SHA-256 of a file (prints just the hash).
sha256() {
- if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
+ local file="$1"
+ if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
+ return
}
diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index 1854c284a..d11434be0 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -20,7 +20,7 @@ APP_ID=ai.storyteller.designcraft
SKIP_BUILD=0
DRY_RUN=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;;
@@ -36,7 +36,7 @@ case "$MACHINE" in
arm64 | aarch64) ARCH=aarch64 ;;
*) echo "unsupported architecture $MACHINE" >&2; exit 2 ;;
esac
-if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then
+if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then
echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2
exit 2
fi
@@ -46,7 +46,7 @@ echo "==> DesignCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
fi
WORK="$CARGO_TARGET_DIR/freebsd-package"
@@ -56,7 +56,7 @@ mkdir -p "$WORK"
BIN="$CARGO_TARGET_DIR/release"
OUT_DIR="$DIST"
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
# Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds.
BIN="$WORK/stub-bin"
OUT_DIR="$WORK"
@@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
copy_docs "$STAGE/share/doc/designcraft"
for f in NOTICE ATTRIBUTION.md; do
- if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi
+ if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi
done
for f in bin/designcraft bin/designcraft-cli "share/applications/$APP_ID.desktop" \
"share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \
share/doc/designcraft/LICENSE-MIT share/doc/designcraft/LICENSE-APACHE; do
- if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi
+ if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi
done
# ---- .tar.gz ------------------------------------------------------------------------------------
@@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $OUT_DIR/$BASENAME.tar.gz"
"$STAGE/bin/designcraft-cli" --version
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
echo "==> dry run: tarball contents"
tar -tzvf "$OUT_DIR/$BASENAME.tar.gz"
fi
diff --git a/packaging/icons.sh b/packaging/icons.sh
index 6e06fb9ad..7ac9e2954 100755
--- a/packaging/icons.sh
+++ b/packaging/icons.sh
@@ -21,7 +21,10 @@ MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
-render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; }
+[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
-[ -x "$WORK/stage/bin/designcraft" ] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; }
+[[ -x "$WORK/stage/bin/designcraft" ]] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak"
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
-if [ "$TEST" = 1 ]; then
+if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=designcraft-cli "$APP_ID" --version
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 12ad8e0f2..c4d843eca 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -22,7 +22,7 @@ APP_ID=ai.storyteller.designcraft
SKIP_BUILD=0
FORMATS="appimage deb rpm arch tar"
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
@@ -43,8 +43,8 @@ BASENAME="designcraft-$VERSION-linux-$ARCH"
echo "==> DesignCraft $VERSION for Linux $ARCH ($FORMATS)"
-if [ "$SKIP_BUILD" = 0 ]; then
- if [ -n "${CROSS_TARGET:-}" ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
+ if [[ -n "${CROSS_TARGET:-}" ]]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
@@ -76,7 +76,10 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
-has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
+has() {
+ local fmt="$1"
+ case " $FORMATS " in *" $fmt "*) return 0 ;; *) return 1 ;; esac
+}
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -134,6 +137,8 @@ if has appimage; then
case "$ARCH" in
x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;;
aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;;
+ # No pinned download (e.g. riscv64): set APPIMAGETOOL or put appimagetool on PATH.
+ *) APPIMAGETOOL_SHA256="" ;;
esac
APPDIR="$WORK/DesignCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
@@ -144,9 +149,9 @@ if has appimage; then
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
- if [ -z "$TOOL" ]; then
+ if [[ -z "$TOOL" ]]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
- if [ ! -x "$TOOL" ]; then
+ if [[ ! -x "$TOOL" ]]; then
download="$TOOL.download"
curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
@@ -174,16 +179,16 @@ if has appimage; then
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
- if [ -s "$OUT.zsync" ]; then
+ if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
else
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
fi
fi
-if [ -z "${CROSS_TARGET:-}" ]; then
+if [[ -z "${CROSS_TARGET:-}" ]]; then
"$STAGE/usr/bin/designcraft-cli" --version
-elif [ -n "${EMULATOR:-}" ]; then
+elif [[ -n "${EMULATOR:-}" ]]; then
"$EMULATOR" "$STAGE/usr/bin/designcraft-cli" --version
fi
echo "==> done"
diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh
index 9e549610f..8669d8675 100755
--- a/packaging/macos/import-cert.sh
+++ b/packaging/macos/import-cert.sh
@@ -12,7 +12,7 @@ umask 077
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
-if [ -z "${APPLE_CERTIFICATE:-}" ]; then
+if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
@@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
-if [ -z "$IDENTITY" ]; then
+if [[ -z "$IDENTITY" ]]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
-if [ -n "${GITHUB_ENV:-}" ]; then
+if [[ -n "${GITHUB_ENV:-}" ]]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index c818ae6db..21edfa574 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
@@ -44,9 +44,9 @@ DMG="$DIST/designcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/designcraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
-if [ "$IDENTITY" = "-" ]; then
+if [[ "$IDENTITY" = "-" ]]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
-elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
+elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
@@ -55,7 +55,7 @@ fi
echo "==> DesignCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli "${args[@]}")
@@ -74,9 +74,9 @@ done
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
- [ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
+ [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none)
local kc=()
- [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
+ [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
@@ -88,8 +88,8 @@ notarize() {
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
- if [ "$status" != "Accepted" ]; then
- if [ -n "$id" ]; then
+ if [[ "$status" != "Accepted" ]]; then
+ if [[ -n "$id" ]]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
@@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
ditto -c -k --keepParent "$APP" "$WORK/DesignCraft-notarize.zip"
notarize "$WORK/DesignCraft-notarize.zip"
xcrun stapler staple "$APP"
@@ -151,7 +151,7 @@ hdiutil_retry() {
if hdiutil "$@"; then
return 0
fi
- [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
+ [[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
@@ -169,7 +169,7 @@ if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; the
exit 1
fi
hdiutil_retry detach detach "$MOUNT" >/dev/null
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
@@ -187,7 +187,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/designcraft-cli"
rm -f "$CLI_ZIP"
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
-if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
+if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/designcraft-cli" --version
echo "==> done"
diff --git a/packaging/web/package.sh b/packaging/web/package.sh
index 4aafc3e80..62c220f0c 100755
--- a/packaging/web/package.sh
+++ b/packaging/web/package.sh
@@ -11,13 +11,13 @@ set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
-if [ "${1:-}" != "--skip-build" ]; then
+if [[ "${1:-}" != "--skip-build" ]]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/designcraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
-[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
+[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
From 7b91a1eefcf2eab790accff399bdc841c5b5d530 Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:32:02 +0200
Subject: [PATCH 086/135] idml: explicit zero cell edge weights hide the
default table border (#1623)
---
crates/idml/src/import.rs | 9 ++++++++-
crates/idml/src/tests.rs | 29 +++++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index ef504065e..dda5ac27e 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -2170,9 +2170,12 @@ impl<'r> Importer<'r> {
}
// Table-local options override the named table style. Cell styles are already
// materialized independently, so their edges retain their higher precedence.
+ let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty());
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
- self.cell_stroke_attrs(e, side).apply_to(border);
+ let attrs = self.cell_stroke_attrs(e, side);
+ border_unset &= attrs.is_empty();
+ attrs.apply_to(border);
}
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
@@ -2243,6 +2246,10 @@ impl<'r> Importer<'r> {
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
+ // No table or table style border is given anywhere, so the built-in 1 pt default must not
+ // draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
+ let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
+ cell.border_overrides[i] |= border_unset && hidden;
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 37fb2af50..b0119274f 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -1100,6 +1100,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() {
assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]);
}
+#[test]
+fn explicitly_hidden_cell_edges_hide_the_default_table_border() {
+ // No table border or table style is given, and every cell edge is explicitly hidden (issue #1623):
+ // the first cell by weight 0, the second by the None swatch.
+ let cell = |name: &str, attr: &str, value: &str| {
+ format!(
+ r#" | |