F: Generate QR Code — vector codes for links, text, SMS, email, business cards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:53:04 -04:00
parent 902fd214c2
commit 059312b1d1
11 changed files with 278 additions and 3 deletions

7
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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.

View File

@ -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());

140
crates/engine/src/cmd/qr.rs Normal file
View File

@ -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<CommandSpec> {
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<String, String> {
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<Item, String> {
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<Value> {
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());
}
}

View File

@ -11,6 +11,7 @@ workspace = true
[dependencies]
kurbo = { workspace = true }
flo_curves = { workspace = true }
qrcodegen = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }

View File

@ -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;

63
crates/geom/src/qr.rs Normal file
View File

@ -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<PathData> {
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<i32> {
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());
}
}

View File

@ -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<Value, String> {
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"));

View File

@ -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<Result<Value,
"app.exportIdml" => 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();

View File

@ -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 |