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:
parent
059312b1d1
commit
af0d241190
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"<",
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Panels and shared panel helpers.
|
||||
|
||||
pub mod conditions;
|
||||
pub mod glyphs;
|
||||
pub mod layers;
|
||||
pub mod pages;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue