O: Script Label, alt text, isolate/knockout groups, sort table rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 21:27:26 -04:00
parent aa54531472
commit cd72171859
8 changed files with 261 additions and 34 deletions

View File

@ -31,6 +31,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- 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.
@ -79,18 +80,18 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
## How far from full parity (estimate, 2026-10-02, updated)
**Breadth: ~79% weighted** (P0 core 99%, P1 73%, P2 15%) over the 275 features of the InDesign catalogue, scored
**Breadth: ~83% weighted** (P0 core 99%, P1 82%, P2 19%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 68%**.
lack some of InDesign's options or dialog details, so **overall parity including depth is about 70%**.
**Remaining work: about 430 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 110–150 hours with
**Remaining work: about 405 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 110–150 hours with
four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (3: PDF/X-4 output intent and validation, EPS place, remaining Preferences sections) | 12 h |
| Open P1 (39: type on a path, variable fonts, overprint preview, per-page sizes, pages per spread, image import options, gradient feather, nested line styles, Smooth/Erase tools, …) | 85 h |
| Open P2 (68: liquid/alternate layouts, books, buttons & forms, XML, conditional text, HTML, tagged PDF, …) | 190 h |
| Open P1 (30: variable fonts, nested line styles, transparency blend space, New Window, on-path caret, …) | 65 h |
| Open P2 (66: liquid/alternate layouts, books, buttons & forms, XML, conditional text, HTML, tagged PDF, …) | 185 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |

View File

@ -590,6 +590,18 @@ pub struct Item {
/// For items on a document page that override a parent-page item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overrides: Option<ItemId>,
/// Script Label (for scripts and agents).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
/// Alternative text (Object Export Options) for tagged PDF and EPUB.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub alt_text: String,
/// Groups: Isolate Blending (blend modes only within the group) and Knockout Group (the
/// group's objects don't show through each other).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub isolate: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub knockout: bool,
}
fn identity() -> Affine {
@ -621,6 +633,10 @@ impl Item {
object_style: crate::styles::NO_OBJECT_STYLE.into(),
content: Content::Unassigned,
overrides: None,
label: String::new(),
alt_text: String::new(),
isolate: false,
knockout: false,
}
}

View File

@ -422,6 +422,70 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"sides": s.prefs.polygon_sides, "starInset": (s.prefs.star_inset * 100.0).round()}))
}
),
cmd!(
"object.label",
"Script Label",
["Window", "Utilities", "Script Label"],
None,
"{label, ids?} — free text scripts and agents can find objects by",
has_selection,
|s, p| {
let label = str_param(p, "label").unwrap_or("").to_string();
set_flag(s, p, move |i| i.label = label.clone(), false)
}
),
cmd!(
"object.altText",
"Object Export Options",
["Object"],
None,
"{text, ids?} — alternative text for tagged PDF and EPUB",
has_selection,
|s, p| {
let text = str_param(p, "text").unwrap_or("").to_string();
set_flag(s, p, move |i| i.alt_text = text.clone(), false)
}
),
cmd!(
"object.transparencyGroup",
"Group Transparency",
[],
None,
"{isolate?: bool, knockout?: bool, ids?} — Effects panel: Isolate Blending, Knockout Group",
has_selection,
|s, p| {
let iso = p.get("isolate").and_then(Value::as_bool);
let ko = p.get("knockout").and_then(Value::as_bool);
set_flag(
s,
p,
move |i| {
if let Some(v) = iso {
i.isolate = v;
}
if let Some(v) = ko {
i.knockout = v;
}
},
false,
)
}
),
cmd!(query "object.findByLabel", "Find by Script Label", [], None, "{label} → ids of objects whose label is `label`", has_doc, |s, p| {
let label = str_param(p, "label").unwrap_or("");
let d = &s.doc()?.doc;
let mut ids = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for it in &sp.items {
it.walk(&mut |x| {
if x.label == label {
ids.push(x.id.0);
}
});
}
}
Ok(json!({"ids": ids}))
}),
cmd!("object.setLayer", "Move to Layer", [], None, "{layer, ids?}", has_selection, |s, p| {
let l = designcraft_doc::LayerId(p.get("layer").and_then(Value::as_u64).unwrap_or(0));
set_flag(s, p, move |i| i.layer = l, false)
@ -1408,3 +1472,26 @@ mod again_tests {
assert_eq!(ys[1] - ys[0], 60.0, "{ys:?}");
}
}
#[cfg(test)]
mod label_tests {
use super::*;
#[test]
fn script_label_alt_text_and_group_transparency() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.label", &json!({"label": "price", "ids": [a, b]})).unwrap();
s.execute("object.altText", &json!({"text": "A red square", "ids": [a]})).unwrap();
assert_eq!(s.execute("object.findByLabel", &json!({"label": "price"})).unwrap()["ids"], json!([a, b]));
assert_eq!(s.execute("object.findByLabel", &json!({"label": "nope"})).unwrap()["ids"], json!([]));
assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().alt_text, "A red square");
s.execute("object.transparencyGroup", &json!({"knockout": true, "ids": [b]})).unwrap();
let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone();
assert!(it.knockout && !it.isolate);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.item(ItemId(b)).unwrap().knockout);
}
}

View File

@ -112,6 +112,15 @@ fn table_specs() -> Vec<CommandSpec> {
cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols),
cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table),
cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge),
cmd!(
"table.sortRows",
"Sort",
["Table"],
None,
"{column?: 0-based (default: the target cell's), descending?: bool} — body rows by that column's text (numbers numerically)",
in_table,
sort_rows
),
cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge),
cmd!(
"table.setCell",
@ -1054,3 +1063,70 @@ mod style_tests {
assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err());
}
}
fn sort_rows(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.sortRows")?;
let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize);
let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false);
edit_table(s, &g, "table.sortRows", |t| {
let (nr, nc) = (t.nrows(), t.ncols());
if col >= nc {
return Err(bad("table.sortRows", format!("no column {col}")));
}
if t.cells.iter().any(|c| c.row_span > 1) {
return Err(bad("table.sortRows", "unmerge cells that span rows first"));
}
let (h, f) = (t.header_rows(), t.footer_rows());
let body: Vec<usize> = (h..nr - f).collect();
let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default();
let mut order = body.clone();
order.sort_by(|a, b| {
let (ka, kb) = (key(*a), key(*b));
let o = match (ka.replace(',', "").parse::<f64>(), kb.replace(',', "").parse::<f64>()) {
(Ok(x), Ok(y)) => x.total_cmp(&y),
_ => ka.to_lowercase().cmp(&kb.to_lowercase()),
};
if desc { o.reverse() } else { o }
});
let rows = t.rows.clone();
let cells = t.cells.clone();
for (k, src) in order.iter().enumerate() {
let dst = body[k];
t.rows[dst] = rows[*src].clone();
for c in 0..nc {
t.cells[dst * nc + c] = cells[src * nc + c].clone();
}
}
Ok(json!({"sorted": body.len()}))
})
}
#[cfg(test)]
mod sort_tests {
use serde_json::json;
use crate::Session;
#[test]
fn sort_body_rows_by_a_column() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")];
for (r, (a, b)) in cells.iter().enumerate() {
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap();
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap();
}
let col = |s: &Session, c: usize| -> Vec<String> {
let d = &s.doc().unwrap().doc;
let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
(0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect()
};
s.execute("table.sortRows", &json!({"column": 0})).unwrap();
assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive");
s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap();
assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value");
}
}

View File

@ -409,6 +409,17 @@ impl Exporter<'_> {
n
}
/// Isolate Blending / Knockout groups: an isolated transparency group (knockout is drawn
/// isolated too).
fn push_isolation(s: &mut Surface, it: &Item) -> usize {
if it.isolate || it.knockout {
s.push_isolated();
1
} else {
0
}
}
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
let gf = &it.effects.gradient_feather;
if !gf.on || it.hidden || it.nonprinting {
@ -519,7 +530,7 @@ impl Exporter<'_> {
return;
}
if !it.children().is_empty() {
let pushes = Self::push_group(s, it.opacity, it.blend);
let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it);
for c in it.children() {
self.item(s, c, xf, page_name);
}

View File

@ -397,13 +397,30 @@ impl Renderer {
}
if !it.children().is_empty() {
// Groups: children carry their own transforms relative to the group.
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
// Isolate Blending / Knockout: the group is its own layer (blend modes stop at it).
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
for c in it.children() {
self.draw_item(ctx, f, c, xf, page_name);
if it.knockout {
// Knockout: each object first clears its area of those behind it, so it composites
// with the group's backdrop rather than with its siblings.
for c in it.children() {
if !c.hidden {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.set_transform(f.view * xf * c.xf);
ctx.set_paint(peniko::Color::BLACK);
ctx.fill_path(&c.path.to_bezpath());
ctx.pop_layer();
}
self.draw_item(ctx, f, c, xf, page_name);
}
} else {
for c in it.children() {
self.draw_item(ctx, f, c, xf, page_name);
}
}
if layered {
ctx.pop_layer();
@ -906,6 +923,37 @@ mod tests {
assert!(on[2] < 60 && on[0] > 180, "magenta over yellow → red: {on:?}");
}
#[test]
fn knockout_group_hides_its_own_objects() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut kids = Vec::new();
for (sw, x) in [("C=0 M=0 Y=100 K=0", 100.0), ("C=0 M=100 Y=0 K=0", 150.0)] {
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)));
it.fill = Fill::swatch(sw);
it.opacity = 0.5;
kids.push(std::sync::Arc::new(it));
}
let gid = designcraft_doc::ItemId(d.alloc());
let mut g = Item::new(gid, lid, designcraft_doc::Shape::Group, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)));
g.content = designcraft_doc::Content::Group { items: kids };
d.insert_item(SpreadRef::Doc(0), g, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let overlap = |d: &Document, r: &mut Renderer| r.render_page(d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap().pixel(175, 150);
let plain = overlap(&d, &mut r);
d.item_mut(gid).unwrap().knockout = true;
let ko = overlap(&d, &mut r);
let magenta_only = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap().pixel(225, 150);
assert_ne!(plain, ko, "{plain:?} {ko:?}");
for c in 0..3 {
assert!((ko[c] as i32 - magenta_only[c] as i32).abs() <= 3, "the overlap shows magenta alone: {ko:?} vs {magenta_only:?}");
}
}
#[test]
fn appearance_of_black_on_screen() {
let mut d = Document::new(&NewDocument::default());

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: 82%** over 275 features.
**Weighted breadth parity: 83%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 67 | 26 | 4 | 82% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| P2 | 76 | 10 | 9 | 57 | 19% |
| Area | Parity |
|---|---|
@ -21,19 +21,19 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Frames, shapes & paths | 88% |
| Transform | 93% |
| Fill, stroke, colour | 86% |
| Effects & transparency | 50% |
| Effects & transparency | 54% |
| Placing & links | 81% |
| Type & text frames | 88% |
| Typography | 85% |
| Styles | 92% |
| Tables | 81% |
| Tables | 85% |
| Long documents | 75% |
| Interactivity & digital | 30% |
| Output & production | 68% |
| XML & automation | 39% |
| XML & automation | 50% |
| View & navigation | 82% |
| Undo, history, saving | 100% |
| Accessibility | 20% |
| Accessibility | 30% |
Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print).
@ -159,7 +159,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Fill, stroke, colour | Eyedropper | P1 | D |
| Fill, stroke, colour | Appearance of Black prefs | P1 | P |
| Effects & transparency | Opacity & blend modes | P0 | D |
| Effects & transparency | Isolate Blending / Knockout Group | P2 | M |
| Effects & transparency | Isolate Blending / Knockout Group | P2 | P |
| Effects & transparency | Drop Shadow | P1 | D |
| Effects & transparency | Inner Shadow, Outer Glow, Inner Glow, Bevel, Satin | P2 | P |
| Effects & transparency | Basic/Directional/Gradient Feather | P2 | P |
@ -257,7 +257,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Tables | Table panel | P1 | D |
| Tables | Overset cells | P1 | D |
| Tables | Tables spanning frames | P1 | D |
| Tables | Sort table rows | P2 | M |
| Tables | Sort table rows | P2 | D |
| Tables | Excel import | P2 | M |
| Tables | Drag to move rows/columns | P2 | M |
| Long documents | Table of contents | P1 | D |
@ -292,7 +292,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| XML & automation | Import/Export XML | P2 | M |
| XML & automation | Data Merge | P2 | D |
| XML & automation | Scripts panel | P2 | P |
| XML & automation | Script Label | P2 | M |
| XML & automation | Script Label | P2 | D |
| XML & automation | Content Collector/Placer | P2 | M |
| XML & automation | Snippets | P1 | D |
| XML & automation | Object Library | P2 | M |
@ -313,6 +313,6 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Undo, history, saving | Revert | P0 | D |
| Undo, history, saving | Save / Save As / Save a Copy | P0 | D |
| Accessibility | Tagged PDF export | P2 | M |
| Accessibility | Alt text | P2 | M |
| Accessibility | Alt text | P2 | P |
| Accessibility | Keyboard navigation of UI | P1 | P |
| Accessibility | High contrast | P2 | M |

View File

@ -8,10 +8,7 @@ use std::path::Path;
/// The `version = "…"` value inside `[workspace.package]`.
pub fn read(manifest: &str) -> Result<String, String> {
let (_, line) = find_line(manifest)?;
let v = line
.split_once('=')
.map(|(_, v)| v.trim().trim_matches('"').to_string())
.unwrap_or_default();
let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default();
if v.is_empty() {
return Err("empty [workspace.package] version".into());
}
@ -48,20 +45,11 @@ pub fn validate(v: &str) -> Result<(), String> {
None => (v, None),
};
let nums: Vec<&str> = core.split('.').collect();
if nums.len() != 3
|| nums.iter().any(|n| {
n.is_empty()
|| !n.bytes().all(|b| b.is_ascii_digit())
|| (n.len() > 1 && n.starts_with('0'))
})
{
if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) {
return bad();
}
if let Some(p) = pre
&& (p.is_empty()
|| p.split('.').any(|id| {
id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}))
&& (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')))
{
return bad();
}