diff --git a/ROADMAP.md b/ROADMAP.md index eb4bad1..ba31c40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,6 +45,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). - Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). +- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 039e14b..b1c9ea9 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -88,6 +88,12 @@ impl Cache { format!("{:?}", doc.footnote_options).hash(&mut h); sig.extend([doc.footnote_start(sid) as usize, h.finish() as usize]); } + // Hidden conditions take their text out of the layout. + for (i, c) in doc.conditions.iter().enumerate() { + if !c.visible { + sig.push(usize::MAX - i); + } + } // Advanced Type sizes super/subscripts. let a = doc.settings.advanced_type; sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize)); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a95e83c..bc746f4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -56,6 +56,8 @@ pub struct RunStyle { pub missing_font: bool, /// Tracking or manual kerning (Highlight Custom Tracking/Kerning). pub custom_tracking: bool, + /// The first condition applied (its indicator colour underlines the text on screen). + pub condition: Option, } /// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = @@ -453,6 +455,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), section_marker: None, vars: var_values, + hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), ..Default::default() }; if !story.objects.is_empty() { @@ -1501,7 +1504,8 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { || g.ch == story::INDENT_HERE || g.ch == story::RIGHT_INDENT_TAB || g.ch == story::TABLE_ANCHOR - || g.ch == shape::SOFT_HYPHEN); + || g.ch == shape::SOFT_HYPHEN + || g.ch == shape::HIDDEN); PlacedGlyph { face: g.face, gid: g.gid, diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index c60bab8..a2096e4 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -77,8 +77,13 @@ pub struct SubstCtx { pub xrefs: HashMap, /// Anchored objects by story byte of their mark. pub objects: HashMap, + /// Conditions currently hidden (conditional text with only these isn't shown). + pub hidden_conditions: Vec, } +/// The stand-in character of hidden conditional text: no width, no break, not drawn. +pub const HIDDEN: char = '\u{2060}'; + /// Size and placement of an anchored object for line layout. #[derive(Clone, Copy, Debug, Default)] pub struct ObjectSpec { @@ -126,6 +131,7 @@ impl StyleTable<'_> { .entry(p.font_family.clone()) .or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)), custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)), + condition: p.conditions.first().cloned(), }; if let Some(i) = self.styles.iter().rposition(|s| *s == rs) { return i as u32; @@ -186,6 +192,21 @@ pub(crate) fn shape_para( }; let props = styles.resolve_char(para_chars, fmt); let style = table.intern(&props); + if !props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c)) { + // Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every + // byte addressable (caret, selection) without taking space. + let face = db.face(&props.font_family, &props.font_style); + for (i, c) in story.text[a..b].char_indices() { + if c == '\n' { + continue; + } + let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c); + g.ch = HIDDEN; + (g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0); + glyphs.push(g); + } + continue; + } if !story.text[a..b].contains(designcraft_doc::FOOTNOTE_REF) { shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs); continue; diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 8ce1646..5945745 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -448,6 +448,8 @@ attr_set! { language: String = "English: USA".into(), /// Additional OpenType features, e.g. `["onum", "ss01"]`. otf_features: Vec = Vec::new(), + /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). + conditions: Vec = Vec::new(), } } diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c79541e..a9fcad6 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -127,6 +127,7 @@ impl Document { styles: Arc::new(Styles::default()), swatches: designcraft_color::default_swatches(), color_groups: vec![], + conditions: vec![], sections: vec![Section { start: 0, start_number: Some(nd.start_page.max(1)), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 881a6aa..49cdab2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -131,6 +131,23 @@ impl Default for NumberedList { } } +/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + pub name: String, + pub color: [u8; 3], + pub visible: bool, +} + +impl Document { + /// Is text with these conditions hidden? (Conditioned text shows while any of its conditions + /// is visible.) + pub fn conditions_hide(&self, conds: &[String]) -> bool { + !conds.is_empty() && conds.iter().all(|c| self.conditions.iter().any(|x| x.name == *c && !x.visible)) + } +} + /// A Swatches panel colour group: a named folder of swatches (by name). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -305,6 +322,9 @@ pub struct Document { /// the top level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub color_groups: Vec, + /// Conditional text conditions; text whose conditions are all hidden isn't composed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, diff --git a/crates/engine/src/cmd/conditions.rs b/crates/engine/src/cmd/conditions.rs new file mode 100644 index 0000000..4744fce --- /dev/null +++ b/crates/engine/src/cmd/conditions.rs @@ -0,0 +1,191 @@ +//! Window › Type & Tables › Conditional Text: conditions, applying them to text, showing and +//! hiding them. + +use std::sync::Arc; + +use designcraft_doc::{Condition, Document, Story}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; + +/// Indicator colours given to new conditions in turn (light, like the Layers panel's). +const COLORS: [[u8; 3]; 6] = [[79, 153, 255], [255, 79, 79], [79, 255, 79], [255, 179, 79], [179, 79, 255], [79, 230, 230]]; + +pub fn specs() -> Vec { + vec![ + cmd!(query "condition.list", "Conditions", [], None, "{} → [{name, color, visible}]", has_doc, |s, _| { + Ok(serde_json::to_value(&s.doc()?.doc.conditions).unwrap_or_default()) + }), + cmd!( + "condition.new", + "New Condition…", + ["Window", "Type & Tables", "Conditional Text"], + None, + "{name, color?: [r, g, b], visible?} → {name}", + has_doc, + |s, p| { + let name = + str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("condition.new", "`name` required"))?.to_string(); + let color = color_param(p); + let visible = p.get("visible").and_then(Value::as_bool).unwrap_or(true); + s.edit(|d, _| { + if d.conditions.iter().any(|c| c.name == name) { + return Err(bad("condition.new", format!("a condition named `{name}` exists"))); + } + let color = color.unwrap_or(COLORS[d.conditions.len() % COLORS.len()]); + d.conditions.push(Condition { name: name.clone(), color, visible }); + Ok(json!({"name": name})) + }) + } + ), + cmd!("condition.options", "Condition Options…", [], None, "{name, to?: new name, color?: [r, g, b], visible?: bool}", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + let to = str_param(p, "to").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let color = color_param(p); + let visible = p.get("visible").and_then(Value::as_bool); + s.edit(|d, _| { + if let Some(t) = &to + && *t != name + && d.conditions.iter().any(|c| c.name == *t) + { + return Err(bad("condition.options", format!("a condition named `{t}` exists"))); + } + let c = d.conditions.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("condition.options", format!("no condition `{name}`")))?; + if let Some(v) = color { + c.color = v; + } + if let Some(v) = visible { + c.visible = v; + } + if let Some(t) = to.filter(|t| *t != name) { + c.name = t.clone(); + rename_everywhere(d, &name, Some(&t)); + } + ok() + }) + }), + cmd!("condition.delete", "Delete Condition", [], None, "{name} — the text it was applied to stays (shown)", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|d, _| { + let n = d.conditions.len(); + d.conditions.retain(|c| c.name != name); + if d.conditions.len() == n { + return Err(bad("condition.delete", format!("no condition `{name}`"))); + } + rename_everywhere(d, &name, None); + ok() + }) + }), + cmd!( + "condition.apply", + "Apply Condition", + [], + None, + "{name, on?: bool (default true), only?: bool (remove the others)} — to the selected text; `name: null` with `only` removes all", + has_text, + |s, p| { + let name = str_param(p, "name").map(str::to_string); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); + let only = p.get("only").and_then(Value::as_bool).unwrap_or(false); + if let Some(n) = &name + && !s.doc()?.doc.conditions.iter().any(|c| c.name == *n) + { + return Err(bad("condition.apply", format!("no condition `{n}`"))); + } + let targets = super::text::format_targets(s); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if t.range.is_empty() { + continue; + } + st.format_chars(t.range.clone(), |f| { + let mut v = if only { Vec::new() } else { f.over.conditions.clone().unwrap_or_default() }; + if let Some(n) = &name { + v.retain(|x| x != n); + if on { + v.push(n.clone()); + } + } + f.over.conditions = if v.is_empty() { None } else { Some(v) }; + }); + } + ok() + }) + } + ), + ] +} + +fn color_param(p: &Value) -> Option<[u8; 3]> { + let a = p.get("color")?.as_array()?; + let c = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v.clamp(0.0, 255.0) as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + +/// Rename (or with `None`, remove) a condition in every story's text, table cells included. +fn rename_everywhere(d: &mut Document, from: &str, to: Option<&str>) { + fn fix(st: &mut Story, from: &str, to: Option<&str>) { + let len = st.text.len(); + if st.chars.iter().any(|r| r.format.over.conditions.as_ref().is_some_and(|v| v.iter().any(|x| x == from))) { + st.format_chars(0..len, |f| { + if let Some(v) = &mut f.over.conditions { + match to { + Some(t) => v.iter_mut().filter(|x| *x == from).for_each(|x| *x = t.to_string()), + None => v.retain(|x| x != from), + } + if v.is_empty() { + f.over.conditions = None; + } + } + }); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + fix(&mut c.text, from, to); + } + } + } + for st in d.stories.values_mut() { + fix(Arc::make_mut(st), from, to); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn hidden_condition_removes_text_from_the_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Price: $10 / €9"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("condition.new", &json!({"name": "EU"})).unwrap(); + let eu = "Price: $10 / ".len(); + s.execute("text.select", &json!({"story": r["story"], "anchor": eu, "focus": "Price: $10 / €9".len()})).unwrap(); + s.execute("condition.apply", &json!({"name": "EU"})).unwrap(); + let end_x = |s: &Session| s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines[0].end_x; + let shown = end_x(&s); + s.execute("condition.options", &json!({"name": "EU", "visible": false})).unwrap(); + let hidden = end_x(&s); + assert!(hidden < shown - 5.0, "{shown} → {hidden}"); + // Every byte still has a glyph (caret positions survive). + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(cs.frames[0].lines[0].glyphs.iter().any(|g| g.byte >= eu && !g.visible)); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("$10") && !html.contains("€9"), "hidden text isn't exported"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(back.conditions, s.doc().unwrap().doc.conditions, "IDML Condition"); + let bs = back.stories.values().find(|st| st.text.contains("€9")).unwrap(); + assert!(bs.chars.iter().any(|r| r.format.over.conditions.as_deref() == Some(&["EU".to_string()][..])), "AppliedConditions"); + s.execute("condition.options", &json!({"name": "EU", "to": "Europe"})).unwrap(); + let runs = &s.doc().unwrap().doc.stories[&sid].chars; + assert!(runs.iter().any(|r| r.format.over.conditions.as_deref() == Some(&["Europe".to_string()][..]))); + s.execute("condition.delete", &json!({"name": "Europe"})).unwrap(); + assert!((end_x(&s) - shown).abs() < 0.01, "text shows again"); + assert!(s.doc().unwrap().doc.stories[&sid].chars.iter().all(|r| r.format.over.conditions.is_none())); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a012a95..5c3ceb4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -3,6 +3,7 @@ mod anchored; pub mod anchors; mod captions; +mod conditions; mod datamerge; mod edit; mod export; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(anchors::specs()); v.extend(paths::specs()); v.extend(captions::specs()); + v.extend(conditions::specs()); v.extend(lists::specs()); v.extend(path_type::specs()); v.extend(qr::specs()); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index cd3dfa1..b37f07c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -496,7 +496,7 @@ pub(crate) struct Target { /// Ranges and stories a formatting command applies to: selected table cells (whole cells), the /// text selection, or whole stories of the selected text frames. -fn format_targets(s: &Session) -> Vec { +pub(crate) fn format_targets(s: &Session) -> Vec { let Some(st) = s.active() else { return vec![] }; if let Some(ts) = st.selection.cells && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 6662ec5..ed6eb55 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -221,6 +221,10 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { if a >= b { continue; } + // Hidden conditional text isn't exported. + if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) { + continue; + } let raw = &st.text[a..b]; let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { let sub: String = raw diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b86d13d..3f7c07b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -287,6 +287,17 @@ impl<'a> Ex<'a> { .attr("ContinueNumbersAcrossDocuments", "false"), ); root.push(El::new("idPkg:Preferences").attr("src", "Resources/Preferences.xml")); + for c in &d.conditions { + root.push( + El::new("Condition") + .attr("Self", format!("Condition/{}", escape_id(&c.name))) + .attr("Name", &c.name) + .attr("IndicatorColor", format!("{} {} {}", c.color[0], c.color[1], c.color[2])) + .attr("IndicatorMethod", "UseUnderline") + .attr("UnderlineIndicatorAppearance", "Solid") + .attr("Visible", bool_s(c.visible)), + ); + } // Variables InDesign's cross-reference page/chapter blocks rely on. for (name, ty) in [("XRefChapterNumber", "XrefChapterNumberType"), ("XRefPageNumber", "XrefPageNumberType")] { let n = format!("TV {name}"); @@ -997,6 +1008,9 @@ impl<'a> Ex<'a> { if let Some(v) = a.no_break { el.set("NoBreak", bool_s(v)); } + if let Some(list) = &a.conditions { + el.set("AppliedConditions", list.iter().map(|c| format!("Condition/{}", escape_id(c))).collect::>().join(" ")); + } if let Some(v) = &a.language { el.set("AppliedLanguage", format!("$ID/{v}")); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 7c59af2..39b4677 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -98,6 +98,8 @@ struct Importer<'r> { /// Unnamed swatches (gradients) added on first use. hidden_swatches: HashMap, color_groups: Vec, + conditions: Vec, + condition_names: HashMap, styles: Styles, para_names: HashMap, char_names: HashMap, @@ -177,6 +179,8 @@ impl<'r> Importer<'r> { hidden_colors: HashMap::new(), hidden_swatches: HashMap::new(), color_groups: Vec::new(), + conditions: Vec::new(), + condition_names: HashMap::new(), styles, para_names: HashMap::new(), char_names: HashMap::new(), @@ -618,6 +622,20 @@ impl<'r> Importer<'r> { // ---------- styles ---------- fn styles(&mut self, top: &[El]) { + for e in top.iter().filter(|e| e.local() == "Condition") { + let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue }; + // Indicator colours: "r g b" (0–255) or a UI colour name (kept as the default blue). + let nums: Vec = e + .get("IndicatorColor") + .unwrap_or("") + .split_whitespace() + .filter_map(|x| x.parse::().ok()) + .map(|x| x.clamp(0.0, 255.0) as u8) + .collect(); + let color = if nums.len() == 3 { [nums[0], nums[1], nums[2]] } else { [79, 153, 255] }; + self.condition_names.insert(id.to_string(), name.to_string()); + self.conditions.push(designcraft_doc::Condition { name: name.to_string(), color, visible: e.get("Visible") != Some("false") }); + } for e in top.iter().filter(|e| e.local() == "NumberingList") { let name = e.get("Name").unwrap_or("").to_string(); if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) { @@ -1004,6 +1022,12 @@ impl<'r> Importer<'r> { a.otf_features = Some(otf_list); } a.no_break = e.boolean("NoBreak"); + if let Some(v) = e.get("AppliedConditions").filter(|v| !v.trim().is_empty()) { + let names: Vec = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect(); + if !names.is_empty() { + a.conditions = Some(names); + } + } a.language = e.prop("AppliedLanguage").map(|l| l.trim().trim_start_matches("$ID/").to_string()); a } @@ -1956,6 +1980,7 @@ impl<'r> Importer<'r> { styles: Arc::new(std::mem::take(&mut self.styles)), swatches: std::mem::take(&mut self.swatches), color_groups: std::mem::take(&mut self.color_groups), + conditions: std::mem::take(&mut self.conditions), sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), hyperlinks: vec![], diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 225a258..a3d2b68 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -105,6 +105,8 @@ pub struct RenderOptions { pub highlight_hj: bool, pub highlight_keeps: bool, pub highlight_custom_tracking: bool, + /// Conditional text indicators: an underline in each condition's colour (screen view). + pub condition_indicators: bool, /// View › Display Performance. pub quality: DisplayQuality, /// Preferences › Appearance of Black: show 100% K as rich (pure) black instead of the @@ -140,6 +142,7 @@ impl Default for RenderOptions { highlight_hj: false, highlight_keeps: false, highlight_custom_tracking: false, + condition_indicators: false, quality: DisplayQuality::High, rich_black: false, overprint_preview: false, diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b03d241..fc57274 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -229,6 +229,19 @@ impl Renderer { } } } + if f.opts.condition_indicators && !f.doc.conditions.is_empty() && cs.styles.iter().any(|s| s.condition.is_some()) { + ctx.set_transform(m); + let px = 1.0 / m.determinant().abs().sqrt().max(1e-9); + for l in &ft.lines { + for g in l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0) { + let Some(name) = cs.styles.get(g.style as usize).and_then(|s| s.condition.as_deref()) else { continue }; + let Some(c) = f.doc.conditions.iter().find(|c| c.name == name) else { continue }; + ctx.set_paint(peniko::Color::from_rgb8(c.color[0], c.color[1], c.color[2])); + let y = l.baseline + l.descent * 0.6; + ctx.fill_rect(&kurbo::Rect::new(g.x, y, g.x + g.adv, y + 2.0 * px)); + } + } + } let scale = m.determinant().abs().sqrt(); let greek_px = f.opts.greek_below_px; let mut greek = BezPath::new(); diff --git a/crates/textimport/src/export.rs b/crates/textimport/src/export.rs index f814c1e..49c2092 100644 --- a/crates/textimport/src/export.rs +++ b/crates/textimport/src/export.rs @@ -92,6 +92,10 @@ pub fn rtf(doc: &Document, story: &Story) -> String { continue; } let cp = styles.resolve_char(&base, f); + // Hidden conditional text isn't exported. + if doc.conditions_hide(&cp.conditions) { + continue; + } if !fonts.contains(&cp.font_family) { fonts.push(cp.font_family.clone()); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be5f38d..a0a743d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -312,6 +312,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, greek_below_px: 3.0 * ppp, highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts, highlight_hj: !preview && app.session.prefs.highlight_hj, + condition_indicators: !preview, highlight_keeps: !preview && app.session.prefs.highlight_keeps, highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, quality: app.ui.display_quality, @@ -420,6 +421,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: greek_below_px: 3.0 * ppp, highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts, highlight_hj: !preview && app.session.prefs.highlight_hj, + condition_indicators: !preview, highlight_keeps: !preview && app.session.prefs.highlight_keeps, highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, quality: app.ui.display_quality, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 2a9b9cf..30ba798 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -30,6 +30,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("info", "Info", "panel-info"), ("preflight", "Preflight", "panel-preflight"), ("attributes", "Attributes", "panel-attributes"), + ("conditions", "Conditional Text", "panel-conditions"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -165,6 +166,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "links" => panels::properties::links_panel(app, ui), "preflight" => panels::properties::preflight_panel(app, ui), "attributes" => panels::properties::attributes_panel(app, ui), + "conditions" => panels::conditions::show(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8458da4..4885eaf 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -310,6 +310,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.circle(10.0, 10.0, 7.0); pen.line(&[(6.5, 10.0), (9.0, 12.5), (13.5, 7.5)]); } + "panel-conditions" => { + // Text lines, one marked. + pen.line(&[(3.0, 6.0), (17.0, 6.0)]); + pen.line(&[(3.0, 10.0), (17.0, 10.0)]); + pen.line(&[(3.0, 14.0), (11.0, 14.0)]); + pen.line(&[(3.0, 16.5), (11.0, 16.5)]); + } "panel-attributes" => { // Two overlapping inks: overprint. pen.rect(3.0, 3.0, 13.0, 13.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e291e7..16e02a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -462,6 +462,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:window.panel", "-", + ">Type & Tables", + "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", + "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", + "<", ">Utilities", "cmd:data.merge", "<", diff --git a/crates/ui-egui/src/panels/conditions.rs b/crates/ui-egui/src/panels/conditions.rs new file mode 100644 index 0000000..7386e46 --- /dev/null +++ b/crates/ui-egui/src/panels/conditions.rs @@ -0,0 +1,83 @@ +//! Conditional Text panel: conditions with their visibility and indicator colour; click a name to +//! apply it to the selected text (the box shows whether the selection has it). + +use egui::{Sense, vec2}; +use serde_json::json; + +use crate::DesignApp; +use crate::theme::Tokens; + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let conditions = st.doc.conditions.clone(); + let has_text = st.selection.text.is_some(); + // Conditions at the selection start (all runs share them for a "checked" box). + let applied: Vec = super::text_attrs(app) + .and_then(|a| a["chars"]["conditions"].as_array().map(|v| v.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())) + .unwrap_or_default(); + if conditions.is_empty() { + ui.label(egui::RichText::new("No conditions. Create one, then select text and click it to apply.").size(11.0).color(t.text_dim)); + } + for c in &conditions { + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + // Visibility (eye). + let eye = egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)); + let er = ui.interact(eye, ui.id().with(("cond_eye", &c.name)), Sense::click()).on_hover_text("Show / hide text with this condition"); + if c.visible { + crate::icons::paint(ui.painter(), eye, "eye", t.text_dim); + } + if er.clicked() { + let _ = app.run("condition.options", json!({"name": c.name, "visible": !c.visible})); + } + // Applied box. + let bx = egui::Rect::from_min_size(row.min + vec2(24.0, 5.0), vec2(12.0, 12.0)); + ui.painter().rect_stroke(bx, 2.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + if applied.contains(&c.name) { + ui.painter() + .line_segment([bx.left_center() + vec2(2.0, 0.0), bx.center_bottom() - vec2(0.0, 3.0)], egui::Stroke::new(1.5, t.text_strong)); + ui.painter().line_segment([bx.center_bottom() - vec2(0.0, 3.0), bx.right_top() + vec2(-2.0, 3.0)], egui::Stroke::new(1.5, t.text_strong)); + } + // Name (click: toggle on the selected text) and indicator colour. + let name_r = egui::Rect::from_min_max(row.min + vec2(42.0, 0.0), row.max - vec2(24.0, 0.0)); + let nr = ui.interact(name_r, ui.id().with(("cond_name", &c.name)), Sense::click()); + if nr.hovered() { + ui.painter().rect_filled(name_r, 0.0, t.hover); + } + ui.painter().text(name_r.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, &c.name, egui::FontId::proportional(12.5), t.text); + let chip = egui::Rect::from_min_size(egui::pos2(row.max.x - 20.0, row.min.y + 5.0), vec2(14.0, 12.0)); + ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2])); + if nr.clicked() { + if has_text { + let on = !applied.contains(&c.name); + if let Err(e) = app.run("condition.apply", json!({"name": c.name, "on": on})) { + app.status(format!("Conditional Text: {e}")); + } + } else { + app.status("Select text to apply a condition".to_string()); + } + } + nr.context_menu(|ui| { + if ui.button("Delete Condition").clicked() { + let _ = app.run("condition.delete", json!({"name": c.name})); + ui.close(); + } + }); + } + ui.add_space(6.0); + let id = egui::Id::new("new_condition_name"); + let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut name).hint_text("New condition name").desired_width(150.0)); + if ui.button("New").clicked() && !name.trim().is_empty() { + match app.run("condition.new", json!({"name": name.trim()})) { + Ok(_) => name.clear(), + Err(e) => app.status(format!("Conditional Text: {e}")), + } + } + }); + ui.data_mut(|d| d.insert_temp(id, name)); + if has_text && !applied.is_empty() && ui.button("Remove Conditions from Selection").clicked() { + let _ = app.run("condition.apply", json!({"name": null, "only": true})); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f8100f..904ac3f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panels and shared panel helpers. +pub mod conditions; pub mod glyphs; pub mod layers; pub mod pages; diff --git a/docs/parity.md b/docs/parity.md index d804fd3..7b33c46 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,13 +5,13 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 85%** over 275 features. +**Weighted breadth parity: 86%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 74 | 21 | 2 | 87% | -| P2 | 76 | 15 | 13 | 48 | 28% | +| P2 | 76 | 16 | 13 | 47 | 30% | | Area | Parity | |---|---| @@ -23,7 +23,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Fill, stroke, colour | 88% | | Effects & transparency | 62% | | Placing & links | 82% | -| Type & text frames | 88% | +| Type & text frames | 89% | | Typography | 88% | | Styles | 92% | | Tables | 88% | @@ -197,7 +197,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Type & text frames | Glyphs panel | P1 | D | | Type & text frames | Insert special chars / white space / breaks | P0 | D | | Type & text frames | Text variables | P1 | D | -| Type & text frames | Conditional text | P2 | M | +| Type & text frames | Conditional text | P2 | D | | Type & text frames | Fill with placeholder text | P1 | D | | Type & text frames | Create Outlines | P1 | D | | Type & text frames | Change Case | P0 | D |