mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 13:16:56 -06:00
O: Print dialog (file.print via the system spooler); shortcuts open dialogs, UI commands win ties
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fcef655ba8
commit
7d4f00d551
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- 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`).
|
||||
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
|
||||
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles and their IDML mapping are still to come.
|
||||
- Table and cell styles: cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. IDML mapping is still to come.
|
||||
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
|
||||
|
||||
@@ -23,6 +23,7 @@ mod paths;
|
||||
mod place_text;
|
||||
pub mod preflight;
|
||||
mod prefs;
|
||||
mod print;
|
||||
pub mod spelling;
|
||||
mod style;
|
||||
pub mod table;
|
||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(anchors::specs());
|
||||
v.extend(paths::specs());
|
||||
v.extend(package::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(style::specs());
|
||||
v.extend(table::specs());
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "file.printers", "Printers", [], None, "{} → {printers: [names], default}", super::always, |_, _| Ok(printers())),
|
||||
cmd!(
|
||||
noundo "file.print",
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn printers() -> Value {
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
|
||||
};
|
||||
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
||||
// "system default destination: NAME"
|
||||
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
|
||||
json!({"printers": names, "default": default})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn printers() -> Value {
|
||||
json!({"printers": [], "default": null})
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
|
||||
let printer = str_param(p, "printer").map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
|
||||
let mut cmd: Vec<String> = vec!["lpr".into()];
|
||||
if let Some(pr) = &printer {
|
||||
cmd.extend(["-P".into(), pr.clone()]);
|
||||
}
|
||||
if copies > 1 {
|
||||
cmd.extend(["-#".into(), copies.to_string()]);
|
||||
}
|
||||
cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
|
||||
if dry {
|
||||
return Ok(out);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = pdf;
|
||||
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?;
|
||||
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
|
||||
if !st.status.success() {
|
||||
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn print_builds_the_spool_command() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
|
||||
assert!(cmd.last().unwrap().ends_with(".pdf"));
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,65 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Print, with the printers the system knows.
|
||||
pub fn open_print(app: &mut DesignApp) {
|
||||
let p = app.session.execute("file.printers", &json!({})).unwrap_or_default();
|
||||
let printer = p["default"].as_str().map(str::to_string).or_else(|| p["printers"][0].as_str().map(str::to_string)).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"print",
|
||||
json!({"printers": p["printers"], "printer": printer, "copies": 1, "range": "all", "pages": "1", "spreads": false, "marks": false, "bleed": false}),
|
||||
));
|
||||
}
|
||||
|
||||
fn print_dialog(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let printers: Vec<String> = d
|
||||
.fields
|
||||
.get("printers")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
egui::Grid::new("print").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("Printer:");
|
||||
let cur = d.s("printer");
|
||||
if printers.is_empty() {
|
||||
ui.label(egui::RichText::new("System default").italics());
|
||||
} else {
|
||||
egui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
|
||||
for p in &printers {
|
||||
if ui.selectable_label(*p == cur, p).clicked() {
|
||||
d.fields.insert("printer".into(), json!(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Copies:");
|
||||
text_field(ui, d, "copies", 50.0);
|
||||
ui.end_row();
|
||||
ui.label("Pages:");
|
||||
ui.vertical(|ui| {
|
||||
let range = d.s("range");
|
||||
if ui.radio(range == "all", "All").clicked() {
|
||||
d.fields.insert("range".into(), json!("all"));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(range == "range", "Range:").clicked() {
|
||||
d.fields.insert("range".into(), json!("range"));
|
||||
}
|
||||
text_field(ui, d, "pages", 120.0);
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.vertical(|ui| {
|
||||
check(ui, d, "spreads", "Spreads");
|
||||
check(ui, d, "marks", "Printer's Marks");
|
||||
check(ui, d, "bleed", "Include Bleed");
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// File › Document Setup, filled from the document.
|
||||
pub fn open_document_setup(app: &mut DesignApp) {
|
||||
let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return };
|
||||
@@ -480,6 +539,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"findFont" => "Find/Replace Font",
|
||||
"polygonSettings" => "Polygon Settings",
|
||||
"preferences" => "Preferences",
|
||||
"print" => "Print",
|
||||
"keyboardShortcuts" => "Keyboard Shortcuts",
|
||||
"colorPicker" => "Color Picker",
|
||||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||||
@@ -618,6 +678,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"findFont" => find_font(app, ui, &mut d),
|
||||
"colorPicker" => color_picker(ui, &mut d),
|
||||
"preferences" => preferences(ui, &mut d),
|
||||
"print" => print_dialog(ui, &mut d),
|
||||
"keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d),
|
||||
"polygonSettings" => {
|
||||
egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
@@ -779,6 +840,17 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
let hex = d.s("hex");
|
||||
app.run("object.color", json!({"color": hex, "target": d.s("target")}))
|
||||
}
|
||||
"print" => {
|
||||
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
|
||||
let printer = d.s("printer");
|
||||
let r = app.run(
|
||||
"file.print",
|
||||
json!({"printer": if printer.is_empty() { Value::Null } else { json!(printer) }, "copies": d.n("copies").unwrap_or(1.0).max(1.0) as u64,
|
||||
"pages": pages, "spreads": d.b("spreads"), "marks": d.b("marks"), "bleed": d.b("bleed")}),
|
||||
)?;
|
||||
app.status(format!("Sent {} page(s) to {}", r["pages"], r["printer"].as_str().unwrap_or("the default printer")));
|
||||
Ok(r)
|
||||
}
|
||||
"preferences" => {
|
||||
app.run(
|
||||
"prefs.set",
|
||||
|
||||
@@ -919,12 +919,13 @@ pub fn shortcut_of(app: &DesignApp, id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command with a shortcut in effect: (id, shortcut).
|
||||
/// Every command with a shortcut in effect: (id, shortcut). UI commands come first: where one
|
||||
/// shares keys with an engine command (⌘N, ⌘O…) it's the one with the dialog.
|
||||
pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> {
|
||||
designcraft_engine::command_specs()
|
||||
UI_COMMANDS
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.chain(UI_COMMANDS.iter().map(|c| c.0))
|
||||
.map(|c| c.0)
|
||||
.chain(designcraft_engine::command_specs().iter().map(|c| c.id))
|
||||
.filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), s)))
|
||||
.collect()
|
||||
}
|
||||
@@ -1088,6 +1089,14 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
crate::dialogs::open_document_setup(app);
|
||||
return;
|
||||
}
|
||||
if params.is_null() && id == "file.print" {
|
||||
crate::dialogs::open_print(app);
|
||||
return;
|
||||
}
|
||||
if params.is_null() && id == "object.textFrameOptions" {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
|
||||
return;
|
||||
}
|
||||
if params.is_null()
|
||||
&& ui_label(id).is_none()
|
||||
&& let Some(c) = designcraft_engine::find_command(id)
|
||||
@@ -1204,7 +1213,8 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
|
||||
continue; // The native menu handles it.
|
||||
}
|
||||
let _ = app.run(&id, json!({}));
|
||||
// Like choosing the menu item: "…" commands open their dialog.
|
||||
activate(app, &id, &Value::Null);
|
||||
continue;
|
||||
}
|
||||
// Tool shortcuts (single keys, Shift+key).
|
||||
@@ -1335,6 +1345,9 @@ mod tests {
|
||||
assert_eq!(shortcut_of(&app, "app.palette"), None);
|
||||
assert!(run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Nope"})).unwrap().is_err());
|
||||
run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
|
||||
// ⌘N is the New Document dialog, not a bare file.new.
|
||||
let first_cmd_n = effective_shortcuts(&app).into_iter().find(|(_, s)| s == "Cmd+N").map(|(i, _)| i);
|
||||
assert_eq!(first_cmd_n.as_deref(), Some("app.newDocumentDialog"));
|
||||
assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
|
||||
assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K");
|
||||
if cfg!(target_os = "macos") {
|
||||
|
||||
+4
-4
@@ -5,12 +5,12 @@ 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: 78%** over 275 features.
|
||||
**Weighted breadth parity: 79%** over 275 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 57 | 25 | 15 | 72% |
|
||||
| P1 | 97 | 58 | 25 | 14 | 73% |
|
||||
| P2 | 76 | 8 | 7 | 61 | 15% |
|
||||
|
||||
| Area | Parity |
|
||||
@@ -29,7 +29,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
||||
| Tables | 81% |
|
||||
| Long documents | 58% |
|
||||
| Interactivity & digital | 30% |
|
||||
| Output & production | 58% |
|
||||
| Output & production | 68% |
|
||||
| XML & automation | 39% |
|
||||
| View & navigation | 75% |
|
||||
| Undo, history, saving | 100% |
|
||||
@@ -278,7 +278,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
||||
| Interactivity & digital | HTML export | P2 | M |
|
||||
| Output & production | Export to PDF (print) | P0 | P |
|
||||
| Output & production | Interactive PDF | P2 | P |
|
||||
| Output & production | Print dialog | P1 | M |
|
||||
| Output & production | Print dialog | P1 | D |
|
||||
| Output & production | Print booklet | P2 | M |
|
||||
| Output & production | Package | P1 | D |
|
||||
| Output & production | Preflight | P1 | D |
|
||||
|
||||
Reference in New Issue
Block a user