From 059312b1d1bf37e70c7253c812868b3a018e4249 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:53:04 -0400 Subject: [PATCH] =?UTF-8?q?F:=20Generate=20QR=20Code=20=E2=80=94=20vector?= =?UTF-8?q?=20codes=20for=20links,=20text,=20SMS,=20email,=20business=20ca?= =?UTF-8?q?rds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 ++ Cargo.toml | 2 + ROADMAP.md | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/qr.rs | 140 ++++++++++++++++++++++++++++++++++ crates/geom/Cargo.toml | 1 + crates/geom/src/lib.rs | 1 + crates/geom/src/qr.rs | 63 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 52 +++++++++++++ crates/ui-egui/src/menus.rs | 6 ++ docs/parity.md | 6 +- 11 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/qr.rs create mode 100644 crates/geom/src/qr.rs diff --git a/Cargo.lock b/Cargo.lock index 04d2403..f85a1f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,6 +986,7 @@ dependencies = [ "flo_curves", "kurbo", "proptest", + "qrcodegen", "serde", "serde_json", "thiserror 2.0.21", @@ -3405,6 +3406,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quick-error" version = "1.2.3" diff --git a/Cargo.toml b/Cargo.toml index 1d93121..9fc45b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ krilla-svg = "0.8" psd = "0.3" # Boolean operations on Bézier paths (Pathfinder). flo_curves = "0.8" +# QR codes (Object › Generate QR Code); MIT. +qrcodegen = "1.8" krilla = { version = "0.8", features = ["pdf"] } hayro = "0.7" quick-xml = "0.38" diff --git a/ROADMAP.md b/ROADMAP.md index c893b8b..eb4bad1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,6 +44,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. - 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). - 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. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 556efe9..a012a95 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -27,6 +27,7 @@ mod place_text; pub mod preflight; mod prefs; mod print; +mod qr; mod select; pub mod spelling; mod style; @@ -137,6 +138,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(captions::specs()); v.extend(lists::specs()); v.extend(path_type::specs()); + v.extend(qr::specs()); v.extend(select::specs()); v.extend(package::specs()); v.extend(print::specs()); diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs new file mode 100644 index 0000000..77b0b70 --- /dev/null +++ b/crates/engine/src/cmd/qr.rs @@ -0,0 +1,140 @@ +//! Object › Generate QR Code: a vector QR code (a compound path) as a new object, inside the +//! selected frame, or replacing a selected code (Edit QR Code). + +use designcraft_doc::{Content, Fill, Item, ItemId, Selection, Shape}; +use designcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, rect_param, spread_param, str_param}; +use crate::{Result, Session}; + +/// Alt text prefix that marks a generated code (and carries its content for editing). +const MARK: &str = "QR code: "; + +pub fn specs() -> Vec { + vec![cmd!( + "object.qrCode", + "Generate QR Code…", + ["Object"], + None, + "{type?: url|text|sms|email|vcard (default url), content (url/text), number?, message?, to?, subject?, body?, name?, phone?, email?, org?, url?, color? (swatch, [Black]), rect? (new object; else into the selected frame, or re-encode a selected code), spread?} → {id, content}", + has_doc, + qr_code + )] +} + +/// The string a scanner reads, per InDesign's QR types. +fn encode(p: &Value) -> std::result::Result { + let g = |k: &str| str_param(p, k).unwrap_or("").trim().to_string(); + let esc = |s: String| s.replace('\\', r"\\").replace(';', r"\;").replace(':', r"\:").replace(',', r"\,"); + let out = match str_param(p, "type").unwrap_or("url") { + "url" | "text" => g("content"), + "sms" => format!("SMSTO:{}:{}", g("number"), g("message")), + "email" => format!("MATMSG:TO:{};SUB:{};BODY:{};;", esc(g("to")), esc(g("subject")), esc(g("body"))), + "vcard" => { + let mut v = format!("MECARD:N:{};", esc(g("name"))); + for (k, tag) in [("org", "ORG"), ("phone", "TEL"), ("email", "EMAIL"), ("url", "URL")] { + if !g(k).is_empty() { + v.push_str(&format!("{tag}:{};", esc(g(k)))); + } + } + v + ";" + } + t => return Err(format!("unknown type `{t}` (url, text, sms, email, vcard)")), + }; + if out.is_empty() { Err("nothing to encode".into()) } else { Ok(out) } +} + +fn qr_item(id: ItemId, layer: designcraft_doc::LayerId, text: &str, r: Rect, color: &str) -> std::result::Result { + let path = designcraft_geom::qr::qr_path(text, r).ok_or("too much content for a QR code")?; + let mut it = Item::new(id, layer, Shape::Path, path); + it.fill = Fill::swatch(color); + it.alt_text = format!("{MARK}{text}"); + Ok(it) +} + +fn qr_code(s: &mut Session, p: &Value) -> Result { + const ID: &str = "object.qrCode"; + let text = encode(p).map_err(|e| bad(ID, e))?; + let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string(); + let rect = rect_param(p, "rect"); + let sr = spread_param(p, "spread"); + let st = s.doc()?; + let lid = st.active_layer; + let sel = st.selection.items.clone(); + s.edit(|d, selection| { + if d.swatch(&color).is_none() { + return Err(bad(ID, format!("no swatch `{color}`"))); + } + let target = if rect.is_some() { None } else { sel.first().copied() }; + let id = match (rect, target) { + (Some(r), _) => { + let id = ItemId(d.alloc()); + d.insert_item(sr, qr_item(id, lid, &text, r, &color).map_err(|e| bad(ID, e))?, None)?; + id + } + (None, Some(t)) => { + let it = d.item(t).ok_or_else(|| bad(ID, "no such object"))?.clone(); + if it.alt_text.starts_with(MARK) { + // Edit QR Code: same place and size. + let b = it.path.bounds().unwrap_or(Rect::ZERO); + let side = b.width().max(b.height()); + let n = designcraft_geom::qr::qr_size(it.alt_text.trim_start_matches(MARK)).unwrap_or(21); + let quiet = side / (n + 4) as f64 * 2.0; + let r = Rect::from_center_size(b.center(), (side + 2.0 * quiet, side + 2.0 * quiet)); + let fresh = qr_item(t, it.layer, &text, r, &color).map_err(|e| bad(ID, e))?; + let it = d.item_mut(t).expect("found"); + it.path = fresh.path; + it.fill = fresh.fill; + it.alt_text = fresh.alt_text; + t + } else { + // Into the frame: the code fills it, as content. + if !matches!(it.content, Content::Unassigned | Content::Group { .. }) || it.shape == Shape::Group { + return Err(bad(ID, "select an empty frame, a QR code, or give `rect`")); + } + let id = ItemId(d.alloc()); + let code = qr_item(id, it.layer, &text, it.inner_bounds(), &color).map_err(|e| bad(ID, e))?; + let f = d.item_mut(t).expect("found"); + f.content = Content::Group { items: vec![std::sync::Arc::new(code)] }; + t + } + } + (None, None) => return Err(bad(ID, "give `rect` or select a frame")), + }; + *selection = Selection::items(vec![id]); + Ok(json!({"id": id.0, "content": text})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn generate_and_edit_qr_codes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("object.qrCode", &json!({"content": "https://example.com", "rect": [72, 72, 216, 216]})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let item = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().clone(); + let a = item(&s); + assert!(a.path.subpaths.len() > 20 && a.alt_text.contains("example.com")); + let b0 = a.bounds(); + assert!(b0.x0 >= 72.0 && b0.x1 <= 216.0); + // Edit QR Code: new content, same place. + let r = s.execute("object.qrCode", &json!({"type": "email", "to": "a@b.c", "subject": "Hi"})).unwrap(); + assert_eq!(r["content"], "MATMSG:TO:a@b.c;SUB:Hi;BODY:;;"); + let b1 = item(&s).bounds(); + assert!((b1.center() - b0.center()).hypot() < 1.0, "{b0:?} → {b1:?}"); + assert!((b1.width() - b0.width()).abs() < 4.0); + // Into an empty frame. + let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.qrCode", &json!({"type": "vcard", "name": "Ada", "phone": "+44 1"})).unwrap(); + let fr = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + assert!(fr.has_nested_items()); + assert!(s.execute("object.qrCode", &json!({"type": "fax", "content": "x", "rect": [0, 0, 9, 9]})).is_err()); + } +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml index 9ed510b..30e4570 100644 --- a/crates/geom/Cargo.toml +++ b/crates/geom/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] kurbo = { workspace = true } flo_curves = { workspace = true } +qrcodegen = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 02802a6..f95ed03 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -14,6 +14,7 @@ pub mod freehand; pub mod hit; pub mod path; pub mod pathfinder; +pub mod qr; pub mod shapes; pub mod snap; pub mod units; diff --git a/crates/geom/src/qr.rs b/crates/geom/src/qr.rs new file mode 100644 index 0000000..f4d137d --- /dev/null +++ b/crates/geom/src/qr.rs @@ -0,0 +1,63 @@ +//! QR codes as vector paths (Object › Generate QR Code). + +use kurbo::Rect; + +use crate::Point; +use crate::path::{PathData, SubPath}; + +/// Error correction as InDesign uses for print: medium (15%). +const ECC: qrcodegen::QrCodeEcc = qrcodegen::QrCodeEcc::Medium; + +/// The dark modules of a QR code for `text`, fitted into `r` (square, centred; a quiet zone of +/// two modules each side), merged into one rectangle per horizontal run. `None` if the text is +/// too long for any QR version. +pub fn qr_path(text: &str, r: Rect) -> Option { + let qr = qrcodegen::QrCode::encode_text(text, ECC).ok()?; + let n = qr.size(); + let quiet = 2; + let side = r.width().min(r.height()); + let m = side / (n + 2 * quiet) as f64; + let origin = Point::new(r.center().x - side / 2.0 + quiet as f64 * m, r.center().y - side / 2.0 + quiet as f64 * m); + let mut subpaths = Vec::new(); + for y in 0..n { + let mut x = 0; + while x < n { + if !qr.get_module(x, y) { + x += 1; + continue; + } + let x0 = x; + while x < n && qr.get_module(x, y) { + x += 1; + } + let (a, b) = (origin.x + x0 as f64 * m, origin.x + x as f64 * m); + let (t, u) = (origin.y + y as f64 * m, origin.y + (y + 1) as f64 * m); + subpaths.push(SubPath::polyline(&[Point::new(a, t), Point::new(b, t), Point::new(b, u), Point::new(a, u)], true)); + } + } + Some(PathData::new(subpaths)) +} + +/// The modules per side for `text` (the version's size), if it can be encoded. +pub fn qr_size(text: &str) -> Option { + qrcodegen::QrCode::encode_text(text, ECC).ok().map(|q| q.size()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qr_modules_fill_the_square() { + let p = qr_path("https://example.com", Rect::new(0.0, 0.0, 100.0, 100.0)).unwrap(); + let n = qr_size("https://example.com").unwrap(); + assert!(n >= 21 && (n - 17) % 4 == 0, "a QR version size: {n}"); + let b = p.bounds().unwrap(); + let m = 100.0 / (n + 4) as f64; + // Finder patterns put dark modules at the corners of the symbol. + assert!((b.x0 - 2.0 * m).abs() < 1e-9 && (b.y0 - 2.0 * m).abs() < 1e-9); + assert!((b.x1 - (100.0 - 2.0 * m)).abs() < 1e-9); + assert!(p.subpaths.len() > 20); + assert!(qr_path(&"x".repeat(5000), Rect::new(0.0, 0.0, 10.0, 10.0)).is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 322d4c6..9c07f8c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ impl Dialog { "marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false}) } "goToPage" => json!({"page": "1"}), + "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), @@ -588,6 +589,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => "New Cross-Reference", "findFont" => "Find/Replace Font", "polygonSettings" => "Polygon Settings", + "qrCode" => "Generate QR Code", "preferences" => "Preferences", "print" => "Print", "pdfImport" => "Place PDF", @@ -738,6 +740,43 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), + "qrCode" => { + let cur = d.s("type"); + ui.horizontal(|ui| { + ui.label("Type:"); + let label = |v: &str| match v { + "text" => "Plain Text", + "sms" => "Text Message", + "email" => "Email", + "vcard" => "Business Card", + _ => "Web Hyperlink", + }; + egui::ComboBox::from_id_salt("qr_type").selected_text(label(&cur)).width(160.0).show_ui(ui, |ui| { + for v in ["url", "text", "sms", "email", "vcard"] { + if ui.selectable_label(cur == v, label(v)).clicked() { + d.fields.insert("type".into(), json!(v)); + } + } + }); + }); + let fields: &[(&str, &str)] = match cur.as_str() { + "sms" => &[("number", "Cell Number:"), ("message", "Message:")], + "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], + "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], + "text" => &[("content", "Text:")], + _ => &[("content", "URL:")], + }; + egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in fields { + ui.label(*l); + text_field(ui, &mut d, k, 260.0); + ui.end_row(); + } + ui.label("Color:"); + text_field(ui, &mut d, "color", 160.0); + ui.end_row(); + }); + } "polygonSettings" => { egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { ui.label("Number of Sides:"); @@ -983,6 +1022,19 @@ pub fn confirm(app: &mut DesignApp) -> Result { doc["advancedType"] = adv; app.run("document.preferences", doc) } + "qrCode" => { + let mut p = Value::Object(d.fields.clone()); + // Nothing selected: a 2-inch code at the top left of the page in view. + if app.session.active().is_some_and(|s| s.selection.items.is_empty()) { + let st = app.session.active().expect("doc"); + let abs = crate::canvas::current_page(app).unwrap_or(0); + let (si, pi) = st.doc.page_loc(abs).unwrap_or((0, 0)); + let x = st.doc.spreads.get(si).and_then(|sp| sp.pages.get(pi)).map_or(0.0, |pg| pg.x); + p["rect"] = json!([x + 36.0, 36.0, x + 180.0, 180.0]); + p["spread"] = json!(si); + } + app.run("object.qrCode", p) + } "polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})), "findFont" => { let (f, st) = (d.s("family"), d.s("style")); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2f77ddd..0e291e7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -19,6 +19,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), ("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3"), ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), + ("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), @@ -366,6 +367,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", "<", + "ui:app.qrCode", ">Captions", "cmd:object.caption", "<", @@ -531,6 +533,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_idml(app, p), "app.exportEpub" => export_bytes(app, p, "epub", "file.exportEpub"), "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), + "app.qrCode" => { + app.ui.dialog = Some(crate::dialogs::Dialog::new("qrCode", json!({}))); + Ok(Value::Null) + } "app.exportText" => export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); diff --git a/docs/parity.md b/docs/parity.md index 9800db2..d804fd3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,14 +11,14 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 74 | 21 | 2 | 87% | -| P2 | 76 | 14 | 13 | 49 | 27% | +| P2 | 76 | 15 | 13 | 48 | 28% | | Area | Parity | |---|---| | Application shell & workspace | 80% | | Documents, pages, spreads | 85% | | Layers | 95% | -| Frames, shapes & paths | 91% | +| Frames, shapes & paths | 93% | | Transform | 100% | | Fill, stroke, colour | 88% | | Effects & transparency | 62% | @@ -116,7 +116,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Frames, shapes & paths | Gridify while drawing | P2 | M | | Frames, shapes & paths | Gap tool | P2 | M | | Frames, shapes & paths | Live Distribute | P2 | M | -| Frames, shapes & paths | Generate QR Code | P2 | M | +| Frames, shapes & paths | Generate QR Code | P2 | D | | Frames, shapes & paths | Placeholder text | P1 | D | | Frames, shapes & paths | Arrange | P0 | D | | Frames, shapes & paths | Group/Ungroup | P0 | D |