T: conditional text — conditions, hide/show in layout and exports, panel, IDML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 23:02:15 -04:00
parent 059312b1d1
commit af0d241190
23 changed files with 416 additions and 6 deletions

View File

@ -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.

View File

@ -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));

View File

@ -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<String>,
}
/// 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,

View File

@ -77,8 +77,13 @@ pub struct SubstCtx {
pub xrefs: HashMap<usize, String>,
/// Anchored objects by story byte of their mark.
pub objects: HashMap<usize, ObjectSpec>,
/// Conditions currently hidden (conditional text with only these isn't shown).
pub hidden_conditions: Vec<String>,
}
/// 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;

View File

@ -448,6 +448,8 @@ attr_set! {
language: String = "English: USA".into(),
/// Additional OpenType features, e.g. `["onum", "ss01"]`.
otf_features: Vec<String> = Vec::new(),
/// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text).
conditions: Vec<String> = Vec::new(),
}
}

View File

@ -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)),

View File

@ -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<ColorGroup>,
/// Conditional text conditions; text whose conditions are all hidden isn't composed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditions: Vec<Condition>,
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,

View File

@ -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<CommandSpec> {
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()));
}
}

View File

@ -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());

View File

@ -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<Target> {
pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
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))

View File

@ -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

View File

@ -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!("<?AID 001b?>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::<Vec<_>>().join(" "));
}
if let Some(v) = &a.language {
el.set("AppliedLanguage", format!("$ID/{v}"));
}

View File

@ -98,6 +98,8 @@ struct Importer<'r> {
/// Unnamed swatches (gradients) added on first use.
hidden_swatches: HashMap<String, Swatch>,
color_groups: Vec<designcraft_doc::ColorGroup>,
conditions: Vec<designcraft_doc::Condition>,
condition_names: HashMap<String, String>,
styles: Styles,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
@ -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<u8> = e
.get("IndicatorColor")
.unwrap_or("")
.split_whitespace()
.filter_map(|x| x.parse::<f64>().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<String> = 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![],

View File

@ -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,

View File

@ -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();

View File

@ -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());
}

View File

@ -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,

View File

@ -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),
}

View File

@ -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);

View File

@ -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",
"<",

View File

@ -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<String> = 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}));
}
}

View File

@ -1,5 +1,6 @@
//! Panels and shared panel helpers.
pub mod conditions;
pub mod glyphs;
pub mod layers;
pub mod pages;

View File

@ -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.
<!-- SUMMARY -->
**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 |