From 2a3d1dc1833d95655024e64fbb1c151ecab940c0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:48:27 +0200 Subject: [PATCH 01/15] Styles: name the attributes in the params docs, and a wrong one in the command's error (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - describe style.paragraph.create (and .edit, style.character.create, .edit) gave chars?: {…} and para?: {…}. The descriptions now say they take type.char's and type.para's attributes and name the common ones. - A wrong attribute gave "invalid parameters for `style`: unknown attribute `font`". The error now names the command and the group, and the attribute meant (nearest name, or one the key starts or ends) or else lists them all: "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?". The suggestion comes from set_json, so type.char, type.para and find.change get it too. - chars or para that isn't an object is an error instead of ignored. Fixes #777 --- crates/doc/src/attrs.rs | 70 +++++++++++++++++++++++++++++- crates/engine/src/cmd/style.rs | 78 +++++++++++++++++++++++++++------- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..6dc9b8efd 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -62,7 +62,7 @@ macro_rules! attr_set { return Ok(()); } )* - Err(format!("unknown attribute `{key}`")) + Err(unknown_attribute(key, &[ $( stringify!($field), )* ])) } /// Fields where `self` differs from `base`'s resolved value (used for "clear overrides"). pub fn diff_from(&self, base: &$props) -> $attrs { @@ -104,6 +104,57 @@ fn attr_key(snake: &'static str) -> &'static str { snake } +/// `snake_case` → `camelCase`, as commands name attributes. +fn camel(snake: &str) -> String { + let mut out = String::with_capacity(snake.len()); + let mut up = false; + for c in snake.chars() { + if c == '_' { + up = true; + } else if up { + out.push(c.to_ascii_uppercase()); + up = false; + } else { + out.push(c); + } + } + out +} + +/// Edits (insert, delete, replace) that turn `a` into `b`, ignoring case and underscores. +fn edit_distance(a: &str, b: &str) -> usize { + let norm = |s: &str| s.chars().filter(|c| *c != '_').map(|c| c.to_ascii_lowercase()).collect::>(); + let (a, b) = (norm(a), norm(b)); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut row = vec![i + 1]; + for (j, cb) in b.iter().enumerate() { + let d = (prev[j] + usize::from(ca != cb)).min(prev[j + 1] + 1).min(row[j] + 1); + row.push(d); + } + prev = row; + } + prev.last().copied().unwrap_or(0) +} + +/// The error for an attribute `key` that isn't one of `fields` (snake_case): the nearest one +/// (a typo, or a name it starts or ends: `font` → `fontFamily`), or else all of them. +fn unknown_attribute(key: &str, fields: &[&str]) -> String { + let k = key.to_ascii_lowercase().replace('_', ""); + let flat = |f: &str| f.replace('_', ""); + let near = fields + .iter() + .map(|f| (edit_distance(key, f), *f)) + .filter(|(d, _)| *d <= (k.len() / 3).max(1)) + .min() + .map(|(_, f)| f) + .or_else(|| fields.iter().copied().find(|f| k.len() >= 3 && (flat(f).starts_with(&k) || flat(f).ends_with(&k)))); + match near { + Some(f) => format!("unknown attribute `{key}`; did you mean `{}`?", camel(f)), + None => format!("unknown attribute `{key}`; the attributes are: {}", fields.iter().map(|f| camel(f)).collect::>().join(", ")), + } +} + // ---------- value types ---------- #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -946,6 +997,23 @@ impl ParaProps { mod tests { use super::*; + /// #777: an unknown attribute names the nearest one, or lists them all. + #[test] + fn unknown_attributes_suggest_the_right_name() { + let mut c = CharAttrs::default(); + assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); + assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); + let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); + assert!( + all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), + "{all}" + ); + assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); + let mut p = ParaAttrs::default(); + assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); + assert_eq!(camel("first_line_indent"), "firstLineIndent"); + } + #[test] fn a_partial_rule_keeps_the_other_fields() { let current = ParaProps { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..3c854ad87 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "New Paragraph Style…", [], None, - "{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}", + "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", has_doc, create_para ), @@ -95,7 +95,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}", + "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -114,7 +114,7 @@ pub fn specs() -> Vec { "Character Style Options…", [], None, - "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use", + "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {character attribute (as type.char names them: fontFamily, fontStyle, size, …): value | null (no longer set)}} — renaming updates every use", has_doc, edit_char ), @@ -380,12 +380,18 @@ pub fn specs() -> Vec { ] } -fn attrs(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { +/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute +/// is that command's parameter error, naming the group. +fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { let mut a = T::default(); - if let Some(o) = v.and_then(Value::as_object) { - for (k, v) in o { - set(&mut a, k, v).map_err(|e| bad("style", e))?; + match p.get(group) { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => { + for (k, v) in o { + set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; + } } + Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), } Ok(a) } @@ -436,8 +442,8 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); - let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; - let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) && !cur.is_null() @@ -479,7 +485,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); @@ -491,7 +497,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); - let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; + let para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); @@ -631,7 +637,7 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) @@ -647,7 +653,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result { // Replay the explicit fields so null clears a captured value rather than looking absent. if let Some(overrides) = p.get("chars").and_then(Value::as_object) { for (key, value) in overrides { - captured.set_json(key, value).map_err(|e| bad("style", e))?; + captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; } } chars = captured; @@ -671,7 +677,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { if name == designcraft_doc::NO_CHAR_STYLE { return Err(bad("style.character.edit", "[None] can't be edited")); } - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p @@ -1199,6 +1205,48 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { } } +/// #777: the params descriptions name the attributes, and a wrong one is the command's error. +#[cfg(test)] +mod attribute_error_tests { + use super::*; + use crate::cmd::find_command; + + #[test] + fn a_wrong_attribute_names_the_command_and_the_right_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), + "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" + ); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), + "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" + ); + assert!( + err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.create`: chars:") + ); + assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); + s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); + assert!( + err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.paragraph.edit`: chars:") + ); + s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert!( + err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.edit`: chars:") + ); + for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + let doc = find_command(id).unwrap().params; + assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); + } + assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); + } +} + #[cfg(test)] mod color_tests { use super::*; From 370dca2ca24fce5545a87280cfa0b7ef2277b742 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:11:58 +0200 Subject: [PATCH 02/15] feat(render): gradient strokes, on screen, in PDF and in the Gradient panel A gradient swatch on a stroke drew nothing: strokes were painted with a single resolved colour. LYH Studio episode 6 gives the loop on the right page (9:13) and, at first by accident, the circle (1:19, "right now it is on the stroke") a gradient stroke. - doc: Stroke::gradient_angle / gradient_vector (as Fill's) and Stroke::as_fill(), the stroke's paint as a fill. - render and PDF: a gradient stroke is painted like a gradient fill, across the path's box (dash gaps and arrowheads keep their paints). - engine: object.gradient {target: stroke} edits the stroke's gradient (kind, stops, angle, the Gradient Swatch tool's from/to); scaling moves the stroke's vector with the shape. - IDML: GradientStrokeStart/Length/Angle round-trip. - Gradient panel: acts on the stroke when the toolbox's Stroke box is in front, as InDesign's follows the Fill/Stroke proxy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 20 ++++++++ crates/engine/src/cmd/object.rs | 11 +++-- crates/engine/src/cmd/style.rs | 71 ++++++++++++++++++++++++--- crates/idml/src/export.rs | 8 +++ crates/idml/src/import.rs | 13 +++++ crates/idml/src/tests.rs | 17 +++++++ crates/pdf/src/export.rs | 17 +++++-- crates/render/src/lib.rs | 17 +++++-- crates/ui-egui/src/i18n.rs | 10 ++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 30 +++++++---- docs/target-app-parity.md | 2 +- 17 files changed, 192 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ac9e48805 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Gradient swatches work on strokes: drawn across the path's box on screen and in PDF (they drew nothing), set with `object.gradient {target: stroke}` and in the Gradient panel when the Stroke box is in front; the stroke's gradient vector moves with the shape and round-trips through IDML. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..fe4ff27ef 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -224,6 +224,12 @@ pub struct Stroke { /// Overprint Gap (Attributes panel). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub gap_overprint: bool, + /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, } impl Default for Stroke { @@ -244,11 +250,25 @@ impl Default for Stroke { gap_tint: 1.0, overprint: false, gap_overprint: false, + gradient_angle: None, + gradient_vector: None, } } } impl Stroke { + /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: + /// a gradient swatch on a stroke runs across the object's box as on its fill. + pub fn as_fill(&self) -> Fill { + Fill { + swatch: self.swatch.clone(), + tint: self.tint, + gradient_angle: self.gradient_angle, + gradient_vector: self.gradient_vector, + overprint: self.overprint, + } + } + /// How far the painted stroke can reach beyond the path (arrowheads reach further). pub fn extent(&self) -> f64 { if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..50b4d577c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1103,12 +1103,15 @@ fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } -/// Transform an item's geometry in its own space: the path and the gradient vector with it. +/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and +/// stroke) with it. pub(crate) fn bake(it: &mut Item, inner: Affine) { it.path.transform(inner); - if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector { - let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] { + if let Some([x0, y0, x1, y1]) = *v { + let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); + *v = Some([a.x, a.y, b.x, b.y]); + } } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..8562ca74d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -922,13 +922,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let angle = p.get("angle").and_then(Value::as_f64); + let stroke = match str_param(p, "target") { + None | Some("fill") => false, + Some("stroke") => true, + Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), + }; let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); let ids = super::targets(s, p)?; s.edit(|d, _| { let mut out = Value::Null; for id in &ids { let Some(it) = d.item(*id) else { continue }; - let cur_name = it.fill.swatch.clone(); + let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); let mut g = cur.clone().unwrap_or_default(); if let Some(k) = kind { @@ -960,16 +965,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); let Some(it) = d.item_mut(*id) else { continue }; - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; + // The fill's or the stroke's paint: swatch, tint, angle, vector. + let (swatch, tint, g_angle, g_vector) = if stroke { + (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) + } else { + (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) + }; + *swatch = name.clone(); + *tint = 1.0; if let Some(a) = angle { - it.fill.gradient_angle = Some(a); - it.fill.gradient_vector = None; + *g_angle = Some(a); + *g_vector = None; } if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { let (a, b) = (inv * a, inv * b); if (b - a).hypot() > 1e-6 { - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + *g_vector = Some([a.x, a.y, b.x, b.y]); } } if out.is_null() { @@ -1751,3 +1762,49 @@ mod sample_color_tests { } } } + +#[cfg(test)] +mod gradient_stroke_tests { + use serde_json::json; + + /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. + fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) + } + + #[test] + fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let r = s + .execute( + "object.gradient", + &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); + let mut rr = designcraft_render::Renderer::new(); + let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); + // The PDF, placed back and drawn: the same. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); + // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. + s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a > 215 && b < 40, "dragged: {a} {b}"); + assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..cfdbae52d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1673,6 +1673,14 @@ impl<'a> Ex<'a> { } else if let Some(a) = it.fill.gradient_angle { el.set("GradientFillAngle", num(a)); } + // The same on the stroke. + if let Some([x0, y0, x1, y1]) = it.stroke.gradient_vector { + el.set("GradientStrokeStart", format!("{} {}", num(x0), num(y0))); + el.set("GradientStrokeLength", num((x1 - x0).hypot(y1 - y0))); + el.set("GradientStrokeAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees())); + } else if let Some(a) = it.stroke.gradient_angle { + el.set("GradientStrokeAngle", num(a)); + } self.stroke_attrs(&mut el, &it.stroke); // Corner options: map path-order corners to the named corners by geometry. if !it.corners.is_none() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..c0959d7c9 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2129,6 +2129,19 @@ impl<'r> Importer<'r> { it.stroke.swatch = swatch::NONE.into(); it.stroke.weight = 1.0; } + // A gradient on the stroke: its angle, or the Gradient Swatch tool's vector. + if let Some(a) = e.num("GradientStrokeAngle") + && a != 0.0 + { + it.stroke.gradient_angle = Some(a); + } + let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) + && len > 0.0 + { + let a = e.num("GradientStrokeAngle").unwrap_or(0.0).to_radians(); + it.stroke.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]); + } } // Corners. let cn = crate::export::corner_names(&it.path); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..8eacd1142 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1479,3 +1479,20 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); } } + +#[test] +fn a_strokes_gradient_vector_round_trips() { + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new( + id, + d.default_layer(), + designcraft_doc::Shape::Path, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(10.0, 10.0, 110.0, 60.0)), + ); + it.stroke.gradient_vector = Some([10.0, 35.0, 110.0, 35.0]); + d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap(); + let back = crate::import_idml(&crate::export_idml(&d)).unwrap(); + let v = back.spreads[0].items[0].stroke.gradient_vector.unwrap(); + assert!(v.iter().zip([10.0, 35.0, 110.0, 35.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{v:?}"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..64434732a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1062,7 +1062,14 @@ impl Exporter<'_> { fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) { let st = &it.stroke; - let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + // A gradient swatch runs across the path's box, as on a fill. + let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() { + let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return }; + p + } else { + let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + c.into() + }; let closed = it.path.is_closed(); let mut cap = match st.cap { designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt, @@ -1083,7 +1090,7 @@ impl Exporter<'_> { s.draw_path(path); s.set_stroke(None); } - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&op); s.set_fill(None); return; @@ -1135,7 +1142,7 @@ impl Exporter<'_> { s.draw_path(path); } s.set_stroke(Some(Stroke { - paint: c.clone().into(), + paint: paint.clone(), width: width as f32, miter_limit: st.miter_limit.max(1.0) as f32, line_cap: cap, @@ -1156,12 +1163,12 @@ impl Exporter<'_> { let Some(p) = to_path(&h.path) else { continue }; match h.outline { Some(w) => { - s.set_stroke(Some(Stroke { paint: c.clone().into(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); + s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); s.draw_path(&p); s.set_stroke(None); } None => { - s.set_fill(Some(krilla::paint::Fill { paint: c.clone().into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..c8b8d642a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -621,8 +621,17 @@ impl Renderer { } fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + self.draw_stroke_painted(ctx, f, it, bp, xf); + ctx.reset_paint_transform(); + } + + /// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's). + fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { let st = &it.stroke; - let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return }; + let (paint, bounds) = (st.as_fill(), bp.bounding_box()); + if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() { + return; + } let mut stroke = kurbo::Stroke::new(st.weight) .with_caps(match st.cap { designcraft_doc::Cap::Butt => kurbo::Cap::Butt, @@ -642,7 +651,9 @@ impl Renderer { _ => {} } ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&c, 1.0)); + if !set_fill_paint(ctx, f.doc, &paint, bounds) { + return; + } let closed = it.path.is_closed(); // Arrowheads: the path is shortened under them; they're drawn after it. let arrows = designcraft_doc::arrow::apply(bp, st, closed); @@ -669,7 +680,7 @@ impl Renderer { if op { ctx.pop_layer(); } - ctx.set_paint(color_of(&c, 1.0)); + set_fill_paint(ctx, f.doc, &paint, bounds); } // Stripes, wavy and hash strokes are fills (along the path's centre line). if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..dd81bca66 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "The stroke isn't a gradient.", + [ + "Die Kontur ist kein Verlauf.", + "Le contour n’est pas un dégradé.", + "El trazo no es un degradado.", + "線はグラデーションではありません。", + "描边不是渐变。", + ], + ), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..4b195386e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("The stroke isn't a gradient.", "الحد ليس تدرجًا."), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..d9ea6eb63 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("The stroke isn't a gradient.", "La traccia non è una sfumatura."), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..988b0ccd0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("The stroke isn't a gradient.", "線はグラデーションではありません。"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..f592575ef 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("The stroke isn't a gradient.", "O traçado não é um gradiente."), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..5d0d613bc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("The stroke isn't a gradient.", "Обведення не є градієнтом."), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7544347a9..06b035dc2 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -407,11 +407,21 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { )); return; }; - let fill = it.fill.clone(); + // The fill's gradient, or the stroke's when the toolbox's Stroke box is in front (as InDesign's + // Gradient panel follows the Fill/Stroke proxy). + let target = if app.ui.stroke_front { "stroke" } else { "fill" }; + let fill = if app.ui.stroke_front { it.stroke.as_fill() } else { it.fill.clone() }; let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "The fill isn't a gradient.")).color(t.text_dim)); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + if app.ui.stroke_front { "The stroke isn't a gradient." } else { "The fill isn't a gradient." }, + )) + .color(t.text_dim), + ); if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Gradient"))).clicked() { - let _ = app.run("object.gradient", json!({})); + let _ = app.run("object.gradient", json!({"target": target, "target": target})); } return; }; @@ -424,7 +434,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { .show_ui(ui, |ui| { for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { if ui.selectable_label(g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - let _ = app.run("object.gradient", json!({"kind": v})); + let _ = app.run("object.gradient", json!({"target": target, "kind": v})); } } }); @@ -435,7 +445,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }) .clicked() { - let _ = app.run("object.gradient", json!({"reverse": true})); + let _ = app.run("object.gradient", json!({"target": target, "reverse": true})); } }); let sel_id = egui::Id::new("grad_sel"); @@ -447,13 +457,13 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) { let mut stops = stops_json(&g); stops[sel]["location"] = json!(v.clamp(0.0, 100.0)); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if !radial { crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Angle:")); let angle = fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0); if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) { - let _ = app.run("object.gradient", json!({"angle": v})); + let _ = app.run("object.gradient", json!({"target": target, "angle": v})); } } }); @@ -503,7 +513,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } else { stops[i]["location"] = json!(o as f64 * 100.0); } - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if resp.clicked() && let Some(p) = resp.interact_pointer_pos() @@ -516,7 +526,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let (c, a) = g.sample(o); let mut stops = stops_json(&g); stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0})); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); sel = g.stops.iter().filter(|s| s.offset <= o).count(); } None => {} @@ -565,7 +575,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..b9f1e6e6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). The Gradient panel follows the Fill/Stroke proxy. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. From ac54c8faf7a6e85d6ca5572e8e2177f210d7bca8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:30:55 +0200 Subject: [PATCH 03/15] feat(text): gradient swatches on text, laid across the frame as in InDesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LYH Studio episode 6 (8:28) gives the "1969" headline the "New Gradient Swatch" (magenta → cyan) from the Swatches panel. InDesign runs a gradient on text across its text frame's box. DesignCraft resolved a character fill to one solid colour, so a gradient swatch drew no text at all. - render: a character fill or stroke that names a gradient swatch is painted with that gradient over the frame's box in text space (type on a path: the path's box). - pdf: text runs take a gradient paint: the shading is laid out in the frame's text space and mapped into each run's origin, so skew and horizontal scale don't bend it; outlined runs and type on a path too. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 55 +++++++++++++++++++++++++ crates/pdf/src/export.rs | 26 +++++++++--- crates/pdf/src/text.rs | 50 +++++++++++++++++------ crates/render/src/text.rs | 76 ++++++++++++++++++++++++++++------- docs/target-app-parity.md | 2 +- 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9689ce7f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Text takes gradient swatches: a gradient fill or stroke on characters spans the text frame's box (on a path: the path's), on screen and in PDF. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..3441b451c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2048,3 +2048,58 @@ mod multi_click_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod gradient_text_tests { + use serde_json::json; + + /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns + /// `x0..x1`. + fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 50.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + /// Red ink at the frame's left, blue at its right. + fn check(img: &designcraft_render::Rendered, what: &str) { + let (lr, lb) = ink(img, 0, 60); + let (rr, rb) = ink(img, 200, 300); + assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + + #[test] + fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); + let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); + s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); + // The PDF, placed back and drawn: the same gradient. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45eb331ff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -161,6 +161,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; if ex.rgb_only { @@ -338,6 +339,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; let pairs = booklet_pairs(n, opts.kind); @@ -468,6 +470,9 @@ pub(crate) struct Exporter<'a> { /// While drawing a story's frame for the structure tree: the story (text runs are tagged /// per paragraph, everything else in the frame is an artifact). pub(crate) tag_story: Option, + /// While drawing a frame's text: the frame's box in text space, which a gradient on the text + /// spans (as InDesign lays it across the frame). + pub(crate) text_area: Option, /// Marked content of each (story, paragraph index). pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>, } @@ -925,8 +930,10 @@ impl Exporter<'_> { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { let st = &cs.styles[si as usize]; - if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) { - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + // A gradient spans the path's box. + let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() }; + if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) { + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } @@ -937,7 +944,9 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } + self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); self.frame_text(s, &cs, ft, text); + self.text_area = None; if local != Affine::IDENTITY { s.pop(); } @@ -977,7 +986,13 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + self.fill_paint_in(fill, bounds, Affine::IDENTITY) + } + + /// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one + /// (text runs: the frame's text space under the run's origin). + pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; @@ -1028,7 +1043,7 @@ impl Exporter<'_> { cx, cy, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1048,7 +1063,7 @@ impl Exporter<'_> { y1: p0.y as f32, x2: p1.x as f32, y2: p1.y as f32, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1577,6 +1592,7 @@ mod image_content_cache_tests { tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..b7921f326 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -15,13 +15,31 @@ use designcraft_fonts::{FontDb, FontFace}; use designcraft_geom::kurbo::Shape as _; use designcraft_geom::{Affine, BezPath, Rect}; use krilla::geom::Point; -use krilla::paint::Stroke; +use krilla::num::NormalizedF32; +use krilla::paint::{Fill, FillRule, Paint, Stroke}; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag}; use krilla::text::{Font, GlyphId, KrillaGlyph}; use crate::export::{Exporter, solid_fill, tf, to_path}; +impl Exporter<'_> { + /// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space), + /// for glyphs drawn under `origin` (text space ← run space). + fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option { + if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 { + let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }; + return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse()); + } + self.swatch_color(swatch, tint).map(Into::into) + } +} + +/// An opaque nonzero fill with `paint`. +fn paint_fill(paint: Paint) -> Fill { + Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } +} + /// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks). fn is_marker(c: char) -> bool { ('\u{E000}'..='\u{E1FF}').contains(&c) @@ -289,17 +307,23 @@ impl Exporter<'_> { fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) { let g0 = &glyphs[0]; let st = &cs.styles[g0.style as usize]; - let fill = self.swatch_color(&st.fill, st.fill_tint); - let stroke = - if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None }; - if fill.is_none() && stroke.is_none() { - return; - } let face = g0.face; let size = g0.sy * face.upem; let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Outside a frame (no text area), a gradient spans the run. + let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv); + let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25); + let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area); + let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { + self.text_paint(&st.stroke, st.stroke_tint, origin, run_area) + } else { + None + }; + if fill.is_none() && stroke.is_none() { + return; + } // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); @@ -322,8 +346,8 @@ impl Exporter<'_> { }) .collect(); s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: st.stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); @@ -338,8 +362,8 @@ impl Exporter<'_> { glyphs: &[PlacedGlyph], origin: Affine, size: f64, - fill: Option, - stroke: Option, + fill: Option, + stroke: Option, stroke_weight: f64, ) { let db = FontDb::global(); @@ -354,8 +378,8 @@ impl Exporter<'_> { } let Some(p) = to_path(&bp) else { return }; s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() })); s.draw_path(&p); s.set_fill(None); s.set_stroke(None); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad729..f10c0d348 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -19,6 +19,45 @@ use vello_cpu::peniko; use crate::{Frame, Renderer, color_of, rect_overlaps}; +/// A text colour: solid, or a gradient swatch laid across the frame (InDesign spans a gradient on +/// text across its frame's box). +#[derive(Clone)] +enum TextPaint { + Solid(peniko::Color), + Gradient(designcraft_doc::Fill), +} + +impl TextPaint { + fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32) -> Option { + if designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch).is_some() { + return Some(TextPaint::Gradient(designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() })); + } + doc.resolve_color(swatch, tint).map(|c| TextPaint::Solid(color_of(&c, 1.0))) + } + + /// Make this the context's paint; a gradient runs across `area` (text space). + fn set(&self, ctx: &mut RenderContext, doc: &designcraft_doc::Document, area: Rect) { + match self { + TextPaint::Solid(c) => ctx.set_paint(*c), + TextPaint::Gradient(fill) => { + crate::set_fill_paint(ctx, doc, fill, area); + } + } + } +} + +/// The box a gradient on a frame's text spans: the frame's, in text space (the union of its lines +/// when the frame is gone). +fn text_area(doc: &designcraft_doc::Document, ft: &FrameText) -> Rect { + if let Some(it) = doc.item(ft.frame) { + let local = doc.text_local(it); + if local.determinant().abs() > 1e-12 { + return local.inverse().transform_rect_bbox(it.inner_bounds()); + } + } + ft.lines.iter().filter_map(line_box).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)) +} + /// Cached outlines of one composed line. pub(crate) struct LineGlyphs { /// Frame-space bounds of everything drawn for the line (empty lines: `None`). @@ -349,13 +388,16 @@ impl Renderer { ctx.set_transform(m); if !shown.is_empty() { let fg = self.glyphs.get(cs, ft); - // Resolve each style's paints once per frame. - let mut fills: Vec>> = vec![None; cs.styles.len()]; - let mut fill_of = |si: u32| -> Option { - *fills[si as usize].get_or_insert_with(|| { - let st = &cs.styles[si as usize]; - doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) - }) + // Resolve each style's paints once per frame; gradients span the frame. + let area = text_area(doc, ft); + let mut fills: Vec>> = vec![None; cs.styles.len()]; + let mut fill_of = |si: u32| -> Option { + fills[si as usize] + .get_or_insert_with(|| { + let st = &cs.styles[si as usize]; + TextPaint::of(doc, &st.fill, st.fill_tint) + }) + .clone() }; shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis))); // Paint all underlines before any glyphs: skewed runs and tightly spaced lines @@ -384,19 +426,21 @@ impl Renderer { None, ); } - ctx.set_paint(c); + c.set(ctx, doc, area); ctx.fill_path(bp); + ctx.reset_paint_transform(); if op { ctx.pop_layer(); } } let st = &cs.styles[*si as usize]; if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(bp); + ctx.reset_paint_transform(); } } } @@ -496,18 +540,22 @@ impl Renderer { ) { let doc = f.doc; ctx.set_transform(f.view * xf); + // A gradient on type on a path spans the path's box. + let area = path.bounding_box(); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); + if let Some(c) = TextPaint::of(doc, &st.fill, st.fill_tint) { + c.set(ctx, doc, area); ctx.fill_path(&bp); + ctx.reset_paint_transform(); } if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(&bp); + ctx.reset_paint_transform(); } self.stats.glyphs += 1; } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..eae72856a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. From 57c997634fe42cf2e1a6f39daae332642cfba9bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 19:33:08 +0000 Subject: [PATCH 04/15] Integration: #825 PDF table-cell and footnote text lays a gradient across its own lines, as on screen (cherry picked from commit 14e98419ad7afd052a51de9c61eb4dd6c05462eb) --- crates/engine/src/cmd/text.rs | 71 +++++++++++++++++++++++++++++++++++ crates/pdf/src/text.rs | 20 ++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 889ba3ed6..3bfacb353 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2612,4 +2612,75 @@ mod gradient_text_tests { let d = p.doc().unwrap().doc.clone(); check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); } + + /// The columns the red → blue ink spans (black table edges left out). + fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { + let cols: Vec = (0..img.width) + .filter(|&x| { + (0..img.height).any(|y| { + let p = img.pixel(x, y); + p[1] < 100 && p[0].max(p[2]) > 100 + }) + }) + .collect(); + (*cols.first().unwrap(), *cols.last().unwrap()) + } + + /// Mean red and blue of the red → blue ink in columns `x0..x1`. + fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 && p[0].max(p[2]) > 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 20.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + #[test] + fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + let g = s + .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) + .unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + // A third of the frame's width: the cell's text, red → blue. + let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); + let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); + let text = &mut t.cell_mut(0, 0).unwrap().text; + text.insert(0, "HHHH"); + text.format_chars(0..4, |f| { + f.over.fill = Some(g.clone()); + f.over.size = Some(24.0); + }); + let mut rr = designcraft_render::Renderer::new(); + let cache = designcraft_compose::Cache::new(); + let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (x0, x1) = ink_span(&screen); + assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); + let q = (x1 - x0) / 4; + for (what, img) in [("screen", &screen), ("pdf", &placed)] { + let (lr, lb) = coloured_ink(img, x0, x0 + q); + let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); + assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index b7921f326..a6695d0b7 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -35,6 +35,22 @@ impl Exporter<'_> { } } +/// The box a gradient on a cell's or a note's text spans: its lines, each padded by its ascent as +/// the screen renderer pads them, so both lay the gradient out alike. +fn lines_area(ft: &FrameText) -> Option { + ft.lines + .iter() + .filter_map(|l| { + let first = l.glyphs.first()?; + let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); + let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); + let pad = l.ascent.max(1.0); + let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0))); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad)) + }) + .reduce(|a, b| a.union(b)) +} + /// An opaque nonzero fill with `paint`. fn paint_fill(paint: Paint) -> Fill { Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } @@ -195,7 +211,9 @@ impl Exporter<'_> { for n in &ft.notes { if let Some(nft) = n.text.frames.first() { s.push_transform(&tf(Affine::translate(n.origin.to_vec2()))); + let area = std::mem::replace(&mut me.text_area, lines_area(nft)); me.frame_text(s, &n.text, nft, &n.source); + me.text_area = area; s.pop(); } } @@ -223,7 +241,9 @@ impl Exporter<'_> { } } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); + let area = std::mem::replace(&mut self.text_area, lines_area(cft)); self.frame_text(s, &c.text, cft, &c.source); + self.text_area = area; s.pop(); } } From bf22391581486e8d26f8b13782ab9a134c2f0ed5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 09:33:07 +0200 Subject: [PATCH 05/15] Gradient text test: make the cell's gradient swatch with object.gradient (swatch.create's gradient form is #763's) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3bfacb353..0c32a50aa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2645,12 +2645,11 @@ mod gradient_text_tests { fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - let g = s - .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) - .unwrap()["name"] - .as_str() - .unwrap() - .to_string(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let g = s.doc().unwrap().doc.item(designcraft_doc::ItemId(shape.as_u64().unwrap())).unwrap().fill.swatch.clone(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); From f69601b0ffd09da60cc90aabb2b713cf78bbc584 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:36:45 +0200 Subject: [PATCH 06/15] A linear gradient across a straight line runs along its length, on screen and in PDF alike Review fix from aiongg (72d7ea2e, with the line tests of 6db40bdb), ported to this branch: Fill::linear_axis gives screen and PDF one gradient axis, and a box with no extent along the gradient's angle takes its longer side. Co-authored-by: aiongg Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 15 ++++++++++++ crates/engine/src/cmd/style.rs | 45 ++++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 10 ++------ crates/render/src/lib.rs | 9 +++---- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1dc045e08..593edd170 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -32,6 +32,21 @@ impl Default for Fill { } impl Fill { + /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the + /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its + /// centre. A box with no extent along that angle (a straight line, across it) takes its longer + /// side. + pub fn linear_axis(&self, b: Rect) -> (Point, Point) { + if let Some([x0, y0, x1, y1]) = self.gradient_vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let a = self.gradient_angle.unwrap_or(0.0).to_radians(); + let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; + let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; + let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; + (b.center() - d, b.center() + d) + } + pub fn none() -> Self { Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 08cb736a9..fdc11e805 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1900,6 +1900,51 @@ mod gradient_stroke_tests { assert!(a > 215 && b < 40, "dragged: {a} {b}"); assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); } + + /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. + fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let mut g = g; + g["ids"] = json!([id]); + g["target"] = json!("stroke"); + s.execute("object.gradient", &g).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + (screen, placed) + } + + #[test] + fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { + // The line's box has no height: the gradient is a circle across the line's length. + let (screen, pdf) = line_with_stroke_gradient( + json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ); + for (what, img) in [("screen", &screen), ("pdf", &pdf)] { + let (a, m, b) = along(img); + assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); + } + } + + #[test] + fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { + let (screen, pdf) = + line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); + let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); + // The line's box has no height: the gradient runs across the line's length, so the stroke + // shows its middle. + assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); + assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); + } } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 64434732a..8da90186e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -978,7 +978,7 @@ impl Exporter<'_> { /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; let expanded = g.expanded_stops(); @@ -1036,13 +1036,7 @@ impl Exporter<'_> { .into() } _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3); - let d = Vec2::new(a.cos(), -a.sin()) * half; - let (p0, p1) = match v { - Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)), - None => (c - d, c + d), - }; + let (p0, p1) = fill.linear_axis(bounds); LinearGradient { x1: p0.x as f32, y1: p0.y as f32, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4085b270..07b08a812 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -889,7 +889,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { /// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown. fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { @@ -901,16 +901,13 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3); peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice()) } - (_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()), (designcraft_color::GradientKind::Radial, None) => { let r = bounds.width().max(bounds.height()) / 2.0; peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice()) } _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0; - let d = Vec2::new(a.cos(), -a.sin()) * half; - peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice()) + let (a, b) = fill.linear_axis(bounds); + peniko::Gradient::new_linear(a, b).with_stops(stops.as_slice()) } }; ctx.set_paint(grad); From 53374aab24a8f41606eda76dd12bdb893dda0aba Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 10:30:59 +0100 Subject: [PATCH 07/15] Type tool: dead keys and input-method composition on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a text frame never turned on the OS input method, so macOS dead keys didn't compose: ⌥E then I typed "ei" instead of "í" (same for ⌥N, ⌥U and Japanese/Chinese input). The canvas now asks for the input method while the Type tool has a caret and handles its events. The text being composed shows in the story as an interaction preview (new `text.compose` command), so it takes the format the next typed character gets, drawn on a yellow box. On commit it's dropped and the result typed with `text.insert` (one undo step); any other edit, tool change or drag drops it first and tells the input method to forget it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 100 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 8 +++ crates/engine/src/tooling.rs | 24 +++++++ crates/ui-egui/src/canvas.rs | 129 +++++++++++++++++++++++++++++----- 4 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..3ff557daa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -55,6 +55,18 @@ pub fn specs() -> Vec { ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), + // Neither journalled nor undoable: what is composed is a preview until it's typed. + CommandSpec { + id: "text.compose", + label: "Compose Text", + menu: &[], + shortcut: None, + params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it", + enabled: has_doc, + run: compose_text, + journal: false, + undoable: false, + }, cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete), cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret), cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| { @@ -492,6 +504,30 @@ fn release(s: &mut Session, p: &Value) -> Result { }) } +/// `text.compose`: show input-method text at the caret, typed in the format the next +/// character would get, without making it part of the story. Replaces what was composed before. +fn compose_text(s: &mut Session, p: &Value) -> Result { + let text = super::text_param(p, "text"); + s.end_composition(); + if text.is_empty() { + return ok(); + } + has_text(s).map_err(|e| bad("text.compose", &e))?; + // The preview types the text; the format chosen at the caret is still for the real typing. + let typing = s.typing_format.clone(); + s.begin_interaction("Type")?; + let r = s.preview("text.insert", json!({"text": text, "raw": true})); + s.typing_format = typing; + if let Err(e) = r { + s.cancel_interaction(); + return Err(e); + } + let uid = s.doc()?.uid; + s.composition = Some((uid, text.len())); + let pos = s.doc()?.selection.text.map_or(0, |t| t.focus); + Ok(json!({"range": [pos.saturating_sub(text.len()), pos]})) +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = super::text_param(p, "text"); // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). @@ -2558,3 +2594,67 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod compose_tests { + use serde_json::json; + + use crate::Session; + + /// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing. + fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.set_tool("type"); + s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + (s, sid) + } + + fn text(s: &Session, sid: u64) -> String { + s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone() + } + + /// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í": + /// one undo step, and the accent was never part of the document. + #[test] + fn composed_text_shows_in_the_typing_format_until_it_is_typed() { + let (mut s, sid) = session(); + let r = s.execute("text.compose", &json!({"text": "´"})).unwrap(); + assert_eq!(r["range"], json!([1, 3])); + assert_eq!(text(&s, sid), "T´a"); + let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret"); + assert_eq!(s.composition().map(|c| c.2), Some(1..3)); + s.execute("text.compose", &json!({"text": "¨"})).unwrap(); + assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one"); + s.execute("text.compose", &json!({"text": ""})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + assert!(s.composition().is_none()); + s.execute("text.insert", &json!({"text": "í"})).unwrap(); + assert_eq!(text(&s, sid), "Tía"); + assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + } + + /// Anything else that edits, a tool change or a drag drops the uncommitted accent. + #[test] + fn other_edits_drop_the_composed_text() { + for act in 0..3 { + let (mut s, sid) = session(); + s.execute("text.compose", &json!({"text": "´"})).unwrap(); + match act { + 0 => { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + } + 1 => s.set_tool("selection"), + _ => s.begin_interaction("Move").unwrap(), + } + assert_eq!(text(&s, sid), "Ta", "case {act}"); + assert!(s.composition().is_none()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 900bd8863..537f2f416 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -278,6 +278,9 @@ pub struct Session { pub(crate) text_drag: bool, /// Character formatting chosen at a bare caret, for the text typed there next. pub(crate) typing_format: Option, + /// Text an input method is composing (a dead key's accent), shown in the story as an + /// interaction preview until it commits: (document uid, byte length before the caret). + pub(crate) composition: Option<(u64, usize)>, /// The open Object Library (File › New / Open Library). pub library: Option, /// The open book (File › Open Book). @@ -310,6 +313,7 @@ impl Session { transforms: Default::default(), text_drag: false, typing_format: None, + composition: None, library: None, conveyor: Vec::new(), book: None, @@ -405,6 +409,10 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + // Text still being composed isn't part of the story: any other edit drops it first. + if id != "text.compose" && (spec.undoable || spec.journal) { + self.end_composition(); + } if let Err(e) = (spec.enabled)(self) { // A command that takes `ids` acts on the objects the call names, so it needs no // selection: with a text caret or nothing selected, `{"ids": [5]}` still runs. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..98868b7d6 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use designcraft_doc::{CellAddr, StoryId}; use designcraft_geom::Unit; pub use designcraft_tools::SnapView; use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; @@ -58,6 +59,7 @@ impl Session { if self.tool.id() == id { return; } + self.end_composition(); self.tool = designcraft_tools::create(id); // Leaving the Type tool keeps the text frame selected. if id != "type" @@ -143,7 +145,29 @@ impl Session { Ok(()) } + /// The text an input method is composing at the caret, as story bytes: it is in the + /// document as a preview until `text.compose` commits or drops it. + pub fn composition(&self) -> Option<(StoryId, Option, std::ops::Range)> { + let (uid, len) = self.composition?; + let st = self.active().filter(|st| st.uid == uid)?; + let t = st.selection.text?; + Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus)) + } + + /// Drop the text being composed: the document goes back to how it was before it. + pub fn end_composition(&mut self) { + let Some((uid, _)) = self.composition.take() else { return }; + if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid) + && let Some(it) = st.interaction.take() + { + st.doc = it.doc; + st.selection = it.selection; + st.revision += 1; + } + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + self.end_composition(); let st = self.doc_mut()?; st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None }); Ok(()) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b77..7a50c15bd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -991,7 +991,11 @@ fn draw_text_selection( cells: Option, ) { let cs = app.session.cache.get(doc, ts.story, None); - let range = ts.range(); + // Text an input method is composing at the caret is marked like it is in every Mac text + // field: a yellow box behind the glyphs. + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); @@ -1044,7 +1048,7 @@ fn draw_text_selection( let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); let poly = [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1080,7 +1084,7 @@ fn draw_text_selection( } } if !runs.is_empty() { - draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs); + draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some()); } if ts.is_caret() && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus) @@ -1088,22 +1092,38 @@ fn draw_text_selection( && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) { let m = a * doc.text_xf(it); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - // Type on a path: the caret stands across the path where it is along it. - let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); - let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); - let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// Where the text caret stood on screen last frame: the OS puts its input-method window there. +fn ime_caret_id() -> egui::Id { + egui::Id::new("canvas_ime_caret") +} + +/// The input method is composing text (sent to `text.compose`) that it hasn't committed. +fn ime_composing_id() -> egui::Id { + egui::Id::new("canvas_ime_composing") +} + +/// Remembers the caret at `p0`–`p1` for the input method's window. +fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) { + painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1))); +} + /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). #[allow(clippy::too_many_arguments)] fn draw_cell_selection( - _app: &DesignApp, + app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, @@ -1121,7 +1141,9 @@ fn draw_cell_selection( let Some(ft) = cs.frames.get(fi) else { return }; let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return }; let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2()); - let range = ts.range(); + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; if !range.is_empty() && let Some(cft) = text.frames.first() { @@ -1134,7 +1156,7 @@ fn draw_cell_selection( let past = e == l.range.end && range.end > l.range.end; for q in compose::highlight_quads(l, s, e, past) { let poly = q.map(|p| xf.to_screen(m * p)); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1145,20 +1167,25 @@ fn draw_cell_selection( } { let m = a * doc.text_xf(it); + let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - painter.line_segment( - [xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))], - Stroke::new(1.0, Color32::BLACK), - ); + painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// The box behind text an input method is composing. +const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153); + /// InDesign draws selected text inverted: a black highlight with the selected glyphs in the /// inverse of their colour (white for black text). The highlight and glyph outlines are /// rasterised with `vello_cpu` into a small screen-space texture over the selection. +/// `composing`: the text is an input method's, still being composed: a yellow box with the +/// glyphs in their own colour. +#[allow(clippy::too_many_arguments)] fn draw_inverse_highlight( app: &DesignApp, painter: &egui::Painter, @@ -1167,6 +1194,7 @@ fn draw_inverse_highlight( cs: &compose::ComposedStory, story: designcraft_doc::StoryId, runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)], + composing: bool, ) { use designcraft_render::vello_cpu::{self, kurbo, peniko}; let ctx = painter.ctx(); @@ -1193,7 +1221,7 @@ fn draw_inverse_highlight( let Some(st) = app.session.active() else { return }; let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::(), w, h)) - .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); + .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing)); let cache_id = egui::Id::new("inverse_sel_tex"); let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id)); let tex = match cached { @@ -1201,7 +1229,8 @@ fn draw_inverse_highlight( _ => { let view = xf.affine(ppp as f64, bbox.min); let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] }; + rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255)); for (_, m, quads, _) in runs { rc.set_transform(view * *m); for q in quads { @@ -1222,8 +1251,12 @@ fn draw_inverse_highlight( let Some(g) = l.glyphs.get(gi) else { continue }; let Some(style) = cs.styles.get(g.style as usize) else { continue }; let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue }; - let [r, gg, b, _] = c.to_rgba8(1.0); - rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + let [r, gg, b, a] = c.to_rgba8(1.0); + if composing { + rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a)); + } else { + rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + } let outline = db.outline(&g.face, g.gid); let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; @@ -1533,12 +1566,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } + // The engine drops composed text when something else edits or the caret moves; the input + // method must forget it too, or the next key would finish an accent that is gone. + let composing = ui.data(|d| d.get_temp::(ime_composing_id())).unwrap_or(false); + let interrupt = composing && app.session.composition().is_none(); + if interrupt || !wants_text { + ui.data_mut(|d| d.remove::(ime_composing_id())); + } + if wants_text { + // Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and + // Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e". + let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO)); + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt }); + }); + } let evs = ui.input(|i| i.events.clone()); for e in evs { match e { egui::Event::Text(t) if wants_text => { let _ = app.run("text.insert", json!({"text": t})); } + // What is composed shows in the story, in the text's own format, until it commits. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => { + let on = !text.is_empty(); + if app.run("text.compose", json!({"text": text})).is_ok() { + ui.data_mut(|d| d.insert_temp(ime_composing_id(), on)); + } + } + egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => { + ui.data_mut(|d| d.remove::(ime_composing_id())); + let _ = app.run("text.compose", json!({"text": ""})); + if !t.is_empty() { + let _ = app.run("text.insert", json!({"text": t})); + } + } egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -2088,6 +2150,37 @@ mod tests { assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); } + /// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which + /// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e". + #[test] + fn dead_keys_compose_through_the_input_method() { + let (mut h, id) = typing("T"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None })); + h.run_steps(2); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::(); + assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format"); + assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into()))); + h.run_steps(2); + assert_eq!(text(&h), "Tí"); + assert!(h.state().session.composition().is_none()); + // Moving the caret mid-composition drops the accent and tells the input method so. + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None })); + h.run_steps(2); + h.state_mut().run("text.move", json!({"dir": "left"})).unwrap(); + h.step(); + assert_eq!(text(&h), "Tí"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition)); + // Another tool takes no text: the input method is off. + h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.is_none()); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); From e9bda1f2d9b989fec8bc52e2b58bbfefef281b4c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:41 +0300 Subject: [PATCH 08/15] idml: cap IDML package part size on import instead of reading unbounded --- crates/idml/src/import.rs | 53 +++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1a4fa65e3..c15f83551 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -60,6 +60,28 @@ pub struct Imported { pub warnings: Vec, } +/// The most one package part may decompress to. The size an archive claims only bounds the +/// reservation, never the read: a small entry can inflate to gigabytes. +pub(crate) const MAX_PART_BYTES: u64 = 512 * 1024 * 1024; + +/// Entry `index` of `zip` as (name, decompressed bytes); `None` for a directory. An entry that +/// decompresses to more than `limit` bytes is an error rather than an unbounded read. +fn read_part(zip: &mut zip::ZipArchive>, index: usize, limit: u64) -> Result)>> { + let f = zip.by_index(index).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + if f.is_dir() { + return Ok(None); + } + let name = f.name().to_string(); + // The size is what the archive claims: don't reserve more than a sane part up front. + let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); + // Read one byte past the limit to tell "exactly at the limit" from "over it". + f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + if buf.len() as u64 > limit { + return Err(IdmlError::Part { part: name, msg: format!("larger than {} MiB uncompressed", limit / (1024 * 1024)) }); + } + Ok(Some((name, buf))) +} + /// Import with a custom reader for linked files, reporting the problems that did not stop it. pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; @@ -67,14 +89,7 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option = HashMap::new(); for i in 0..zip.len() { - let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { - continue; - } - let name = f.name().to_string(); - // The size is what the archive claims: don't reserve more than a sane part up front. - let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); - f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + let Some((name, buf)) = read_part(&mut zip, i, MAX_PART_BYTES)? else { continue }; lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); files.insert(name, buf); } @@ -3004,3 +3019,25 @@ mod frame_preference_scalar_tests { assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); } } + +#[cfg(test)] +mod part_size_tests { + use std::io::Write; + + use super::*; + + /// A small package part can inflate to gigabytes; the read is capped, so an oversized entry + /// is an error instead of an unbounded read. + #[test] + fn an_oversized_part_is_an_error_not_a_huge_read() { + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + w.start_file("Stories/Story_big.xml", deflated).unwrap(); + w.write_all(&vec![b' '; 64 * 1024]).unwrap(); + let bytes = w.finish().unwrap().into_inner(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + assert!(matches!(read_part(&mut zip, 0, 4096), Err(IdmlError::Part { .. }))); + // At or under the cap reads fine: the same entry with a 64 KiB limit. + assert_eq!(read_part(&mut zip, 0, 64 * 1024).unwrap().map(|(_, b)| b.len()), Some(64 * 1024)); + } +} From bb96f1455f20c52bfd4fe0ec0910a642d25a7cd1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 21:32:57 +0200 Subject: [PATCH 09/15] Fill with Placeholder Text in paragraphs, as InDesign does InDesign's placeholder text comes in paragraphs; Zero2Graphic's magazine tutorial styles its second paragraph as an 18 pt lead (2:16). DesignCraft filled the frame with one paragraph, and the last filler sentence ran into the first ("interit.Ovid"). Paragraphs now hold 2 to 5 sentences, and an overset fill is trimmed to the last whole sentence. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 45 +++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..aadbad502 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Type › Fill with Placeholder Text fills a frame with paragraphs of a few sentences, as InDesign does, so one paragraph can be styled on its own (it was one long paragraph, and the filler ran sentences together, "interit.Ovid"). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..f6ed4eca5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1398,20 +1398,30 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result { story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? } }; - // Fill until overset: repeat the filler sentences. + // Fill until overset with paragraphs of a few sentences each, as InDesign's placeholder text + // comes (so a paragraph can be styled on its own). + const SENTENCES_PER_PARAGRAPH: [usize; 6] = [3, 4, 2, 5, 3, 4]; let mut text = String::new(); - let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect(); + let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); let doc = s.doc()?.doc.clone(); let mut d2 = (*doc).clone(); + let (mut paragraph, mut in_paragraph) = (0, 0); for i in 0..400 { text.push_str(sentences[i % sentences.len()]); + in_paragraph += 1; + if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { + text.push('\n'); + (paragraph, in_paragraph) = (paragraph + 1, 0); + } else { + text.push(' '); + } if i % 3 == 2 { d2.set_story_text(sid, &text)?; let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); if cs.is_overset() { // Trim to the last sentence that fits. - let cut = cs.overset_at.unwrap_or(text.len()); - let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut); + let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); + let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); text.truncate(keep); break; } @@ -2559,3 +2569,30 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod placeholder_tests { + use serde_json::json; + + use crate::Session; + + /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one + /// paragraph, and the filler ran sentences together: "interit.Ovid"). + #[test] + fn placeholder_text_comes_in_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); + s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + let text = st["text"].as_str().unwrap(); + let paragraphs: Vec<&str> = text.split('\n').collect(); + assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); + for p in ¶graphs { + assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); + } + assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); + assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); + assert!(text.len() > 1500, "the frame is filled: {}", text.len()); + } +} From 017b925189565c8397ca294812011e9840836e0f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 22:31:32 +0200 Subject: [PATCH 10/15] Placeholder paragraphs as short as InDesign's (2 to 5 sentences) Measured in Zero2Graphic's video: InDesign's placeholder paragraphs run 5, 10 (at 18 pt), 6 and 8 lines in a 5.9 cm column, about 2, 2, 3 and 3 of DesignCraft's filler sentences. The rhythm started 3, 4, 2, 5. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f6ed4eca5..52c3588c5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1398,9 +1398,9 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result { story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? } }; - // Fill until overset with paragraphs of a few sentences each, as InDesign's placeholder text + // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text // comes (so a paragraph can be styled on its own). - const SENTENCES_PER_PARAGRAPH: [usize; 6] = [3, 4, 2, 5, 3, 4]; + const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; let mut text = String::new(); let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); let doc = s.doc()?.doc.clone(); From 8cfaa90dcb036698ea54fa6c719b8d79c66b8fa5 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:46:19 +0300 Subject: [PATCH 11/15] idml: read PDFAttribute PageNumber so placed PDFs show their page A placement of a multi-page PDF carried no page number through IDML: graphic() always made the asset with page 0 and keyed linked_assets by path alone, so every placement of one file showed page 1 (InDesign's PDFAttribute PageNumber was ignored). Import now reads PDFAttribute/@PageNumber (1-based) into Asset.page, linked_assets is keyed by (path, page), and export writes PDFAttribute back for pages past the first. Regression test: the same file placed at pages 2, 1 and 2 imports as two assets with pages 1 and 0 and round-trips through export. --- crates/idml/src/export.rs | 5 +++++ crates/idml/src/import.rs | 28 ++++++++++++++++------- crates/idml/src/tests/assets.rs | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..e99d0a4e8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1884,6 +1884,11 @@ impl<'a> Ex<'a> { .attr("ImportPolicy", "NoAutoImport") .attr("ExportPolicy", "NoAutoExport"), ); + if is_pdf && asset.page > 0 { + // PDF Import Options: the page is 1-based; page 1 (the default) needs no element, as + // InDesign omits it too. + el.push(El::new("PDFAttribute").attr("PageNumber", asset.page.saturating_add(1).to_string())); + } el } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..c72237964 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -194,8 +194,9 @@ struct Importer<'r> { page_index: HashMap, item_ids: HashMap, assets: BTreeMap>, - /// Assets read from a link path, so later placements of that path reuse them unread. - linked_assets: HashMap, + /// Assets read from a link path at a page, so later placements of that path and page reuse + /// them unread. + linked_assets: HashMap<(String, u32), AssetId>, ctx: ItemCtx, sections: Vec
, footnote_options: designcraft_doc::FootnoteOptions, @@ -2377,17 +2378,28 @@ impl<'r> Importer<'r> { }) }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); + // PDF Import Options: `PDFAttribute/@PageNumber` is 1-based; `Asset.page` is 0-based. A + // missing or malformed value means page 1, and an out-of-range page later fails to render + // rather than panicking. + let page = g + .find("PDFAttribute") + .or_else(|| g.prop_el("PDFAttribute")) + .and_then(|a| a.get("PageNumber")) + .and_then(|v| v.trim().parse::().ok()) + .and_then(|n| u32::try_from(n.saturating_sub(1)).ok()) + .unwrap_or(0); let mut read_from = None; if data.is_empty() && let Some(p) = &uri { - // Every placement of a linked file shares one asset; read the file once. - if let Some(id) = self.linked_assets.get(p) { + // Every placement of a linked file at one page shares one asset; read the file once. + let key = (p.clone(), page); + if let Some(id) = self.linked_assets.get(&key) { return graphic(*id); } if let Some(d) = (self.read_link)(p) { data = d; - read_from = Some(p.clone()); + read_from = Some(key); } } let (mime, px) = sniff_image(&data); @@ -2407,9 +2419,9 @@ impl<'r> Importer<'r> { let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); let id = - designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); - if let Some(p) = read_from { - self.linked_assets.insert(p, id); + designcraft_doc::insert_asset(&mut self.assets, Asset { page, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); + if let Some(key) = read_from { + self.linked_assets.insert(key, id); } graphic(id) } diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index dd2761442..d4d1501f2 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -31,6 +31,22 @@ fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String { ) } +/// A graphic frame showing `pdf` (a `` element) with the page `PageNumber` (1-based). +fn pdf_frame(n: usize, x: f64, page: u32) -> String { + format!( + r#" + + + + + + + + + "# + ) +} + fn package(frames: &[String]) -> Vec { let spread = format!( r#" @@ -108,3 +124,27 @@ fn one_file_placed_several_times_imports_as_one_asset() { } } } + +/// `PDFAttribute/@PageNumber` (1-based) is read into `Asset.page` (0-based), and the same file at +/// different pages stays apart (#905). +#[test] +fn a_pdf_placement_reads_its_page_number() { + let bytes = b"%PDF-1.7\n% fixture".to_vec(); + let reader = |p: &str| p.ends_with("insert.pdf").then(|| bytes.clone()); + let frames = vec![pdf_frame(1, 0.0, 2), pdf_frame(2, 100.0, 1), pdf_frame(3, 200.0, 2)]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + let p = placements(&d); + let page = |id| d.assets.get(&id).map(|a| a.page); + assert_eq!(page(p[0].0), Some(1), "PageNumber=\"2\" is the second page, stored 0-based"); + assert_eq!(page(p[1].0), Some(0), "PageNumber=\"1\" is the first page"); + assert_eq!(p[0].0, p[2].0, "the same file at the same page shares one asset"); + assert_ne!(p[0].0, p[1].0, "the same file at another page is another asset"); + + // Exported (linked or embedded), the page survives the round trip. + for embed_images in [false, true] { + let out = export_idml_with(&d, &ExportOptions { embed_images }); + let back = import_idml_with(&out, &reader).unwrap(); + let pages: Vec<_> = placements(&back).iter().map(|(id, _, _)| back.assets.get(id).map(|a| a.page)).collect(); + assert_eq!(pages, vec![Some(1), Some(0), Some(1)], "embed_images: {embed_images}"); + } +} From 85d7c161613df14c53759c0094b4ed0b8d9c945f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 00:49:33 +0200 Subject: [PATCH 12/15] IDML: InDesign's default CJK leading model only with a Japanese composer InDesign writes LeadingModel="LeadingModelAkiBelow" on [No paragraph style] of every document, Western ones included, but only its Japanese composers use the leading model. DesignCraft applied it to every paragraph, so a 24 pt paragraph after a 32 pt one sat 34.53 pt below it instead of the Roman 32.8 (4 pt space after + 28.8 auto leading) in Montilla Design's Flex Layout exercise file, and its fitted captions came out overset. The import now drops that default when no part of the package applies a Japanese composer (HL Composer J / HL Single J). Leading models set on text, other styles, or in documents that use a Japanese composer are kept. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/idml/src/import.rs | 29 ++++++++++++++++++++++++++++- crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..7daa06fa5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..a0aa73d00 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -106,6 +106,7 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option Option bool { + const KEY: &[u8] = b"Composer=\""; + let mut rest = data; + while let Some(at) = rest.windows(KEY.len()).position(|w| w == KEY) { + let value = rest.get(at + KEY.len()..).unwrap_or_default(); + let end = value.iter().position(|b| *b == b'"').unwrap_or(value.len()); + let name = String::from_utf8_lossy(value.get(..end).unwrap_or_default()); + if name.ends_with(" J") || name.contains("Japanese") { + return true; + } + rest = value.get(end..).unwrap_or_default(); + } + false +} + /// A package part name in the form the lookup compares: InDesign and other IDML writers differ /// in percent-encoding, case, path separators and a leading `./` or `/`. fn normalize_part_name(name: &str) -> String { @@ -159,6 +177,8 @@ struct ItemCtx { struct Importer<'r> { read_link: &'r dyn Fn(&str) -> Option>, + /// The package applies a Japanese composer somewhere (see [`names_japanese_composer`]). + japanese_composer: bool, next_id: u64, settings: DocSettings, default_margins: Option<(Margins, Columns)>, @@ -275,6 +295,7 @@ impl<'r> Importer<'r> { styles.paragraph.iter_mut().for_each(|s| s.based_on = None); Importer { read_link, + japanese_composer: false, next_id: 0, settings: DocSettings::default(), default_margins: None, @@ -903,7 +924,13 @@ impl<'r> Importer<'r> { let based_on = if name == designcraft_doc::NO_PARA_STYLE { None } else { self.based_on(e, true) }; let next_style = e.get("NextStyle").map(|n| self.para_style_ref(n)).filter(|n| *n != name); let para = self.para_attrs(e); - let chars = self.char_attrs(e); + let mut chars = self.char_attrs(e); + // InDesign writes its CJK leading model (Aki Below) on [No paragraph style] of every + // document; only the Japanese composers use it, so a document set with the Paragraph or + // Single-line Composer spaces its lines baseline to baseline. + if name == designcraft_doc::NO_PARA_STYLE && !self.japanese_composer { + chars.leading_model = None; + } let s = ParagraphStyle { name: name.clone(), based_on, next_style, para, chars, shortcut: String::new() }; match self.styles.para_mut(&name) { Some(slot) => *slot = s, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..7c9087da1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,35 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// InDesign writes `LeadingModel="LeadingModelAkiBelow"` on [No paragraph style] of every +/// document, but only its Japanese composers use the leading model: with the Paragraph Composer, +/// a 24 pt paragraph after a 32 pt one sits a Roman 28.8 pt below it (Montilla Design's Flex Layout +/// file: frames fitted to that came out overset). +#[test] +fn the_default_cjk_leading_model_applies_only_with_a_japanese_composer() { + let styles = |composer: &str| { + STYLES.replace( + r#"Name="$ID/[No paragraph style]" PointSize="12">"#, + &format!(r#"Name="$ID/[No paragraph style]" PointSize="12" LeadingModel="LeadingModelAkiBelow" Composer="{composer}">"#), + ) + }; + let model = |composer: &str| { + let s = styles(composer); + let pkg = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &s), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let d = import_idml(&pkg).unwrap(); + d.styles.para(designcraft_doc::NO_PARA_STYLE).unwrap().chars.leading_model + }; + assert_eq!(model("HL Composer"), None, "the Paragraph Composer leads baseline to baseline"); + assert_eq!(model("HL Single"), None); + assert_eq!(model("$ID/HL Composer J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); + assert_eq!(model("$ID/HL Single J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); +} From 4626c4b0846d7560c2d06b6fdc9542f074b2e446 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:24:38 +0200 Subject: [PATCH 13/15] Bound decompressed size and entry count of native and IDML ZIP imports Fixes #710 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 126 +++++++++++++++++++++++++++++++++++--- crates/idml/src/import.rs | 94 ++++++++++++++++++++-------- crates/idml/src/tests.rs | 86 ++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 33 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1c8910747..86393f803 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -87,22 +87,51 @@ pub fn load(bytes: &[u8]) -> Result { load_legacy_json(bytes) } +/// Most entries a `.designcraft` archive may hold. +const MAX_ENTRIES: usize = 100_000; +/// Most bytes `meta.json` or `document.json` may expand to. +const MAX_JSON_BYTES: u64 = 256 << 20; +/// Most bytes one embedded asset may expand to. +const MAX_ASSET_BYTES: u64 = 1 << 30; +/// Most bytes all the entries read from one archive may expand to together. +const MAX_TOTAL_BYTES: u64 = 4 << 30; + +/// Read entry `name` of `z`, or `None` when it is missing or unreadable. Refuses an entry that +/// expands past `limit` bytes or past what is `left` of the archive's budget, without +/// decompressing more than that: the sizes the archive claims are not trusted. +fn read_entry(z: &mut zip::ZipArchive>, name: &str, limit: u64, left: &mut u64) -> Result>, FormatError> { + let too_big = || FormatError::Io(format!("{name} is larger than the {limit}-byte limit (or the archive is larger than {MAX_TOTAL_BYTES} bytes)")); + let Ok(f) = z.by_name(name) else { return Ok(None) }; + let limit = limit.min(*left); + if f.size() > limit { + return Err(too_big()); + } + let mut v = Vec::new(); + if f.take(limit.saturating_add(1)).read_to_end(&mut v).is_err() { + return Ok(None); + } + let n = v.len() as u64; + if n > limit { + return Err(too_big()); + } + *left = left.saturating_sub(n); + Ok(Some(v)) +} + fn load_zip(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; - let read = |z: &mut zip::ZipArchive>, name: &str| -> Option> { - let mut f = z.by_name(name).ok()?; - let mut v = Vec::new(); - f.read_to_end(&mut v).ok()?; - Some(v) - }; - let version = read(&mut z, "meta.json") + if z.len() > MAX_ENTRIES { + return Err(FormatError::NotOurs(format!("{} entries (at most {MAX_ENTRIES})", z.len()))); + } + let mut left = MAX_TOTAL_BYTES; + let version = read_entry(&mut z, "meta.json", MAX_JSON_BYTES, &mut left)? .and_then(|meta| serde_json::from_slice::(&meta).ok()) .and_then(|m| m.get("version").and_then(Value::as_u64)) .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX)); if version > VERSION { return Err(FormatError::TooNew(version)); } - let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; + let json = read_entry(&mut z, "document.json", MAX_JSON_BYTES, &mut left)?.ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -114,7 +143,11 @@ fn load_zip(bytes: &[u8]) -> Result { for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; - if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { + // Entries for assets the document doesn't have are never decompressed. + if !doc.assets.contains_key(&AssetId(id)) { + continue; + } + if let (Some(data), Some(a)) = (read_entry(&mut z, &name, MAX_ASSET_BYTES, &mut left)?, doc.assets.get_mut(&AssetId(id))) { let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), @@ -613,4 +646,79 @@ mod tests { } assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } + + /// Write `len` bytes of `fill` to the current entry of `z`. + fn write_fill(z: &mut zip::ZipWriter>>, len: usize, fill: u8) { + let chunk = vec![fill; 1 << 20]; + let mut left = len; + while left > 0 { + let n = left.min(chunk.len()); + z.write_all(&chunk[..n]).unwrap(); + left -= n; + } + } + + /// Make the archive claim that entry `name` expands to `size` bytes, in both its local header + /// and its central directory record (the archive has no ZIP64 fields or data descriptors). + fn claim_size(bytes: &mut [u8], name: &str, size: u32) { + let mut patched = 0; + for i in 0..bytes.len().saturating_sub(46) { + let (size_at, len_at, name_at) = match &bytes[i..i + 4] { + b"PK\x03\x04" => (22, 26, 30), + b"PK\x01\x02" => (24, 28, 46), + _ => continue, + }; + let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize; + if bytes.get(i + name_at..i + name_at + len) == Some(name.as_bytes()) { + bytes[i + size_at..i + size_at + 4].copy_from_slice(&size.to_le_bytes()); + patched += 1; + } + } + assert_eq!(patched, 2, "{name} not found in both headers"); + } + + /// A small archive whose `document.json` expands past the 256 MiB limit while claiming to + /// hold one byte is refused once the limit is reached, with an error that says so, instead of + /// being read whole into memory. + #[test] + fn oversized_document_json_is_refused() { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + write_fill(&mut z, (256 << 20) + 1, b' '); + let mut bytes = z.finish().unwrap().into_inner(); + assert!(bytes.len() < 4 << 20, "{}", bytes.len()); + claim_size(&mut bytes, "document.json", 1); + let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") }; + assert!(err.to_string().contains("limit"), "{err}"); + } + + /// An embedded file that expands past the 1 GiB limit (and says so) is refused too. + #[test] + fn oversized_asset_is_refused() { + let mut d = Document::new(&NewDocument::default()); + let aid = png_asset(&mut d, &[1]); + place(&mut d, aid, 0.0); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap(); + write_fill(&mut z, (1 << 30) + 1, 0); + let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") }; + assert!(err.to_string().contains("limit"), "{err}"); + } + + /// An archive with more entries than any document needs is refused before any is read. + #[test] + fn too_many_entries_are_refused() { + let d = Document::new(&NewDocument::default()); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + z.start_file("document.json", stored).unwrap(); + z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + for i in 0..100_000 { + z.start_file(format!("junk/{i}"), stored).unwrap(); + } + let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") }; + assert!(err.to_string().contains("entries"), "{err}"); + } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..54e1da5db 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -62,30 +62,14 @@ pub struct Imported { /// Import with a custom reader for linked files, reporting the problems that did not stop it. pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - let mut files: HashMap> = HashMap::new(); - // Normalised part name → entry name, first entry wins (zip order, so the choice is stable). - let mut lenient: HashMap = HashMap::new(); - for i in 0..zip.len() { - let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { - continue; - } - let name = f.name().to_string(); - // The size is what the archive claims: don't reserve more than a sane part up front. - let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); - f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; - lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); - files.insert(name, buf); - } - let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n))); - if let Some(m) = part("mimetype") - && String::from_utf8_lossy(m).trim() != MIMETYPE + let mut pkg = Package::open(bytes)?; + if let Some(m) = pkg.part("mimetype")? + && String::from_utf8_lossy(&m).trim() != MIMETYPE { - return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim()))); + return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(&m).trim()))); } - let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; - let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; + let dm = pkg.part("designmap.xml")?.ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; + let root = parse(&dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; if root.local() != "Document" { return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name))); } @@ -95,11 +79,11 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option Option { + zip: zip::ZipArchive>, + /// Normalised part name → entry name, first entry wins (zip order, so the choice is stable). + lenient: HashMap, + /// Bytes the parts still to be read may expand to. + left: u64, +} + +impl<'a> Package<'a> { + fn open(bytes: &'a [u8]) -> Result { + let zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + if zip.len() > MAX_ENTRIES { + return Err(IdmlError::NotIdml(format!("{} entries (at most {MAX_ENTRIES})", zip.len()))); + } + let mut lenient: HashMap = HashMap::new(); + for i in 0..zip.len() { + let Some(name) = zip.name_for_index(i) else { continue }; + if name.ends_with('/') { + continue; + } + lenient.entry(normalize_part_name(name)).or_insert_with(|| name.to_string()); + } + Ok(Package { zip, lenient, left: MAX_TOTAL_BYTES }) + } + + /// The bytes of part `name` (matched exactly, then leniently), or `None` if the package has + /// no such part. + fn part(&mut self, name: &str) -> Result>> { + let exact = self.zip.index_for_name(name).filter(|_| !name.ends_with('/')); + let Some(i) = exact.or_else(|| self.lenient.get(&normalize_part_name(name)).and_then(|n| self.zip.index_for_name(n))) else { + return Ok(None); + }; + let f = self.zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + let part = f.name().to_string(); + let limit = MAX_PART_BYTES.min(self.left); + let too_big = || IdmlError::Part { + part: part.clone(), + msg: format!("larger than the {MAX_PART_BYTES}-byte part limit (or the package is larger than {MAX_TOTAL_BYTES} bytes)"), + }; + if f.size() > limit { + return Err(too_big()); + } + let mut buf = Vec::new(); + f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: part.clone(), msg: e.to_string() })?; + let n = buf.len() as u64; + if n > limit { + return Err(too_big()); + } + self.left = self.left.saturating_sub(n); + Ok(Some(buf)) + } +} + /// A package part name in the form the lookup compares: InDesign and other IDML writers differ /// in percent-encoding, case, path separators and a leading `./` or `/`. fn normalize_part_name(name: &str) -> String { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..4cc273ca1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,89 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// A package entry nothing in `designmap.xml` refers to is never decompressed: a damaged one +/// (its checksum no longer matches) does not stop the import. +#[test] +fn unreferenced_entries_are_not_read() { + use zip::write::SimpleFileOptions; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + for (n, c) in [ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ] { + w.start_file(n, SimpleFileOptions::default()).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + w.start_file("junk/irrelevant.bin", stored).unwrap(); + w.write_all(b"<>").unwrap(); + let mut bytes = w.finish().unwrap().into_inner(); + let at = bytes.windows(9).position(|w| w == b"< + + "#; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("mimetype", SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("designmap.xml", SimpleFileOptions::default()).unwrap(); + w.write_all(designmap.as_bytes()).unwrap(); + w.start_file("Stories/Story_big.xml", SimpleFileOptions::default()).unwrap(); + let chunk = vec![b' '; 1 << 20]; + for _ in 0..512 { + w.write_all(&chunk).unwrap(); + } + w.write_all(b" ").unwrap(); + let mut bytes = w.finish().unwrap().into_inner(); + assert!(bytes.len() < 8 << 20, "{}", bytes.len()); + // Claim one byte in the local header and the central directory record (no ZIP64 fields or + // data descriptors here), so only the bytes actually decompressed can show the part is too big. + let mut patched = 0; + for i in 0..bytes.len().saturating_sub(46) { + let (size_at, len_at, name_at) = match &bytes[i..i + 4] { + b"PK\x03\x04" => (22, 26, 30), + b"PK\x01\x02" => (24, 28, 46), + _ => continue, + }; + let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize; + if bytes.get(i + name_at..i + name_at + len) == Some(b"Stories/Story_big.xml".as_slice()) { + bytes[i + size_at..i + size_at + 4].copy_from_slice(&1u32.to_le_bytes()); + patched += 1; + } + } + assert_eq!(patched, 2); + let Err(err) = import_idml(&bytes) else { panic!("an oversized part imported") }; + assert!(err.to_string().contains("limit"), "{err}"); +} + +/// A package with more entries than any document needs is refused before any is read. +#[test] +fn too_many_entries_are_refused() { + use zip::write::SimpleFileOptions; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + for i in 0..100_000 { + w.start_file(format!("junk/{i}"), stored).unwrap(); + } + let Err(err) = import_idml(&w.finish().unwrap().into_inner()) else { panic!("a package with 100 001 entries imported") }; + assert!(err.to_string().contains("entries"), "{err}"); +} From e35c430628ae1203da82177d8c785b1da4254fbf Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:30:58 +1000 Subject: [PATCH 14/15] Preserve stored IDML page liquid rules Co-Authored-By: Codex --- crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 2 +- crates/idml/src/names.rs | 25 ++++++ crates/idml/src/tests.rs | 156 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..d699268e4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1663,6 +1663,7 @@ impl<'a> Ex<'a> { .attr("Self", uid(pg.id.0)) .attr("Name", name) .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) + .attr("LayoutRule", names::liquid_rule_out(pg.liquid)) .attr("OverrideList", "") .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..04e3bbec2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2015,7 +2015,7 @@ impl<'r> Importer<'r> { overridden: vec![], guides: Vec::::new(), show_parent_items: show, - liquid: Default::default(), + liquid: names::liquid_rule_in(p.get("LayoutRule").unwrap_or("Off")), transition: None, view_rotation: 0, show_parent_overlay: false, diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ed0daaf40..d83a55884 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -74,6 +74,31 @@ pub fn fitting_in(s: &str) -> designcraft_doc::Fitting { } } +pub fn liquid_rule_out(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Recenter", + R::GuideBased => "GuideBased", + R::ObjectBased => "ObjectBased", + R::ControlledByParent => "UseMaster", + } +} + +pub fn liquid_rule_in(s: &str) -> designcraft_doc::LiquidRule { + use designcraft_doc::LiquidRule as R; + match s { + "Off" => R::Off, + "Scale" => R::Scale, + "Recenter" => R::ReCenter, + "GuideBased" => R::GuideBased, + "ObjectBased" => R::ObjectBased, + "UseMaster" => R::ControlledByParent, + _ => R::Off, + } +} + pub fn escape_id(s: &str) -> String { s.replace('%', "%25").replace(':', "%3a") } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..a5da73992 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,159 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +mod liquid_page_rules { + use super::*; + use designcraft_doc::LiquidRule as R; + use std::sync::Arc; + + // Literal XML expectations, independent of names.rs and serde spellings. + const CASES: [(&str, R); 6] = [ + ("Scale", R::Scale), + ("Off", R::Off), + ("Recenter", R::ReCenter), + ("GuideBased", R::GuideBased), + ("ObjectBased", R::ObjectBased), + ("UseMaster", R::ControlledByParent), + ]; + + fn fixture(rule: Option<&str>, parent_rule: Option<&str>) -> Vec { + let attribute = |value: Option<&str>| value.map(|v| format!(r#" LayoutRule="{v}""#)).unwrap_or_default(); + let (rule, parent_rule) = (attribute(rule), attribute(parent_rule)); + let map = r#" + + + + +"#; + let parent = format!( + r#" + + + + +"# + ); + // Based-on belongs to the whole derived master: both pages apply the same master. + let derived = format!( + r#" + + + + +"# + ); + let spread = format!( + r#" + + + + + + +"# + ); + zip_files(&[ + ("designmap.xml", map), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/A.xml", &parent), + ("MasterSpreads/B.xml", &derived), + ("Spreads/S.xml", &spread), + ]) + } + + #[test] + fn import_literal_xml() { + let baseline = import_idml(&fixture(None, None)).unwrap(); + assert_eq!((baseline.spreads.len(), baseline.parents.len()), (1, 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).all(|sp| sp.pages.len() == 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).flat_map(|sp| &sp.pages).all(|pg| pg.liquid == R::Off)); + let cases = CASES.into_iter().map(|(literal, rule)| (Some(literal), rule)).chain([ + (None, R::Off), + (Some("ReCenter"), R::Off), + (Some("controlledByParent"), R::Off), + (Some("PreserveExisting"), R::Off), + ]); + for (literal, rule) in cases { + let actual = import_idml(&fixture(literal, Some("Recenter"))).unwrap(); + assert_eq!(actual.spreads[0].pages[1].liquid, rule, "ordinary Page: {literal:?}"); + assert_eq!(actual.parents[1].pages[0].liquid, rule, "master Page: {literal:?}"); + // Same XML without rule attributes supplies the complete allowed-delta oracle. + let mut expected = baseline.clone(); + Arc::make_mut(&mut expected.spreads[0]).pages[1].liquid = rule; + Arc::make_mut(&mut expected.parents[1]).pages[0].liquid = rule; + Arc::make_mut(&mut expected.parents[0]).pages[1].liquid = R::ReCenter; + // Import assigns a fresh creation timestamp; all other document fields are compared. + expected.created = actual.created; + assert_eq!(actual, expected, "{literal:?}: only the three explicit rule fields may change apart from the import timestamp"); + assert_eq!(actual.spreads[0].pages[0].liquid, R::Off, "omitted does not infer inheritance"); + assert_eq!(actual.spreads[0].pages[0].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parents[1].parent.as_ref().unwrap().based_on, Some(actual.parents[0].id)); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + if rule == R::ControlledByParent { + // The right document page resolves to A's right page, not B's controlled page. + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert_eq!(actual.parents[0].pages[1].liquid, R::ReCenter); + } + } + } + + #[test] + fn export_and_round_trip() { + use std::io::Read; + + let baseline = import_idml(&fixture(None, None)).unwrap(); + for (literal, rule) in CASES { + // Set native enums directly; the literal importer under test does not build this input. + let mut source = baseline.clone(); + let pages = &mut Arc::make_mut(&mut source.spreads[0]).pages; + pages[0].parent = None; + pages[0].liquid = R::ControlledByParent; + pages[1].liquid = rule; + Arc::make_mut(&mut source.parents[0]).pages[1].liquid = R::ReCenter; + Arc::make_mut(&mut source.parents[1]).pages[0].liquid = rule; + source.check().unwrap(); + let bytes = export_idml(&source); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + for (directory, tag, spread, rules) in [ + ("Spreads", "Spread", &source.spreads[0], ["UseMaster", literal]), + ("MasterSpreads", "MasterSpread", &source.parents[0], ["Off", "Recenter"]), + ("MasterSpreads", "MasterSpread", &source.parents[1], [literal, "Off"]), + ] { + let path = format!("{directory}/{tag}_u{:x}.xml", spread.id.0); + let mut part = Vec::new(); + archive.by_name(&path).unwrap().read_to_end(&mut part).unwrap(); + let root = xml::parse(&part).unwrap(); + let body = root.find(tag).unwrap(); + let pages: Vec<_> = body.find_all("Page").collect(); + assert_eq!(pages.len(), spread.pages.len(), "{path}"); + for (page, expected) in spread.pages.iter().zip(rules) { + let id = format!("u{:x}", page.id.0); + let matches: Vec<_> = pages.iter().filter(|p| p.get("Self") == Some(id.as_str())).collect(); + assert_eq!(matches.len(), 1, "{path}: {id}"); + assert_eq!(matches[0].get("LayoutRule"), Some(expected), "{path}: {id}"); + } + } + let actual = import_idml(&bytes).unwrap(); + assert_eq!((actual.spreads.len(), actual.parents.len()), (1, 2)); + assert_eq!(actual.spreads[0].pages[0].parent, None); + assert_eq!(actual.spreads[0].pages[1].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + for (index, prefix, name, based_on) in [(0, "A", "Base", None), (1, "B", "Derived", Some(actual.parents[0].id))] { + let info = actual.parents[index].parent.as_ref().unwrap(); + assert_eq!((&info.prefix[..], &info.name[..], info.based_on), (prefix, name, based_on)); + } + for (got, want) in actual.spreads.iter().chain(&actual.parents).zip(source.spreads.iter().chain(&source.parents)) { + assert_eq!(got.pages.len(), want.pages.len()); + for (got, want) in got.pages.iter().zip(&want.pages) { + // IDs are allocated afresh; parent associations were checked explicitly above. + let mut normalized = got.clone(); + normalized.id = want.id; + normalized.parent = want.parent; + assert_eq!(&normalized, want, "{literal}: all other Page fields, including the stored rule"); + } + } + } + } +} From 0697b39a3aef53d7e99a8e8a74ebaef1dedf6a9c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:45:50 -0400 Subject: [PATCH 15/15] fix: integrate batched community PRs (rustfmt after the IDML/text cluster merges) Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 54126acf2..f2b8a9766 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3228,4 +3228,3 @@ mod frame_preference_scalar_tests { assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); } } -