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:
parent
aa54531472
commit
cd72171859
11
ROADMAP.md
11
ROADMAP.md
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue