collective

Browse / github.com/storytold/designcraft / patch

QR codes: styles, logo, colours and background; a code is a frame; edit an existing code

Generate QR Code: styles, logo, colours and background, and editing an existing code **What's missing:** Generate QR Code makes a plain black square-module code and nothing else: - **No style options.** Error correction can't be chosen, and module shapes (rounded, dots, connected) or rounded or circular corner patterns aren't available. - **No room for a logo.** There is no clear area in the middle for one. - **No background and no colour per part.** The quiet zone is transparent, and the code is one compound path in one colour. - **The usual tools don't fit the code well.** Fill, stroke and the Gradient Swatch tool act on one object, so the corner patterns can't be coloured

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1821, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+1705 −84 in 13 files
Review
pending
Shared
2026-10-28
Id
ffa8ebe1fd7c209eeca1f5ba

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml ROADMAP.md crates/doc/src/item.rs crates/engine/src/cmd/mod.rs crates/engine/src/cmd/qr.rs crates/geom/Cargo.toml crates/geom/src/qr.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/de.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/properties.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -845,6 +845,15 @@ dependencies = [  "thiserror 2.0.21", ] +[[package]]+name = "codepage-437"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535"+dependencies = [+ "csv",+]+ [[package]] name = "codespan-reporting" version = "0.13.1"@@ -1237,6 +1246,27 @@ dependencies = [  "typenum", ] +[[package]]+name = "csv"+version = "1.4.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"+dependencies = [+ "csv-core",+ "itoa",+ "ryu",+ "serde_core",+]++[[package]]+name = "csv-core"+version = "0.1.13"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"+dependencies = [+ "memchr",+]+ [[package]] name = "cursor-icon" version = "1.2.0"@@ -1458,6 +1488,8 @@ dependencies = [  "kurbo",  "proptest",  "qrcodegen",+ "rqrr",+ "rxing",  "serde",  "serde_json",  "thiserror 2.0.21",@@ -2323,6 +2355,34 @@ dependencies = [  "slab", ] +[[package]]+name = "g2gen"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc"+dependencies = [+ "g2poly",+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "g2p"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad"+dependencies = [+ "g2gen",+ "g2poly",+]++[[package]]+name = "g2poly"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b"+ [[package]] name = "generic-array" version = "0.14.7"@@ -3455,6 +3515,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]]+name = "lru"+version = "0.18.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"+dependencies = [+ "hashbrown 0.17.1",+]+ [[package]] name = "lru-slab" version = "0.1.3"@@ -3670,12 +3739,75 @@ dependencies = [  "windows-sys 0.61.2", ] +[[package]]+name = "num"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"+dependencies = [+ "num-bigint",+ "num-complex",+ "num-integer",+ "num-iter",+ "num-rational",+ "num-traits",+]++[[package]]+name = "num-bigint"+version = "0.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"+dependencies = [+ "num-integer",+ "num-traits",+]++[[package]]+name = "num-complex"+version = "0.4.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"+dependencies = [+ "num-traits",+]+ [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]]+name = "num-integer"+version = "0.1.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"+dependencies = [+ "num-traits",+]++[[package]]+name = "num-iter"+version = "0.1.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"+dependencies = [+ "num-integer",+ "num-traits",+]++[[package]]+name = "num-rational"+version = "0.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"+dependencies = [+ "num-bigint",+ "num-integer",+ "num-traits",+]+ [[package]] name = "num-traits" version = "0.2.19"@@ -4990,6 +5122,17 @@ dependencies = [  "memchr", ] +[[package]]+name = "rqrr"+version = "0.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b"+dependencies = [+ "g2p",+ "image",+ "lru",+]+ [[package]] name = "rustc-hash" version = "1.1.0"@@ -5150,6 +5293,22 @@ dependencies = [  "unicode-script", ] +[[package]]+name = "rxing"+version = "0.9.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "842b2a567172af73f7d18ee076f7bbb4ed8545c933b11f43868f0b1bba203157"+dependencies = [+ "chrono",+ "codepage-437",+ "encoding_rs",+ "num",+ "once_cell",+ "regex",+ "thiserror 2.0.21",+ "unicode-segmentation",+]+ [[package]] name = "ryu" version = "1.0.23"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -73,6 +73,8 @@ krilla-svg = "=0.8.1" flo_curves = "0.8" # QR codes (Object › Generate QR Code); MIT. qrcodegen = "1.8"+rqrr = "0.11"+rxing = { version = "0.9", default-features = false, features = ["encoding_rs", "decoders", "qrcode"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branchdiff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -89,7 +89,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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). - File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice. - 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).+- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; error correction, module and corner pattern shapes, a logo area and logo; the code is a frame whose fill is the background, with the code, corner patterns and logo inside it as ordinary objects; Edit QR Code reopens a code with its content, style and colours). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.@@ -380,6 +380,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m  Newest first. +- **2026-10-25** · A generated QR code is a frame: its fill is the background and its stroke an outline, so Swatches, the Gradient Swatch tool and Stroke work on it as on any frame. The code, its corner patterns (in a colour of their own if wanted) and a logo are objects inside it that Direct Selection colours with any tool; Properties › QR Code shows their colours with Select Code and Edit QR Code. Generate QR Code with a code selected opens as Edit QR Code with its content, style and colours, and keeps what the usual tools set on its objects. Codes made before open unchanged and become a frame in place when edited. No readiness percentage change.+- **2026-10-24** · Generate QR Code styles the code: error correction (Low to High), module shape (squares, rounded squares, dots, connected) with roundness and size, corner patterns (square, rounded, circle), and a clear area in the middle with an optional logo placed and fitted there (High error correction by default), as one undo step. Edit QR Code keeps a code's style. Every style is checked against two open-source readers (rqrr, and ZXing's Rust port rxing) in the tests. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs--- a/crates/doc/src/item.rs+++ b/crates/doc/src/item.rs@@ -1466,6 +1466,14 @@ pub struct Item {     /// Alternative text (Object Export Options) for tagged PDF and EPUB.     #[serde(default, skip_serializing_if = "String::is_empty")]     pub alt_text: String,+    /// A QR code's style (Generate QR Code…), kept so editing the code keeps its look. None for+    /// the classic code and every other object.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub qr: Option<designcraft_geom::qr::QrStyle>,+    /// A part of a multi-coloured QR code other than the code itself (its background, its finder+    /// patterns): found again when the code is edited.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub qr_part: Option<designcraft_geom::qr::QrPart>,     /// Liquid Layout (object-based pages): pins and resize permissions.     #[serde(default, skip_serializing_if = "Option::is_none")]     pub liquid: Option<ObjectLiquid>,@@ -1550,6 +1558,8 @@ impl Item {             overrides: None,             label: String::new(),             alt_text: String::new(),+            qr: None,+            qr_part: None,             xml_tag: String::new(),             button: None,             pdf_hidden_layers: Vec::new(),diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs--- a/crates/engine/src/cmd/mod.rs+++ b/crates/engine/src/cmd/mod.rs@@ -51,7 +51,7 @@ pub mod preflight; mod prefs; mod prepress; mod print;-mod qr;+pub mod qr; mod rulers; mod scripts; mod select;diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs--- a/crates/engine/src/cmd/qr.rs+++ b/crates/engine/src/cmd/qr.rs@@ -3,6 +3,7 @@  use designcraft_doc::{Content, Fill, Item, ItemId, Selection, Shape}; use designcraft_geom::Rect;+use designcraft_geom::qr::{Ecc, Finder, Modules, QrPart, QrStyle}; use serde_json::{Value, json};  use super::{CommandSpec, bad, cmd, has_doc, rect_param, spread_param, str_param};@@ -17,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {         "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}",+        "{type?: url|text|sms|email|vcard (default url), content (url/text), number?, message?, to?, subject?, body?, name?, phone?, email?, org?, url?, color? (swatch, [Black]: the code; a gradient swatch too), finderColor? (swatch for the three corner patterns; none: the code's), background? (swatch behind the code and its quiet zone; none: transparent), rect? (new object; else into the selected frame, or re-encode a selected code), spread?, ecc?: low|medium|quartile|high (error correction; high with a logo), modules?: square|rounded|dots|connected, roundness? (0–1), moduleSize? (0.5–1 of a cell: below 1 the modules stand apart), finder?: square|rounded|circle (the three corner patterns), logoSize? (0–0.34 of the side, left clear in the middle), logo? (an image file placed there, fitted; logoSize 0.25 and ecc high unless given)} — a re-encoded code keeps its style unless given → {id, content, logo?}",         has_doc,         qr_code     )]@@ -45,96 +46,830 @@ fn encode(p: &Value) -> std::result::Result<String, String> {     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")?;+/// The dialog fields a code's text was made from: [`encode`] the other way round (Edit QR Code+/// shows what the code holds).+pub fn decode(text: &str) -> Value {+    // Fields separated by unescaped `;`, unescaped.+    let split = |body: &str| -> Vec<String> {+        let mut out = vec![String::new()];+        let mut esc = false;+        for ch in body.chars() {+            match (esc, ch) {+                (true, c) => {+                    if let Some(last) = out.last_mut() {+                        last.push(c);+                    }+                    esc = false;+                }+                (false, '\\') => esc = true,+                (false, ';') => out.push(String::new()),+                (false, c) => {+                    if let Some(last) = out.last_mut() {+                        last.push(c);+                    }+                }+            }+        }+        out+    };+    let tagged = |body: &str, tags: &[(&str, &str)]| {+        let mut v = serde_json::Map::new();+        for field in split(body) {+            if let Some((tag, value)) = field.split_once(':')+                && let Some((_, key)) = tags.iter().find(|(t, _)| *t == tag)+            {+                v.insert((*key).into(), json!(value));+            }+        }+        v+    };+    if let Some(body) = text.strip_prefix("MECARD:") {+        let mut v = tagged(body, &[("N", "name"), ("ORG", "org"), ("TEL", "phone"), ("EMAIL", "email"), ("URL", "url")]);+        v.insert("type".into(), json!("vcard"));+        return Value::Object(v);+    }+    if let Some(body) = text.strip_prefix("MATMSG:") {+        let mut v = tagged(body, &[("TO", "to"), ("SUB", "subject"), ("BODY", "body")]);+        v.insert("type".into(), json!("email"));+        return Value::Object(v);+    }+    if let Some(body) = text.strip_prefix("SMSTO:") {+        let (number, message) = body.split_once(':').unwrap_or((body, ""));+        return json!({"type": "sms", "number": number, "message": message});+    }+    let kind = if text.contains("://") || text.starts_with("www.") { "url" } else { "text" };+    json!({"type": kind, "content": text})+}++/// What Edit QR Code starts from for the selected object, if it is, holds or is part of a+/// generated code: the dialog's fields for its content, style and colours.+pub fn edit_fields(d: &designcraft_doc::Document, sel: ItemId) -> Option<Value> {+    let code = find_code(d, sel)?;+    let c = d.item(code)?;+    let mut v = decode(c.alt_text.trim_start_matches(MARK));+    let q = c.qr.unwrap_or_default();+    let name = |x: Value| x.as_str().unwrap_or_default().to_string();+    v["ecc"] = json!(name(serde_json::to_value(q.ecc).unwrap_or_default()));+    v["modules"] = json!(name(serde_json::to_value(q.modules).unwrap_or_default()));+    v["finder"] = json!(name(serde_json::to_value(q.finder).unwrap_or_default()));+    v["roundness"] = json!(q.roundness);+    v["moduleSize"] = json!(q.scale);+    v["logoSize"] = json!(q.logo);+    let colors = Colors::of(d, code);+    v["color"] = json!(colors.code);+    v["finderColor"] = json!(colors.finders.unwrap_or_default());+    v["background"] = json!(colors.background.unwrap_or_default());+    v["editing"] = json!(true);+    Some(v)+}++/// The objects a code is made of.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Parts {+    /// The frame it sits in: its fill is the background (none for a code made before).+    pub frame: Option<ItemId>,+    /// The code: its modules, and its finder patterns unless they have a colour of their own.+    pub code: ItemId,+    pub finders: Option<ItemId>,+}++/// The parts of the code the selected object is, holds or is part of.+pub fn parts(d: &designcraft_doc::Document, sel: ItemId) -> Option<Parts> {+    let code = find_code(d, sel)?;+    let siblings = parent_of(d, code).and_then(|g| d.item(g)).map(|g| g.children().to_vec()).unwrap_or_default();+    Some(Parts { frame: qr_frame_of(d, code), code, finders: siblings.iter().find(|i| i.qr_part == Some(QrPart::Finders)).map(|i| i.id) })+}++/// The style `p` asks for, starting from `base` (a re-encoded code's own): every key given+/// replaces that part. A logo file without a size or error correction gets room and High.+fn style_param(p: &Value, base: QrStyle) -> std::result::Result<QrStyle, String> {+    let mut st = base;+    let word = |k: &str| str_param(p, k).map(str::to_ascii_lowercase);+    if let Some(e) = word("ecc") {+        st.ecc = match e.as_str() {+            "low" | "l" => Ecc::Low,+            "medium" | "m" => Ecc::Medium,+            "quartile" | "q" => Ecc::Quartile,+            "high" | "h" => Ecc::High,+            _ => return Err(format!("unknown ecc `{e}` (low, medium, quartile, high)")),+        };+    }+    if let Some(m) = word("modules") {+        st.modules = match m.as_str() {+            "square" => Modules::Square,+            "rounded" => Modules::Rounded,+            "dots" => Modules::Dots,+            "connected" => Modules::Connected,+            _ => return Err(format!("unknown modules `{m}` (square, rounded, dots, connected)")),+        };+    }+    if let Some(f) = word("finder") {+        st.finder = match f.as_str() {+            "square" => Finder::Square,+            "rounded" => Finder::Rounded,+            "circle" => Finder::Circle,+            _ => return Err(format!("unknown finder `{f}` (square, rounded, circle)")),+        };+    }+    let num = |k: &str| -> std::result::Result<Option<f64>, String> {+        match p.get(k) {+            None | Some(Value::Null) => Ok(None),+            Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| format!("`{k}` must be a number")),+        }+    };+    if let Some(v) = num("roundness")? {+        st.roundness = v;+    }+    if let Some(v) = num("moduleSize")? {+        st.scale = v;+    }+    match num("logoSize")? {+        Some(v) => st.logo = v,+        None if str_param(p, "logo").is_some() && st.logo <= 0.0 => st.logo = 0.25,+        None => {}+    }+    if str_param(p, "logo").is_some() && p.get("ecc").is_none() {+        st.ecc = Ecc::High;+    }+    Ok(st.sanitized())+}++fn qr_item(id: ItemId, layer: designcraft_doc::LayerId, text: &str, r: Rect, color: &str, style: &QrStyle) -> std::result::Result<Item, String> {+    let path = designcraft_geom::qr::qr_styled_path(text, r, style).ok_or("too much content for a QR code")?;     let mut it = Item::new(id, layer, Shape::Path, path);+    it.qr = (!style.is_classic()).then_some(*style);     it.fill = Fill::swatch(color);     it.alt_text = format!("{MARK}{text}");     Ok(it) } +/// The colours of a code's parts: the code, its finder patterns (with the code when `None`) and+/// its background, the fill of the frame it sits in (none when `None`). Each is a swatch: a+/// colour, a tint or a gradient.+#[derive(Clone, Debug, PartialEq)]+struct Colors {+    code: String,+    finders: Option<String>,+    background: Option<String>,+}++impl Colors {+    /// The colours `p` gives, over `base` (an edited code's own). `finderColor`/`background` of+    /// `""` or `none` take that part away.+    fn from(p: &Value, base: Colors) -> Colors {+        let part = |k: &str, cur: Option<String>| match str_param(p, k) {+            Some(v) if v.is_empty() || v.eq_ignore_ascii_case("none") || v == designcraft_color::swatch::NONE => None,+            Some(v) => Some(v.to_string()),+            None => cur,+        };+        Colors {+            code: str_param(p, "color").map_or(base.code, str::to_string),+            finders: part("finderColor", base.finders),+            background: part("background", base.background),+        }+    }++    /// The colours code `code` has now: as its objects are filled, whichever tool filled them.+    fn of(d: &designcraft_doc::Document, code: ItemId) -> Colors {+        let swatch = |it: &Item| (!it.fill.is_none()).then(|| it.fill.swatch.clone());+        let c = d.item(code);+        let frame = qr_frame_of(d, code).and_then(|f| d.item(f));+        let siblings: Vec<std::sync::Arc<Item>> = parent_of(d, code).and_then(|g| d.item(g)).map(|g| g.children().to_vec()).unwrap_or_default();+        let part = |k: QrPart| siblings.iter().find(|i| i.qr_part == Some(k));+        Colors {+            code: c.map_or_else(|| designcraft_color::swatch::BLACK.into(), |c| c.fill.swatch.clone()),+            finders: part(QrPart::Finders).and_then(|i| swatch(i)),+            // The frame's fill; a code made before the frame carried it: its own square's.+            background: frame.and_then(swatch).or_else(|| part(QrPart::Background).and_then(|i| swatch(i))),+        }+    }+}++/// An object of a code drawn anew with `path`: an edited code's own object when there is one, so+/// what the usual tools set on it (a gradient's direction, a stroke, effects, opacity) stays, and+/// its fill changes only when the colour does.+fn redraw(old: Option<&Item>, id: ItemId, layer: designcraft_doc::LayerId, path: designcraft_geom::PathData, swatch: &str) -> Item {+    let mut it = match old {+        Some(o) => {+            let mut it = o.clone();+            it.path = path;+            it+        }+        None => Item::new(id, layer, Shape::Path, path),+    };+    if old.is_none_or(|o| o.fill.swatch != swatch) {+        it.fill = Fill::swatch(swatch);+    }+    it+}++/// The objects a code in `r` is made of, back to front: the code (with its finder patterns unless+/// they have a colour of their own) and the finder patterns, redrawn from an edited code's own+/// (`old_code`, `old_finders`).+#[allow(clippy::too_many_arguments)]+fn qr_parts(+    d: &mut designcraft_doc::Document,+    layer: designcraft_doc::LayerId,+    text: &str,+    r: Rect,+    style: &QrStyle,+    colors: &Colors,+    old_code: Option<&Item>,+    old_finders: Option<&Item>,+) -> std::result::Result<Vec<Item>, String> {+    let shapes = designcraft_geom::qr::qr_shapes(text, r, style).ok_or("too much content for a QR code")?;+    let fresh = qr_item(ItemId(0), layer, text, r, &colors.code, style)?;+    let id = old_code.map_or_else(|| ItemId(d.alloc()), |c| c.id);+    let path = if colors.finders.is_some() { shapes.modules } else { fresh.path };+    let mut code = redraw(old_code, id, layer, path, &colors.code);+    code.qr = fresh.qr;+    code.alt_text = fresh.alt_text;+    let mut out = vec![code];+    if let Some(fc) = &colors.finders {+        let id = old_finders.map_or_else(|| ItemId(d.alloc()), |f| f.id);+        let mut it = redraw(old_finders, id, layer, shapes.finders, fc);+        it.qr_part = Some(QrPart::Finders);+        out.push(it);+    }+    Ok(out)+}++/// The group or frame an object sits in, if any (not the spread).+fn parent_of(d: &designcraft_doc::Document, id: ItemId) -> Option<ItemId> {+    let loc = d.find(id)?;+    let (_, up) = loc.path.split_last()?;+    let mut items = &d.spread(loc.spread)?.items[..];+    let mut parent = None;+    for &i in up {+        let it = items.get(i)?;+        parent = Some(it.id);+        items = it.children();+    }+    parent+}++/// How far a search for a code goes into groups and frames.+const MAX_DEPTH: usize = 8;++/// A generated code that is `it` or lies inside it, the frontmost one.+fn code_under(it: &Item, depth: usize) -> Option<ItemId> {+    if it.alt_text.starts_with(MARK) {+        return Some(it.id);+    }+    if depth == 0 {+        return None;+    }+    it.children().iter().rev().find_map(|c| code_under(c, depth - 1))+}++/// The code the selected object is, holds (a code's frame, a group with one in it), or is a part+/// of (its finder patterns or logo).+fn find_code(d: &designcraft_doc::Document, sel: ItemId) -> Option<ItemId> {+    let it = d.item(sel)?;+    if let Some(c) = code_under(it, MAX_DEPTH) {+        return Some(c);+    }+    if !matches!(it.qr_part, Some(QrPart::Finders | QrPart::Logo)) {+        return None;+    }+    code_under(d.item(parent_of(d, sel)?)?, 1)+}++/// The frame a code sits in, if it sits in one (not in a group): its fill is the background.+fn qr_frame_of(d: &designcraft_doc::Document, code: ItemId) -> Option<ItemId> {+    let p = parent_of(d, code)?;+    (d.item(p)?.shape != Shape::Group).then_some(p)+}++/// Puts `new` where the object `id` is (in its group or frame, or on its spread).+fn replace_item(d: &mut designcraft_doc::Document, id: ItemId, new: Item) -> Result<()> {+    let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;+    let Some((&last, up)) = loc.path.split_last() else { return Err(designcraft_doc::DocError::NoItem(id).into()) };+    let slot = if up.is_empty() {+        d.spread_mut(loc.spread).and_then(|sp| sp.items.get_mut(last))+    } else {+        let parent = designcraft_doc::ItemLoc { spread: loc.spread, path: up.to_vec() };+        d.item_mut_at(&parent).and_then(Item::children_mut).and_then(|items| items.get_mut(last))+    };+    *slot.ok_or(designcraft_doc::DocError::NoItem(id))? = std::sync::Arc::new(new);+    Ok(())+}++/// A new code's frame on `r`'s square: the background as its fill, no stroke.+fn qr_frame(d: &mut designcraft_doc::Document, layer: designcraft_doc::LayerId, r: Rect, colors: &Colors) -> Item {+    let mut f = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));+    f.fill = colors.background.as_deref().map_or_else(Fill::none, Fill::swatch);+    f.qr_part = Some(QrPart::Background);+    f+}++/// What [`qr_code`]'s edit leaves to do after it: the frame, where a logo goes (frame space), and+/// a logo frame moved to a new area, to fit its image again.+struct Made {+    select: ItemId,+    frame: ItemId,+    logo_area: Option<Rect>,+    refit: Option<ItemId>,+}+ 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, ID)?;+    let logo_file = str_param(p, "logo").map(str::to_string);     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 editing = if rect.is_some() { None } else { sel.first().and_then(|t| find_code(&st.doc, *t)) };+    // An edited code keeps its style and colours unless the params change them.+    let (base_style, base_colors) = match editing.and_then(|code| Some((st.doc.item(code)?.qr.unwrap_or_default(), Colors::of(&st.doc, code)))) {+        Some(b) => b,+        None => (QrStyle::default(), Colors { code: designcraft_color::swatch::BLACK.into(), finders: None, background: None }),+    };+    let style = style_param(p, base_style).map_err(|e| bad(ID, e))?;+    let colors = Colors::from(p, base_colors);+    let made = s.edit(|d, selection| {+        for c in [Some(&colors.code), colors.finders.as_ref(), colors.background.as_ref()].into_iter().flatten() {+            if d.swatch(c).is_none() {+                return Err(bad(ID, format!("no swatch `{c}`")));+            }         }-        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+        let logo_in = |r: Rect| designcraft_geom::qr::qr_shapes(&text, r, &style).and_then(|q| q.logo);+        let made = match (rect, sel.first().copied(), editing) {+            (Some(r), _, _) => {+                // A new code: a frame on the code's square (with its quiet zone), the parts in it.+                let square = designcraft_geom::qr::qr_shapes(&text, r, &style).ok_or_else(|| bad(ID, "too much content for a QR code"))?.frame;+                let parts = qr_parts(d, lid, &text, square, &style, &colors, None, None).map_err(|e| bad(ID, e))?;+                let mut f = qr_frame(d, lid, square, &colors);+                f.content = Content::Group { items: parts.into_iter().map(std::sync::Arc::new).collect() };+                let fid = d.insert_item(sr, f, None)?;+                Made { select: fid, frame: fid, logo_area: logo_in(square), refit: None }             }-            (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).ok_or(designcraft_doc::DocError::NoItem(t))?;-                    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`"));+            (None, _, Some(code_id)) => {+                // Edit QR Code: the parts drawn anew in the code's frame; what else is in it stays.+                let c = d.item(code_id).ok_or(designcraft_doc::DocError::NoItem(code_id))?.clone();+                let fid = match qr_frame_of(d, code_id) {+                    Some(f) => f,+                    None => {+                        // A code made without a frame: a frame on its square takes its place.+                        // The symbol: the code with its finder patterns, wherever they are.+                        let beside = parent_of(d, code_id)+                            .and_then(|g| d.item(g))+                            .and_then(|g| g.children().iter().find(|i| i.qr_part == Some(QrPart::Finders)).cloned());+                        let finders_beside = beside.and_then(|f| f.path.bounds().map(|b| (c.xf.inverse() * f.xf).transform_rect_bbox(b)));+                        let symbol = c.path.bounds().into_iter().chain(finders_beside).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);+                        let n = designcraft_geom::qr::qr_size_at(c.alt_text.trim_start_matches(MARK), c.qr.unwrap_or_default().ecc).unwrap_or(21);+                        let quiet = symbol.width() / n as f64 * 2.0;+                        let side = symbol.width().max(symbol.height()) + 2.0 * quiet;+                        let r = Rect::from_center_size(symbol.center(), (side, side));+                        let mut f = qr_frame(d, c.layer, r, &colors);+                        f.xf = c.xf;+                        let mut inner = c.clone();+                        inner.xf = designcraft_geom::Affine::IDENTITY;+                        let mut items = vec![std::sync::Arc::new(inner)];+                        // Its own background square and finder patterns, beside it in a group,+                        // move in with it (the frame's fill takes over the background).+                        let legacy: Vec<std::sync::Arc<Item>> = parent_of(d, code_id)+                            .and_then(|g| d.item(g))+                            .map(|g| {+                                g.children().iter().filter(|i| matches!(i.qr_part, Some(QrPart::Background | QrPart::Finders))).cloned().collect()+                            })+                            .unwrap_or_default();+                        for l in legacy {+                            d.remove_item(l.id)?;+                            if l.qr_part == Some(QrPart::Finders) {+                                let mut l = (*l).clone();+                                l.xf = c.xf.inverse() * l.xf;+                                items.push(std::sync::Arc::new(l));+                            }+                        }+                        f.content = Content::Group { items };+                        let fid = f.id;+                        replace_item(d, code_id, f)?;+                        fid                     }-                    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).ok_or(designcraft_doc::DocError::NoItem(t))?;-                    f.content = Content::Group { items: vec![std::sync::Arc::new(code)] };-                    t+                };+                let frame = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.clone();+                let r = frame.inner_bounds();+                let kids = frame.children();+                // The code as it sits in the frame now: a code just moved into one has handed its+                // transform to the frame, and must not keep it as well.+                let c = kids.iter().find(|i| i.id == code_id).map_or(c, |i| (**i).clone());+                let finders = kids.iter().find(|i| i.qr_part == Some(QrPart::Finders)).map(|i| (**i).clone());+                let logo = kids.iter().find(|i| i.qr_part == Some(QrPart::Logo)).map(|i| (**i).clone());+                let parts = qr_parts(d, c.layer, &text, r, &style, &colors, Some(&c), finders.as_ref()).map_err(|e| bad(ID, e))?;+                let area = logo_in(r);+                // The logo: kept and moved to the new clear area, or gone with it or a new file.+                let logo = logo.filter(|_| area.is_some() && logo_file.is_none());+                let mut refit = None;+                let logo = logo.map(|mut l| {+                    if let Some(a) = area+                        && l.xf.transform_rect_bbox(l.inner_bounds()) != a+                    {+                        l.path = designcraft_geom::shapes::rectangle(a);+                        l.xf = designcraft_geom::Affine::IDENTITY;+                        refit = Some(l.id);+                    }+                    l+                });+                let f = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;+                let own = (!f.fill.is_none()).then(|| f.fill.swatch.clone());+                if own != colors.background || f.qr_part.is_none() {+                    f.fill = colors.background.as_deref().map_or_else(Fill::none, Fill::swatch);+                }+                f.qr_part = Some(QrPart::Background);+                let items = f.children_mut().ok_or(designcraft_doc::DocError::NoItem(fid))?;+                let at = items.iter().position(|i| i.id == code_id).unwrap_or(0);+                items.retain(|i| i.id != code_id && i.qr_part.is_none());+                let at = at.min(items.len());+                for (k, it) in parts.into_iter().chain(logo).enumerate() {+                    items.insert((at + k).min(items.len()), std::sync::Arc::new(it));                 }+                // The selection stays (the frame, a group around it, a part); else the frame.+                let keep = sel.first().copied().filter(|s| d.item(*s).is_some());+                Made { select: keep.unwrap_or(fid), frame: fid, logo_area: if logo_file.is_some() { area } else { None }, refit }             }-            (None, None) => return Err(bad(ID, "give `rect` or select a frame")),+            (None, Some(t), None) => {+                // Into the frame: it becomes the code's frame, the parts its content.+                let it = d.item(t).ok_or_else(|| bad(ID, "no such object"))?.clone();+                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 r = it.inner_bounds();+                let parts = qr_parts(d, it.layer, &text, r, &style, &colors, None, None).map_err(|e| bad(ID, e))?;+                let f = d.item_mut(t).ok_or(designcraft_doc::DocError::NoItem(t))?;+                f.content = Content::Group { items: parts.into_iter().map(std::sync::Arc::new).collect() };+                f.fill = colors.background.as_deref().map_or_else(Fill::none, Fill::swatch);+                f.qr_part = Some(QrPart::Background);+                Made { select: t, frame: t, logo_area: logo_in(r), refit: None }+            }+            (None, None, _) => return Err(bad(ID, "give `rect` or select a frame")),+        };+        *selection = Selection::items(vec![made.select]);+        Ok(made)+    })?;+    let mut out = json!({"id": made.select.0, "frame": made.frame.0, "content": text});+    if let Some(l) = made.refit {+        s.execute("object.fit", &json!({"mode": "fitProportionally", "ids": [l.0]}))?;+    }+    // A logo file: a frame on the clear area with the image fitted in it, moved into the code's+    // frame (all part of this one undo step, as everything this command runs).+    if let (Some(file), Some(area)) = (logo_file, made.logo_area) {+        let d = &s.doc()?.doc;+        let fid = made.frame;+        let loc = d.find(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;+        let to_spread = d.parent_xf(&loc) * d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.xf;+        let a = to_spread.transform_rect_bbox(area);+        let spread = match loc.spread {+            designcraft_doc::SpreadRef::Doc(i) => json!({"kind": "doc", "index": i}),+            designcraft_doc::SpreadRef::Parent(i) => json!({"kind": "parent", "index": i}),         };-        *selection = Selection::items(vec![id]);-        Ok(json!({"id": id.0, "content": text}))-    })+        let logo = s.execute("frame.create", &json!({"rect": [a.x0, a.y0, a.x1, a.y1], "content": "unassigned", "spread": spread}))?["id"].clone();+        s.execute("file.place", &json!({"path": file, "frame": logo}))?;+        s.execute("object.fit", &json!({"mode": "fitProportionally", "ids": [logo]}))?;+        let lid = ItemId(logo.as_u64().ok_or_else(|| bad(ID, "no logo frame"))?);+        let select = made.select;+        s.edit(|d, selection| {+            let mut l = (*d.remove_item(lid)?).clone();+            l.xf = to_spread.inverse() * l.xf;+            l.stroke = designcraft_doc::Stroke::none();+            l.qr_part = Some(QrPart::Logo);+            let items = d.item_mut(fid).and_then(Item::children_mut).ok_or(designcraft_doc::DocError::NoItem(fid))?;+            items.push(std::sync::Arc::new(l));+            *selection = Selection::items(vec![select]);+            Ok(())+        })?;+        out["logo"] = logo;+    }+    Ok(out) }  #[cfg(test)] mod tests {+    use designcraft_doc::{Content, Item, ItemId};+    use designcraft_geom::qr::{Ecc, Finder, Modules, QrPart, QrStyle};     use serde_json::json; +    use super::MARK;+     use crate::Session; -    #[test]-    fn generate_and_edit_qr_codes() {+    fn new_doc() -> Session {         let mut s = Session::new();         s.execute("file.new", &json!({})).unwrap();+        s+    }++    fn item(s: &Session, id: ItemId) -> Item {+        s.doc().unwrap().doc.item(id).unwrap().clone()+    }++    /// The code an object is, holds or is part of.+    fn code(s: &Session, id: ItemId) -> Item {+        let d = &s.doc().unwrap().doc;+        d.item(super::find_code(d, id).unwrap()).unwrap().clone()+    }++    fn id(v: &serde_json::Value) -> ItemId {+        ItemId(v["id"].as_u64().unwrap())+    }++    /// A code as Generate QR Code made it before codes had a frame: one path on the spread.+    fn lone_code(s: &mut Session, text: &str, r: designcraft_geom::Rect, color: &str) -> ItemId {+        s.edit(|d, _| {+            let lid = d.layers[0].id;+            let it = super::qr_item(ItemId(d.alloc()), lid, text, r, color, &QrStyle::default()).unwrap();+            Ok(d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None)?)+        })+        .unwrap()+    }++    #[test]+    fn generate_and_edit_qr_codes() {+        let mut s = new_doc();         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);+        let frame = id(&r);+        let f = item(&s, frame);+        assert_eq!(f.shape, designcraft_doc::Shape::Rectangle, "a code is a frame");+        assert_eq!(f.qr_part, Some(QrPart::Background));+        let a = code(&s, frame);         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();+        let b1 = code(&s, frame).bounds();         assert!((b1.center() - b0.center()).hypot() < 1.0, "{b0:?} → {b1:?}");         assert!((b1.width() - b0.width()).abs() < 4.0);+        assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 1, "still one object");         // 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());+        let fr = item(&s, ItemId(f));+        assert!(fr.has_nested_items() && fr.qr_part == Some(QrPart::Background));         assert!(s.execute("object.qrCode", &json!({"type": "fax", "content": "x", "rect": [0, 0, 9, 9]})).is_err());     }++    /// A styled code keeps its style (stored on the object) when its content is edited, and a+    /// classic code stores none.+    #[test]+    fn a_styled_code_keeps_its_style_when_edited() {+        let mut s = new_doc();+        let classic = id(&s.execute("object.qrCode", &json!({"content": "a", "rect": [10, 10, 110, 110]})).unwrap());+        assert!(code(&s, classic).qr.is_none());+        let r = s+            .execute(+                "object.qrCode",+                &json!({"content": "https://example.com", "rect": [200, 200, 300, 300], "modules": "dots", "finder": "circle", "ecc": "q", "moduleSize": 0.8, "roundness": 0.5}),+            )+            .unwrap();+        let fid = id(&r);+        let style = |s: &Session| code(s, fid).qr.unwrap();+        let st = style(&s);+        assert_eq!((st.modules, st.finder, st.ecc, st.scale, st.roundness), (Modules::Dots, Finder::Circle, Ecc::Quartile, 0.8, 0.5));+        // Edit QR Code with new content only: the look stays; a given key still changes it.+        s.execute("selection.set", &json!({"ids": [fid.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.org"})).unwrap();+        assert_eq!(style(&s), st);+        s.execute("object.qrCode", &json!({"content": "https://example.org", "modules": "connected"})).unwrap();+        assert_eq!(style(&s).modules, Modules::Connected);+        for bad in [+            json!({"content": "x", "rect": [0, 0, 9, 9], "modules": "hearts"}),+            json!({"content": "x", "rect": [0, 0, 9, 9], "ecc": "max"}),+            json!({"content": "x", "rect": [0, 0, 9, 9], "roundness": "round"}),+        ] {+            assert!(s.execute("object.qrCode", &bad).is_err(), "{bad}");+        }+    }++    /// A logo file goes on the clear area in the middle, fitted, inside the code's frame and+    /// without a stroke, at High error correction; the whole is one undo step, and editing the+    /// code keeps the logo, moved to a new clear area.+    #[test]+    fn a_logo_is_placed_in_the_middle_in_one_undo_step() {+        let dir = std::env::temp_dir().join(format!("dc-qr-logo-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let png = dir.join("logo.png");+        std::fs::write(&png, designcraft_render::Rendered { width: 40, height: 40, pixels: vec![200u8; 40 * 40 * 4] }.to_png()).unwrap();+        let mut s = new_doc();+        let undo = |s: &Session| s.doc().unwrap().history.undo.len();+        let before = undo(&s);+        let r = s+            .execute("object.qrCode", &json!({"content": "https://example.com", "rect": [100, 100, 300, 300], "logo": png.to_string_lossy()}))+            .unwrap();+        assert_eq!(undo(&s), before + 1, "one undo step");+        let fid = id(&r);+        let logo_of = |s: &Session| item(s, fid).children().iter().find(|c| c.qr_part == Some(QrPart::Logo)).map(|c| (**c).clone());+        let logo = logo_of(&s).expect("the logo is in the code's frame");+        assert!(matches!(logo.content, Content::Graphic(_)), "the image is in the logo frame");+        assert!(logo.stroke.is_none(), "no stroke around the logo");+        assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 1, "one object");+        let c = code(&s, fid);+        assert_eq!(c.qr.unwrap().ecc, Ecc::High, "High unless given");+        assert!((c.qr.unwrap().logo - 0.25).abs() < 1e-9);+        let b = logo.bounds();+        assert!((b.center() - designcraft_geom::Point::new(200.0, 200.0)).hypot() < 1.0, "centred: {b:?}");+        assert!(b.width() < 200.0 * 0.3, "within the clear area: {b:?}");+        // Edited with a bigger clear area: the same logo, grown with it; with none, it goes.+        s.execute("selection.set", &json!({"ids": [fid.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.org", "logoSize": 0.3})).unwrap();+        let grown = logo_of(&s).unwrap();+        assert_eq!(grown.id, logo.id);+        assert!(grown.bounds().width() > b.width() + 1.0, "{:?} → {:?}", b, grown.bounds());+        s.execute("object.qrCode", &json!({"content": "https://example.org", "logoSize": 0})).unwrap();+        assert!(logo_of(&s).is_none());+        for _ in 0..3 {+            s.execute("edit.undo", &json!({})).unwrap();+        }+        assert!(s.doc().unwrap().doc.spreads[0].items.is_empty(), "undo takes code and logo");+        let _ = std::fs::remove_dir_all(&dir);+    }++    /// The frame's fill is the background and its stroke the code's outline, as on any frame; the+    /// code and its finder patterns are ordinary objects inside it. What the usual tools set on+    /// them (a fill, a gradient's direction, a stroke) survives editing, and the dialog shows it.+    #[test]+    fn the_usual_tools_colour_a_code_and_editing_keeps_it() {+        let mut s = new_doc();+        // A gradient swatch to colour the code with.+        let g = s.execute("frame.create", &json!({"rect": [500, 500, 520, 520], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();+        s.execute("object.gradient", &json!({"ids": [g], "stops": [{"location": 0, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}]})).unwrap();+        s.execute("selection.set", &json!({"ids": [g]})).unwrap();+        let grad = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string();+        let r = s+            .execute(+                "object.qrCode",+                &json!({"content": "https://example.com", "rect": [100, 100, 200, 200], "color": grad, "finderColor": "C=100 M=0 Y=0 K=0", "background": "[Paper]"}),+            )+            .unwrap();+        let fid = id(&r);+        let f = item(&s, fid);+        assert_eq!(f.fill.swatch, "[Paper]", "the background is the frame's fill");+        let kids: Vec<(Option<QrPart>, bool, String)> =+            f.children().iter().map(|c| (c.qr_part, c.alt_text.starts_with(MARK), c.fill.swatch.clone())).collect();+        assert_eq!(kids, [(None, true, grad.clone()), (Some(QrPart::Finders), false, "C=100 M=0 Y=0 K=0".to_string())]);+        // The usual tools: the frame's fill and stroke; the code (selected inside) a gradient's+        // direction and a stroke.+        let cid = code(&s, fid).id;+        s.execute("object.fill", &json!({"ids": [fid.0], "swatch": "[Black]", "tint": 0.2})).unwrap();+        s.execute("object.stroke", &json!({"ids": [fid.0], "swatch": "[Black]", "weight": 2})).unwrap();+        s.execute("object.stroke", &json!({"ids": [cid.0], "swatch": "[Black]", "weight": 0.5})).unwrap();+        s.edit(|d, _| {+            d.item_mut(cid).unwrap().fill.gradient_vector = Some([100.0, 100.0, 200.0, 200.0]);+            Ok(())+        })+        .unwrap();+        let fields = super::edit_fields(&s.doc().unwrap().doc, fid).unwrap();+        assert_eq!((fields["background"].as_str(), fields["color"].as_str()), (Some("[Black]"), Some(grad.as_str())));+        // Edit from the code itself (selected inside the frame) with new content only.+        s.execute("selection.set", &json!({"ids": [cid.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.org/a/longer/address"})).unwrap();+        let (f, c) = (item(&s, fid), code(&s, fid));+        assert_eq!((f.fill.swatch.as_str(), f.fill.tint, f.stroke.weight), ("[Black]", 0.2, 2.0), "the frame keeps fill, tint and stroke");+        assert_eq!((c.fill.swatch.clone(), c.fill.gradient_vector, c.stroke.weight), (grad.clone(), Some([100.0, 100.0, 200.0, 200.0]), 0.5));+        assert!(c.alt_text.ends_with("longer/address"));+        assert_eq!(s.doc().unwrap().selection.items, vec![cid], "the code stays selected");+        // A colour picked in the dialog replaces the fill; the finder patterns back to the code's.+        s.execute("selection.set", &json!({"ids": [fid.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "x", "color": "[Black]", "finderColor": "none", "background": "none"})).unwrap();+        let f = item(&s, fid);+        assert!(f.fill.is_none() && f.children().len() == 1 && f.children()[0].fill.swatch == "[Black]");+        assert!(s.execute("object.qrCode", &json!({"content": "x", "rect": [0, 0, 9, 9], "background": "Nope"})).is_err());+    }++    /// Codes made before codes had a frame open as they were and edit like new ones: a lone code+    /// (one path), a group of a background square, the code and finder patterns, and a frame+    /// holding a background square and the code. Each becomes a frame where it was, with its+    /// colours, and nothing doubles.+    #[test]+    fn codes_made_before_stay_editable() {+        // A lone code.+        let mut s = new_doc();+        let r = designcraft_geom::Rect::new(100.0, 100.0, 200.0, 200.0);+        let lone = lone_code(&mut s, "https://old.example", r, "[Black]");+        let before = item(&s, lone).bounds();+        let fields = super::edit_fields(&s.doc().unwrap().doc, lone).unwrap();+        assert_eq!(+            (fields["content"].as_str(), fields["color"].as_str(), fields["background"].as_str()),+            (Some("https://old.example"), Some("[Black]"), Some(""))+        );+        s.execute("selection.set", &json!({"ids": [lone.0]})).unwrap();+        let out = s.execute("object.qrCode", &json!({"content": "https://old.example"})).unwrap();+        let items = &s.doc().unwrap().doc.spreads[0].items;+        assert_eq!(items.len(), 1);+        let f = &items[0];+        assert_eq!((f.qr_part, f.fill.is_none()), (Some(QrPart::Background), true));+        let after = code(&s, f.id).bounds();+        assert!((after.x0 - before.x0).abs() < 0.01 && (after.x1 - before.x1).abs() < 0.01, "{before:?} → {after:?}");+        assert_eq!(code(&s, f.id).id, lone, "the code keeps its id");+        assert!(out["id"].as_u64().is_some());++        // A group of a background square, the code and finder patterns (as the first preview made).+        let mut s = new_doc();+        let r = s+            .execute(+                "object.qrCode",+                &json!({"content": "https://example.com", "rect": [100, 100, 200, 200], "finderColor": "[Registration]", "background": "[Paper]"}),+            )+            .unwrap();+        let fid = id(&r);+        let f = item(&s, fid);+        let (code_it, finders) = (f.children()[0].clone(), f.children()[1].clone());+        let gid = s+            .edit(|d, _| {+                d.remove_item(fid)?;+                let lid = d.layers[0].id;+                let mut bg =+                    Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(f.inner_bounds()));+                bg.fill = designcraft_doc::Fill::swatch("[Paper]");+                bg.qr_part = Some(QrPart::Background);+                let mut g = Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default());+                g.content = Content::Group { items: vec![std::sync::Arc::new(bg), code_it.clone(), finders.clone()] };+                Ok(d.insert_item(designcraft_doc::SpreadRef::Doc(0), g, None)?)+            })+            .unwrap();+        let fields = super::edit_fields(&s.doc().unwrap().doc, gid).unwrap();+        assert_eq!((fields["finderColor"].as_str(), fields["background"].as_str()), (Some("[Registration]"), Some("[Paper]")));+        s.execute("selection.set", &json!({"ids": [gid.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.org"})).unwrap();+        let g = item(&s, gid);+        assert_eq!(g.children().len(), 1, "the group holds the code's frame alone");+        let f = &g.children()[0];+        assert_eq!(f.fill.swatch, "[Paper]");+        let kinds: Vec<Option<QrPart>> = f.children().iter().map(|c| c.qr_part).collect();+        assert_eq!(kinds, [None, Some(QrPart::Finders)], "code and finder patterns once each");++        // A frame holding a background square and the code (Generate QR Code into a frame).+        let mut s = new_doc();+        let fr = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.com", "background": "[Paper]"})).unwrap();+        s.edit(|d, _| {+            let lid = d.layers[0].id;+            let f = d.item_mut(ItemId(fr)).unwrap();+            let b = f.inner_bounds();+            f.qr_part = None;+            f.fill = designcraft_doc::Fill::none();+            let mut bg = Item::new(ItemId(1_000_000), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(b));+            bg.fill = designcraft_doc::Fill::swatch("[Paper]");+            bg.qr_part = Some(QrPart::Background);+            f.children_mut().unwrap().insert(0, std::sync::Arc::new(bg));+            Ok(())+        })+        .unwrap();+        s.execute("selection.set", &json!({"ids": [fr]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://example.org"})).unwrap();+        let f = item(&s, ItemId(fr));+        assert_eq!((f.fill.swatch.as_str(), f.qr_part), ("[Paper]", Some(QrPart::Background)), "its square's colour moves to the frame");+        assert_eq!(f.children().len(), 1);+    }++    /// A code made before codes had a frame, then moved and rotated with the tools, stays where+    /// it is when edited: the frame takes its transform, and the code doesn't keep it as well.+    #[test]+    fn a_moved_and_rotated_code_from_before_stays_in_place_when_edited() {+        let mut s = new_doc();+        let r = designcraft_geom::Rect::new(100.0, 100.0, 200.0, 200.0);+        let lone = lone_code(&mut s, "https://old.example", r, "[Black]");+        let xf = designcraft_geom::Affine::translate((60.0, 40.0))+            * designcraft_geom::Affine::rotate_about(0.3, designcraft_geom::Point::new(150.0, 150.0));+        s.edit(|d, _| {+            d.item_mut(lone).unwrap().xf = xf;+            Ok(())+        })+        .unwrap();+        // The code's symbol on the spread: its path through every transform above it.+        let on_spread = |s: &Session| {+            let d = &s.doc().unwrap().doc;+            let loc = d.find(lone).unwrap();+            let it = d.item(lone).unwrap();+            (d.parent_xf(&loc) * it.xf).transform_rect_bbox(it.path.bounds().unwrap())+        };+        let before = on_spread(&s);+        s.execute("selection.set", &json!({"ids": [lone.0]})).unwrap();+        s.execute("object.qrCode", &json!({"content": "https://old.example"})).unwrap();+        let after = on_spread(&s);+        assert!((after.center() - before.center()).hypot() < 0.5, "{before:?} → {after:?}");+        assert!((after.width() - before.width()).abs() < 0.5, "{before:?} → {after:?}");+        assert_eq!(code(&s, lone).xf, designcraft_geom::Affine::IDENTITY, "the frame carries the transform");+    }++    /// Edit QR Code shows what a code holds: every type's fields come back from its text.+    #[test]+    fn decode_gives_back_the_fields_encode_was_given() {+        for p in [+            json!({"type": "url", "content": "https://example.com/a?b=c"}),+            json!({"type": "text", "content": "Hello; world"}),+            json!({"type": "sms", "number": "+49 170 1", "message": "Hi there"}),+            json!({"type": "email", "to": "a@b.c", "subject": "Re: one; two", "body": "x"}),+            json!({"type": "vcard", "name": "Ada Lovelace", "org": "Analytical; Engines", "phone": "+44 1", "email": "ada@x.y", "url": "https://x.y"}),+        ] {+            let text = super::encode(&p).unwrap();+            let back = super::decode(&text);+            for (k, v) in p.as_object().unwrap() {+                assert_eq!(&back[k], v, "{k} of {p}");+            }+        }+    } }diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml--- a/crates/geom/Cargo.toml+++ b/crates/geom/Cargo.toml@@ -16,5 +16,9 @@ serde = { workspace = true } thiserror = { workspace = true }  [dev-dependencies]+# An open-source QR reader: the styled codes must still read.+rqrr = { workspace = true }+# ZXing in Rust: the reading most phone scanners do.+rxing = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true }diff --git a/crates/geom/src/qr.rs b/crates/geom/src/qr.rs--- a/crates/geom/src/qr.rs+++ b/crates/geom/src/qr.rs@@ -1,50 +1,289 @@-//! QR codes as vector paths (Object › Generate QR Code).+//! QR codes as vector paths (Object › Generate QR Code), with a style: the shape of the modules+//! and of the three finder patterns, the error correction level, and an area left clear in the+//! middle for a logo.  use kurbo::Rect;+use serde::{Deserialize, Serialize};  use crate::Point;-use crate::path::{PathData, SubPath};+use crate::path::{Anchor, PathData, SubPath}; -/// Error correction as InDesign uses for print: medium (15%).-const ECC: qrcodegen::QrCodeEcc = qrcodegen::QrCodeEcc::Medium;+/// Error correction: how much of the code may be damaged or covered (by a logo) and still read.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum Ecc {+    /// About 7 %.+    Low,+    /// About 15 %, as InDesign uses for print.+    #[default]+    Medium,+    /// About 25 %.+    Quartile,+    /// About 30 %: for a logo in the middle.+    High,+} -/// 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;+impl Ecc {+    fn level(self) -> qrcodegen::QrCodeEcc {+        match self {+            Ecc::Low => qrcodegen::QrCodeEcc::Low,+            Ecc::Medium => qrcodegen::QrCodeEcc::Medium,+            Ecc::Quartile => qrcodegen::QrCodeEcc::Quartile,+            Ecc::High => qrcodegen::QrCodeEcc::High,+        }+    }+}++/// How the dark modules are drawn.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum Modules {+    /// Squares, touching (the classic code).+    #[default]+    Square,+    /// Squares with rounded corners, each on its own.+    Rounded,+    /// Dots.+    Dots,+    /// Rounded where a module has no dark neighbour, so neighbouring modules flow into one shape.+    Connected,+}++/// How the three finder patterns (the large squares in the corners) are drawn.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum Finder {+    #[default]+    Square,+    Rounded,+    Circle,+}++/// A QR code's look. The defaults draw the classic code.+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase", default)]+pub struct QrStyle {+    pub ecc: Ecc,+    pub modules: Modules,+    /// How round the modules' corners are, 0 (square) to 1 (as round as they get).+    pub roundness: f64,+    /// A module's size in its cell, 0.5 to 1: below 1 the modules stand apart.+    pub scale: f64,+    pub finder: Finder,+    /// The side of the area left clear in the middle, as a fraction of the code's side (0 to+    /// [`MAX_LOGO`]): room for a logo.+    pub logo: f64,+}++impl Default for QrStyle {+    fn default() -> Self {+        QrStyle { ecc: Ecc::Medium, modules: Modules::Square, roundness: 1.0, scale: 1.0, finder: Finder::Square, logo: 0.0 }+    }+}++/// The largest logo area: a third of the side, about a ninth of the code, which High error+/// correction still reads around.+pub const MAX_LOGO: f64 = 0.34;++impl QrStyle {+    /// The style with every number in its range (a hostile file or command can't break the code).+    pub fn sanitized(self) -> Self {+        let clamp = |v: f64, lo: f64, hi: f64, def: f64| if v.is_finite() { v.clamp(lo, hi) } else { def };+        QrStyle {+            roundness: clamp(self.roundness, 0.0, 1.0, 1.0),+            scale: clamp(self.scale, 0.5, 1.0, 1.0),+            logo: clamp(self.logo, 0.0, MAX_LOGO, 0.0),+            ..self+        }+    }++    /// Whether this is the classic code (no stored style needed).+    pub fn is_classic(&self) -> bool {+        *self == QrStyle::default()+    }+}++/// Which part of a QR code an object is, besides the code itself (which carries the style): a+/// code drawn in more than one colour is a group of these, each an ordinary filled object.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum QrPart {+    /// The frame the code sits in: its fill is the background behind the code and its quiet+    /// zone, its stroke the code's outline. (Earlier: a square of its own behind the code.)+    Background,+    /// The three finder patterns, in a colour of their own.+    Finders,+    /// The frame in the middle holding the logo image.+    Logo,+}++/// A QR code's shapes: the data modules, the three finder patterns, and where a logo goes.+#[derive(Clone, Debug, PartialEq)]+pub struct QrShapes {+    /// The code with its quiet zone: the background's square.+    pub frame: Rect,+    pub modules: PathData,+    pub finders: PathData,+    /// The clear area in the middle, inset by half a module: where a logo fits.+    pub logo: Option<Rect>,+    /// Modules per side.+    pub size: i32,+}++/// Quiet zone, modules each side.+const QUIET: i32 = 2;++/// The QR code for `text` in `r` (square, centred, a quiet zone of two modules each side) drawn+/// in `style`. `None` if the text is too long for any QR version at its error correction.+pub fn qr_shapes(text: &str, r: Rect, style: &QrStyle) -> Option<QrShapes> {+    let style = style.sanitized();+    // A rectangle given corner to corner the other way round is the same square; an empty or+    // non-finite one holds no code.+    let r = r.abs();     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;+    if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || side <= 0.0 {+        return None;+    }+    let qr = qrcodegen::QrCode::encode_text(text, style.ecc.level()).ok()?;+    let n = qr.size();+    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 cell =+        |x: i32, y: i32| Rect::new(origin.x + x as f64 * m, origin.y + y as f64 * m, origin.x + (x + 1) as f64 * m, origin.y + (y + 1) as f64 * m);+    // The finder patterns with their separators: drawn on their own.+    let in_finder = |x: i32, y: i32| (x < 8 && y < 8) || (x >= n - 8 && y < 8) || (x < 8 && y >= n - 8);+    // The clear area: an odd number of modules, centred.+    let clear = {+        let k = ((style.logo * n as f64).round() as i32) | 1;+        let lo = (n - k) / 2;+        (style.logo > 0.0).then_some((lo, lo + k))+    };+    let cleared = |x: i32, y: i32| clear.is_some_and(|(lo, hi)| x >= lo && x < hi && y >= lo && y < hi);+    let dark = |x: i32, y: i32| x >= 0 && y >= 0 && x < n && y < n && qr.get_module(x, y) && !in_finder(x, y) && !cleared(x, y);+    let mut modules = Vec::new();+    match style.modules {+        Modules::Square if style.scale >= 1.0 => {+            // One rectangle per horizontal run.+            for y in 0..n {+                let mut x = 0;+                while x < n {+                    if !dark(x, y) {+                        x += 1;+                        continue;+                    }+                    let x0 = x;+                    while x < n && dark(x, y) {+                        x += 1;+                    }+                    modules.push(round_rect(cell(x0, y).union(cell(x - 1, y)), [0.0; 4]));+                }             }-            let x0 = x;-            while x < n && qr.get_module(x, y) {-                x += 1;+        }+        shape => {+            for y in 0..n {+                for x in 0..n {+                    if !dark(x, y) {+                        continue;+                    }+                    let c = cell(x, y);+                    let s = style.scale * m;+                    let r = Rect::from_center_size(c.center(), (s, s));+                    let full = s / 2.0;+                    let radii = match shape {+                        Modules::Square => [0.0; 4],+                        Modules::Rounded => [style.roundness * full; 4],+                        Modules::Dots => [full; 4],+                        // A corner rounds only where neither neighbour towards it is dark, so+                        // runs and blocks of modules flow into one shape.+                        Modules::Connected => {+                            let rr = style.roundness * full;+                            let open = |dx: i32, dy: i32| !dark(x + dx, y) && !dark(x, y + dy);+                            [open(-1, -1), open(1, -1), open(1, 1), open(-1, 1)].map(|o| if o { rr } else { 0.0 })+                        }+                    };+                    // Connected modules fill their whole cell, so neighbours touch.+                    let r = if shape == Modules::Connected { c } else { r };+                    modules.push(round_rect(r, radii));+                }             }-            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));         }     }+    // Finder patterns: a 7×7 ring around a 3×3 eye, in the three corners.+    let mut finders = Vec::new();+    for (fx, fy) in [(0, 0), (n - 7, 0), (0, n - 7)] {+        let outer = cell(fx, fy).union(cell(fx + 6, fy + 6));+        let inner = outer.inset(-m);+        let eye = outer.inset(-2.0 * m);+        let (ro, ri, re) = match style.finder {+            Finder::Square => (0.0, 0.0, 0.0),+            Finder::Rounded => (2.0 * m, 1.2 * m, 0.8 * m),+            Finder::Circle => (outer.width() / 2.0, inner.width() / 2.0, eye.width() / 2.0),+        };+        finders.push(round_rect(outer, [ro; 4]));+        let mut hole = round_rect(inner, [ri; 4]);+        hole.reverse();+        finders.push(hole);+        finders.push(round_rect(eye, [re; 4]));+    }+    let logo = clear.map(|(lo, hi)| cell(lo, lo).union(cell(hi - 1, hi - 1)).inset(-m / 2.0));+    let frame = Rect::from_center_size(r.center(), (side, side));+    Some(QrShapes { frame, modules: PathData::new(modules), finders: PathData::new(finders), logo, size: n })+}++/// The classic code's dark modules for `text` in `r`: one path, finder patterns included.+pub fn qr_path(text: &str, r: Rect) -> Option<PathData> {+    qr_styled_path(text, r, &QrStyle::default())+}++/// [`qr_shapes`] as one path: modules and finder patterns together.+pub fn qr_styled_path(text: &str, r: Rect, style: &QrStyle) -> Option<PathData> {+    let s = qr_shapes(text, r, style)?;+    let mut subpaths = s.finders.subpaths;+    subpaths.extend(s.modules.subpaths);     Some(PathData::new(subpaths)) } -/// The modules per side for `text` (the version's size), if it can be encoded.+/// The modules per side for `text` at `ecc` (the version's size), if it can be encoded.+pub fn qr_size_at(text: &str, ecc: Ecc) -> Option<i32> {+    qrcodegen::QrCode::encode_text(text, ecc.level()).ok().map(|q| q.size())+}++/// The modules per side for `text` at the default error correction. pub fn qr_size(text: &str) -> Option<i32> {-    qrcodegen::QrCode::encode_text(text, ECC).ok().map(|q| q.size())+    qr_size_at(text, Ecc::default())+}++/// A clockwise rectangle with corner radii (top left, top right, bottom right, bottom left), each+/// at most half the shorter side.+fn round_rect(r: Rect, radii: [f64; 4]) -> SubPath {+    // The handle length that makes a cubic quarter circle.+    const K: f64 = 0.552_284_749_830_793_4;+    let max = (r.width().min(r.height()) / 2.0).max(0.0);+    let [tl, tr, br, bl] = radii.map(|v| v.clamp(0.0, max));+    let mut a = Vec::with_capacity(8);+    let mut corner = |p: Point, rad: f64, from: kurbo::Vec2, to: kurbo::Vec2| {+        if rad <= 0.0 {+            a.push(Anchor::corner(p));+            return;+        }+        // Into the corner along `from`, out of it along `to`.+        let start = p - from * rad;+        let end = p + to * rad;+        a.push(Anchor::with_handles(start, start, start + from * rad * K));+        a.push(Anchor::with_handles(end, end - to * rad * K, end));+    };+    let (x, y) = (kurbo::Vec2::new(1.0, 0.0), kurbo::Vec2::new(0.0, 1.0));+    corner(Point::new(r.x0, r.y0), tl, -y, x);+    corner(Point::new(r.x1, r.y0), tr, x, y);+    corner(Point::new(r.x1, r.y1), br, y, -x);+    corner(Point::new(r.x0, r.y1), bl, -x, -y);+    SubPath::new(a, true) }  #[cfg(test)] mod tests {+    use kurbo::Shape;+     use super::*;      #[test]@@ -60,4 +299,149 @@ mod tests {         assert!(p.subpaths.len() > 20);         assert!(qr_path(&"x".repeat(5000), Rect::new(0.0, 0.0, 10.0, 10.0)).is_none());     }++    /// The code drawn as a scanner sees it: `px` pixels a side, dark where the path covers a+    /// pixel's centre (nonzero winding), light quiet zone and background.+    fn raster(p: &PathData, side: f64, px: usize) -> (usize, Vec<u8>) {+        // Each subpath with its bounds: a pixel only asks the few that can cover it.+        let parts: Vec<(Rect, kurbo::BezPath)> = p+            .subpaths+            .iter()+            .map(|sp| {+                let mut bp = kurbo::BezPath::new();+                sp.to_bezpath_into(&mut bp);+                (bp.bounding_box(), bp)+            })+            .collect();+        let k = side / px as f64;+        let mut img = vec![255u8; px * px];+        for (i, v) in img.iter_mut().enumerate() {+            let pt = Point::new(((i % px) as f64 + 0.5) * k, ((i / px) as f64 + 0.5) * k);+            let w: i32 = parts.iter().filter(|(b, _)| b.contains(pt)).map(|(_, bp)| bp.winding(pt)).sum();+            if w != 0 {+                *v = 0;+            }+        }+        (px, img)+    }++    /// The code for `text` drawn in `style`, eight pixels a module (as a phone camera sees a+    /// printed code at a short distance).+    fn image(text: &str, style: &QrStyle) -> (usize, Vec<u8>) {+        let side = 100.0;+        let p = qr_styled_path(text, Rect::new(0.0, 0.0, side, side), style).unwrap();+        let n = qr_size_at(text, style.ecc).unwrap() as usize;+        raster(&p, side, (n + 4) * 8)+    }++    /// What rqrr, an open-source QR reader, reads.+    fn read_rqrr(text: &str, style: &QrStyle) -> Option<String> {+        let (w, img) = image(text, style);+        let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w, w, |x, y| img[y * w + x]);+        let grids = prepared.detect_grids();+        grids.first()?.decode().ok().map(|(_, content)| content)+    }++    /// What ZXing (its Rust port rxing) reads: the way most phone scanners read.+    fn read_zxing(text: &str, style: &QrStyle) -> Option<String> {+        let (w, img) = image(text, style);+        rxing::helpers::detect_in_luma(img, w as u32, w as u32, Some(rxing::BarcodeFormat::QR_CODE)).ok().map(|r| r.getText().to_string())+    }++    const URL: &str = "https://github.com/storytold/designcraft/issues?q=qr";++    /// Every module shape and finder shape reads with ZXing, at every error correction level,+    /// with the modules at their usual size and noticeably apart. rqrr finds finder patterns by+    /// their corners: it reads every module shape with square finder patterns, not round ones.+    #[test]+    fn every_style_reads_back() {+        let mut failed = Vec::new();+        for ecc in [Ecc::Low, Ecc::Medium, Ecc::Quartile, Ecc::High] {+            for modules in [Modules::Square, Modules::Rounded, Modules::Dots, Modules::Connected] {+                for finder in [Finder::Square, Finder::Rounded, Finder::Circle] {+                    for scale in [1.0, 0.8] {+                        let style = QrStyle { ecc, modules, finder, scale, roundness: 1.0, logo: 0.0 };+                        if read_zxing(URL, &style).as_deref() != Some(URL) {+                            failed.push(format!("zxing {ecc:?}/{modules:?}/{finder:?}/{scale}"));+                        }+                        if finder == Finder::Square && read_rqrr(URL, &style).as_deref() != Some(URL) {+                            failed.push(format!("rqrr {ecc:?}/{modules:?}/{finder:?}/{scale}"));+                        }+                    }+                }+            }+        }+        assert!(failed.is_empty(), "unreadable: {failed:?}");+    }++    /// A logo area as large as the dialog allows reads at High error correction, in every module+    /// and finder shape; at Low the same area breaks the code (the error correction pays for it).+    #[test]+    fn a_logo_area_reads_at_high_error_correction() {+        let mut failed = Vec::new();+        for modules in [Modules::Square, Modules::Rounded, Modules::Dots, Modules::Connected] {+            for finder in [Finder::Square, Finder::Rounded, Finder::Circle] {+                let style = QrStyle { ecc: Ecc::High, modules, finder, logo: MAX_LOGO, ..Default::default() };+                if read_zxing(URL, &style).as_deref() != Some(URL) {+                    failed.push(format!("zxing {modules:?}/{finder:?}"));+                }+                if finder == Finder::Square && read_rqrr(URL, &style).as_deref() != Some(URL) {+                    failed.push(format!("rqrr {modules:?}/{finder:?}"));+                }+            }+        }+        assert!(failed.is_empty(), "unreadable with a logo: {failed:?}");+        let low = QrStyle { ecc: Ecc::Low, logo: MAX_LOGO, ..Default::default() };+        assert_eq!(read_zxing(URL, &low), None, "a third of the side cleared at Low error correction");+    }++    #[test]+    fn the_logo_area_is_centred_and_clear() {+        let style = QrStyle { ecc: Ecc::High, logo: 0.3, ..Default::default() };+        let s = qr_shapes(URL, Rect::new(0.0, 0.0, 100.0, 100.0), &style).unwrap();+        let logo = s.logo.unwrap();+        assert!((logo.center() - Point::new(50.0, 50.0)).hypot() < 1e-9);+        assert!(logo.width() > 25.0 && logo.width() < 32.0, "{logo:?}");+        let bp = s.modules.to_bezpath();+        assert_eq!(bp.winding(logo.center()), 0, "nothing drawn where the logo goes");+        assert!(QrStyle::default().is_classic() && !style.is_classic());+    }++    #[test]+    fn finder_rings_have_a_hole() {+        let s = qr_shapes(URL, Rect::new(0.0, 0.0, 100.0, 100.0), &QrStyle { finder: Finder::Circle, ..Default::default() }).unwrap();+        let m = 100.0 / (s.size + 4) as f64;+        let bp = s.finders.to_bezpath();+        // Top left finder: ring at 0.5 module in, light between ring and eye, dark eye centre.+        let at = |modules: f64| Point::new(2.0 * m + modules * m, 2.0 * m + 3.5 * m);+        assert_ne!(bp.winding(at(0.5)), 0, "the ring");+        assert_eq!(bp.winding(at(1.5)), 0, "between ring and eye");+        assert_ne!(bp.winding(at(3.5)), 0, "the eye");+    }++    #[test]+    fn hostile_numbers_are_clamped() {+        let s = QrStyle { roundness: f64::NAN, scale: 9.0, logo: -1.0, ..Default::default() }.sanitized();+        assert_eq!((s.roundness, s.scale, s.logo), (1.0, 1.0, 0.0));+    }++    /// A rectangle given corner to corner the other way round makes the same code (it panicked+    /// in `round_rect` for rounded modules); an empty or non-finite one makes none.+    #[test]+    fn a_reversed_rectangle_makes_the_same_code() {+        for modules in [Modules::Square, Modules::Rounded, Modules::Dots, Modules::Connected] {+            let style = QrStyle { modules, scale: 0.8, finder: Finder::Rounded, ..Default::default() };+            let fwd = qr_shapes("https://example.com", Rect::new(100.0, 100.0, 200.0, 200.0), &style).unwrap();+            let back = qr_shapes("https://example.com", Rect::new(200.0, 200.0, 100.0, 100.0), &style).unwrap();+            assert_eq!(fwd, back, "{modules:?}");+        }+        for r in [+            Rect::new(5.0, 5.0, 5.0, 50.0),+            Rect::new(0.0, 0.0, f64::NAN, 9.0),+            Rect::new(0.0, 0.0, f64::INFINITY, f64::INFINITY),+            Rect::new(0.0, 0.0, 10.0, f64::INFINITY),+        ] {+            assert!(qr_shapes("x", r, &QrStyle::default()).is_none(), "{r:?}");+        }+    } }diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -33,7 +33,9 @@ impl Dialog {             "newDocument" => crate::new_document::defaults(),             "goToPage" => json!({"page": "1"}),             "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}),-            "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),+            "qrCode" => {+                json!({"type": "url", "content": "https://", "color": "[Black]", "finderColor": "", "background": "[Paper]", "ecc": "medium", "modules": "square", "finder": "square", "roundness": 1.0, "moduleSize": 1.0, "logoSize": 0.0, "logo": ""})+            }             "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"}),@@ -2242,6 +2244,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {         "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"),         "loadStyles" => crate::i18n::tr(&app.ui.language, "Load Styles"),         "fittingOptions" => crate::i18n::tr(&app.ui.language, "Frame Fitting Options"),+        "qrCode" if d.fields.get("editing").and_then(Value::as_bool).unwrap_or(false) => crate::i18n::tr(&app.ui.language, "Edit QR Code"),         "qrCode" => crate::i18n::tr(&app.ui.language, "Generate QR Code"),         "preferences" => crate::i18n::tr(&app.ui.language, "Preferences"),         "print" => crate::i18n::tr(&app.ui.language, "Print"),@@ -2931,10 +2934,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {                                 text_field(ui, &mut d, k, 260.0);                                 ui.end_row();                             }-                            crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:"));-                            text_field(ui, &mut d, "color", 160.0);-                            ui.end_row();                         });+                        qr_style_ui(app, ui, &mut d);                     }                     "polygonSettings" => {                         egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {@@ -3771,6 +3772,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {         }         "qrCode" => {             let mut p = Value::Object(d.fields.clone());+            // No logo file chosen: no logo (the clear area stays as set).+            if d.s("logo").is_empty()+                && let Some(m) = p.as_object_mut()+            {+                m.remove("logo");+            }             // Nothing selected: a 2-inch code at the top left of the page in view.             if let Some(st) = app.session.active().filter(|s| s.selection.items.is_empty()) {                 let abs = crate::canvas::current_page(app).unwrap_or(0);@@ -6001,6 +6008,126 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog         }); } +/// Generate QR Code › the code's look: error correction, module and corner pattern shapes, and a+/// logo in the middle, with what each choice means for reading the code.+fn qr_style_ui(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {+    let lang = app.ui.language.clone();+    let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();+    ui.add_space(6.0);+    crate::rtl::label(ui, egui::RichText::new(tr("Style")).strong());+    let slider = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive<f64>, enabled: bool| {+        let mut v = d.n(key).unwrap_or(0.0) * 100.0;+        let r = egui::Slider::new(&mut v, range.start() * 100.0..=range.end() * 100.0).step_by(1.0).suffix("%");+        if ui.add_enabled(enabled, r).changed() {+            d.fields.insert(key.into(), json!(v / 100.0));+        }+    };+    // Colours: any swatch, gradient swatches too. Corner patterns may follow the code; the+    // background may be left out.+    let doc = app.session.active().map(|st| st.doc.clone());+    let colour = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, empty: Option<&str>| {+        let Some(doc) = &doc else { return };+        let cur = d.s(key);+        let shown = if cur.is_empty() { empty.unwrap_or_default().to_string() } else { cur.clone() };+        egui::ComboBox::from_id_salt(("qr_colour", key.to_string())).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| {+            if let Some(e) = empty+                && ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, e)).clicked()+            {+                d.fields.insert(key.into(), json!(""));+            }+            for w in doc.swatches.iter().filter(|w| !w.hidden && w.name != designcraft_color::swatch::NONE) {+                let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0);+                ui.horizontal(|ui| {+                    let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());+                    crate::widgets::paint_chip(ui.painter(), r, c, g);+                    if ui.selectable_label(w.name == cur, &w.name).clicked() {+                        d.fields.insert(key.into(), json!(w.name));+                    }+                });+            }+        });+    };+    let same = tr("Same as Code");+    let none = tr("None");+    egui::Grid::new("qr_colours").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {+        crate::rtl::label(ui, tr("Code Color:"));+        colour(ui, d, "color", None);+        ui.end_row();+        crate::rtl::label(ui, tr("Corner Pattern Color:"));+        colour(ui, d, "finderColor", Some(&same));+        ui.end_row();+        crate::rtl::label(ui, tr("Background:"));+        colour(ui, d, "background", Some(&none));+        ui.end_row();+    });+    let dim = crate::theme::Tokens::get(ui.ctx()).text_dim;+    crate::rtl::label(+        ui,+        egui::RichText::new(tr("The code sits in a frame: its fill is the background, its stroke an outline. Select the code inside with Direct Selection to colour it with any tool.")).color(dim),+    );+    egui::Grid::new("qr_style").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {+        crate::rtl::label(ui, tr("Error Correction:"));+        translated_combo(+            &lang,+            ui,+            d,+            "ecc",+            &[("low", "Low (7%)"), ("medium", "Medium (15%)"), ("quartile", "Quartile (25%)"), ("high", "High (30%)")],+        );+        ui.end_row();+        crate::rtl::label(ui, tr("Modules:"));+        translated_combo(+            &lang,+            ui,+            d,+            "modules",+            &[("square", "Squares"), ("rounded", "Rounded Squares"), ("dots", "Dots"), ("connected", "Connected")],+        );+        ui.end_row();+        let rounds = matches!(d.s("modules").as_str(), "rounded" | "connected");+        crate::rtl::label(ui, tr("Roundness:"));+        slider(ui, d, "roundness", 0.0..=1.0, rounds);+        ui.end_row();+        crate::rtl::label(ui, tr("Module Size:"));+        slider(ui, d, "moduleSize", 0.5..=1.0, true);+        ui.end_row();+        crate::rtl::label(ui, tr("Corner Patterns:"));+        translated_combo(&lang, ui, d, "finder", &[("square", "Square"), ("rounded", "Rounded"), ("circle", "Circle")]);+        ui.end_row();+        crate::rtl::label(ui, tr("Logo Area:"));+        slider(ui, d, "logoSize", 0.0..=designcraft_geom::qr::MAX_LOGO, true);+        ui.end_row();+        crate::rtl::label(ui, tr("Logo:"));+        ui.horizontal(|ui| {+            let file = d.s("logo");+            let shown = std::path::Path::new(&file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+            if ui.button(tr("Choose…")).clicked()+                && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place"))+            {+                d.fields.insert("logo".into(), json!(path));+                // Room for it, at the error correction that reads around it.+                if d.n("logoSize").unwrap_or(0.0) <= 0.0 {+                    d.fields.insert("logoSize".into(), json!(0.25));+                }+                d.fields.insert("ecc".into(), json!("high"));+            }+            if !shown.is_empty() {+                crate::rtl::label(ui, shown);+                if ui.small_button("×").on_hover_text(tr("No logo")).clicked() {+                    d.fields.insert("logo".into(), json!(""));+                }+            }+        });+        ui.end_row();+    });+    if d.n("logoSize").unwrap_or(0.0) > 0.0 && d.s("ecc") != "high" {+        crate::rtl::label(ui, egui::RichText::new(tr("A logo area needs High error correction to stay readable.")).color(dim));+    }+    if d.s("finder") != "square" {+        crate::rtl::label(ui, egui::RichText::new(tr("Round corner patterns read on phones; a few simple readers need square ones.")).color(dim));+    }+}+ fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {     let cur = d.s(key);     let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();@@ -7403,6 +7530,32 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests {     use super::*; +    /// Generate QR Code makes a styled code, and opened on that code it starts from its style.+    #[test]+    fn the_qr_dialog_makes_a_styled_code_and_reopens_with_its_style() {+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+        app.run("file.new", json!({})).unwrap();+        app.ui.dialog = Some(Dialog::new(+            "qrCode",+            json!({"content": "https://example.com", "modules": "dots", "finder": "circle", "ecc": "high", "logoSize": 0.2}),+        ));+        let r = confirm(&mut app).unwrap();+        let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());+        // A paper background by default: the fill of the code's frame.+        let d = &app.session.doc().unwrap().doc;+        assert_eq!(d.item(id).unwrap().fill.swatch, "[Paper]");+        let q = d.item(id).unwrap().children().iter().find_map(|c| c.qr).unwrap();+        use designcraft_geom::qr::{Ecc, Finder, Modules};+        assert_eq!((q.modules, q.finder, q.ecc), (Modules::Dots, Finder::Circle, Ecc::High));+        assert!((q.logo - 0.2).abs() < 1e-9);+        // Reopened on the code: its style, not the defaults.+        app.run("selection.set", json!({"ids": [id.0]})).unwrap();+        crate::menus::activate(&mut app, "app.qrCode", &Value::Null);+        let d = app.ui.dialog.as_ref().unwrap();+        assert_eq!((d.s("modules"), d.s("finder"), d.s("ecc")), ("dots".to_string(), "circle".to_string(), "high".to_string()));+        assert_eq!((d.s("type"), d.s("content"), d.s("background")), ("url".to_string(), "https://example.com".to_string(), "[Paper]".to_string()));+    }+     #[test]     fn paragraph_style_settings_show_captured_color_and_pending_spacing_without_changing_the_document() {         let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -5159,6 +5159,39 @@ mod tests {         // Docking chrome labels reach `tr` through craft-ui's localizer.         keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english));         // UI labels passed to `tr` that no other catalog has yet.+        // Generate QR Code › Style.+        keys.extend([+            "Code Color:",+            "Corner Pattern Color:",+            "Background:",+            "Same as Code",+            "QR Code",+            "Corner Patterns",+            "Select Code",+            "Select the code inside its frame, to colour it with any tool (a gradient too)",+            "Edit QR Code…",+            "The code sits in a frame: its fill is the background, its stroke an outline. Select the code inside with Direct Selection to colour it with any tool.",+            "Edit QR Code",+            "Error Correction:",+            "Low (7%)",+            "Medium (15%)",+            "Quartile (25%)",+            "High (30%)",+            "Modules:",+            "Squares",+            "Rounded Squares",+            "Dots",+            "Connected",+            "Roundness:",+            "Module Size:",+            "Corner Patterns:",+            "Logo Area:",+            "Logo:",+            "Choose…",+            "No logo",+            "A logo area needs High error correction to stay readable.",+            "Round corner patterns read on phones; a few simple readers need square ones.",+        ]);         keys.extend([             "(any)",             "(unchanged)",diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs--- a/crates/ui-egui/src/i18n/de.rs+++ b/crates/ui-egui/src/i18n/de.rs@@ -3,6 +3,46 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[+    ("Code Color:", "Code-Farbe:"),+    ("Corner Pattern Color:", "Farbe der Eckmarken:"),+    ("Background:", "Hintergrund:"),+    ("Same as Code", "Wie Code"),+    ("QR Code", "QR-Code"),+    ("Code", "Code"),+    ("Corner Patterns", "Eckmarken"),+    ("Select Code", "Code auswählen"),+    (+        "Select the code inside its frame, to colour it with any tool (a gradient too)",+        "Den Code in seinem Rahmen auswählen, um ihn mit jedem Werkzeug zu färben (auch mit Verlauf)",+    ),+    ("Edit QR Code…", "QR-Code bearbeiten …"),+    (+        "The code sits in a frame: its fill is the background, its stroke an outline. Select the code inside with Direct Selection to colour it with any tool.",+        "Der Code liegt in einem Rahmen: Seine Fläche ist der Hintergrund, seine Kontur ein Rand. Den Code darin mit dem Direktauswahl-Werkzeug auswählen, um ihn mit jedem Werkzeug zu färben.",+    ),+    ("Edit QR Code", "QR-Code bearbeiten"),+    ("Error Correction:", "Fehlerkorrektur:"),+    ("Low (7%)", "Niedrig (7 %)"),+    ("Medium (15%)", "Mittel (15 %)"),+    ("Quartile (25%)", "Quartil (25 %)"),+    ("High (30%)", "Hoch (30 %)"),+    ("Modules:", "Module:"),+    ("Squares", "Quadrate"),+    ("Rounded Squares", "Abgerundete Quadrate"),+    ("Dots", "Punkte"),+    ("Connected", "Verbunden"),+    ("Roundness:", "Rundung:"),+    ("Module Size:", "Modulgröße:"),+    ("Corner Patterns:", "Eckmarken:"),+    ("Logo Area:", "Logo-Bereich:"),+    ("Logo:", "Logo:"),+    ("Choose…", "Auswählen …"),+    ("No logo", "Kein Logo"),+    ("A logo area needs High error correction to stay readable.", "Ein Logo-Bereich braucht hohe Fehlerkorrektur, damit der Code lesbar bleibt."),+    (+        "Round corner patterns read on phones; a few simple readers need square ones.",+        "Runde Eckmarken lesen Smartphones; einige einfache Lesegeräte brauchen eckige.",+    ),     ("File", "Datei"),     ("Edit", "Bearbeiten"),     ("Layout", "Layout"),diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -1249,7 +1249,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value             Ok(Value::Null)         }         "app.qrCode" => {-            app.ui.dialog = Some(crate::dialogs::Dialog::new("qrCode", json!({})));+            // Editing a code: the dialog starts from what it holds, its style and its colours.+            let fields = app+                .session+                .active()+                .and_then(|st| st.selection.items.first().and_then(|i| designcraft_engine::cmd::qr::edit_fields(&st.doc, *i)))+                .unwrap_or_else(|| json!({}));+            app.ui.dialog = Some(crate::dialogs::Dialog::new("qrCode", fields));             Ok(Value::Null)         }         "app.exportText" => export_bytes(app, p, "rtf", "file.exportText"),diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs--- a/crates/ui-egui/src/panels/properties.rs+++ b/crates/ui-egui/src/panels/properties.rs@@ -136,6 +136,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {     section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true);     appearance_section(app, ui, &i);     divider(ui);+    let qr = (i.count == 1)+        .then(|| app.session.active().and_then(|st| st.selection.items.first().and_then(|id| designcraft_engine::cmd::qr::parts(&st.doc, *id))))+        .flatten();+    if let Some(parts) = qr {+        section(ui, crate::i18n::tr(&app.ui.language, "QR Code"), true);+        qr_section(app, ui, parts);+        divider(ui);+    }     if i.is_text && i.count == 1 {         section(ui, crate::i18n::tr(&app.ui.language, "Character"), true);         character_section(app, ui);@@ -908,6 +916,91 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {     }); } +/// Properties › QR Code: the colours of a code's own objects (Fill and Stroke above are its frame's:+/// the background and an outline), selecting the code to use any tool on it, and Edit QR Code.+fn qr_section(app: &mut DesignApp, ui: &mut Ui, parts: designcraft_engine::cmd::qr::Parts) {+    let t = Tokens::get(ui.ctx());+    let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };+    let lang = app.ui.language.clone();+    let fill_of = |id: designcraft_doc::ItemId| doc.item(id).map(|i| i.fill.swatch.clone()).unwrap_or_default();+    let code = fill_of(parts.code);+    let finders = parts.finders.map(fill_of);+    // (label, swatch shown, the object it colours, if it is one of its own)+    let rows = [("Code", code.clone(), Some(parts.code)), ("Corner Patterns", finders.clone().unwrap_or_else(|| code.clone()), parts.finders)];+    for (label, cur, target) in rows {+        let b = block(ui, FIELD_H);+        let (c, g) = widgets::swatch_colors(&doc, &cur, 1.0);+        let chip = sub(b, 4.0, 2.0, 17.0, 17.0);+        widgets::paint_chip(ui.painter(), chip, c, g);+        ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);+        let resp = ui+            .interact(chip, ui.scope_id().with(("qrchip", label)), Sense::click())+            .on_hover_text(format!("{}: {cur}", crate::i18n::tr(&lang, label)));+        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("QR {label} swatch")));+        let mut picked = None;+        egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {+            picked = super::swatch_list(ui, &doc, &cur);+        });+        let shown = if target.is_none() && label == "Corner Patterns" { crate::i18n::tr(&lang, "Same as Code").to_string() } else { cur.clone() };+        crate::rtl::paint(+            ui.painter(),+            b.min + vec2(34.0, FIELD_H / 2.0),+            egui::Align2::LEFT_CENTER,+            crate::i18n::tr(&lang, label),+            egui::FontId::proportional(11.5),+            t.text,+        );+        crate::rtl::paint(+            ui.painter(),+            b.min + vec2(130.0, FIELD_H / 2.0),+            egui::Align2::LEFT_CENTER,+            &shown,+            egui::FontId::proportional(11.0),+            t.text_dim,+        );+        let Some(name) = picked else { continue };+        let corners_none = label == "Corner Patterns" && name == designcraft_color::swatch::NONE;+        match target {+            // [None] for corner patterns of their own: back to the code's colour ("Same as Code"),+            // rather than invisible patterns no scanner finds.+            Some(_) if corners_none => {+                if let Some(mut p) = designcraft_engine::cmd::qr::edit_fields(&doc, parts.code) {+                    p["finderColor"] = json!("none");+                    let _ = app.run("selection.set", json!({"ids": [parts.frame.unwrap_or(parts.code).0]}));+                    let _ = app.run("object.qrCode", p);+                }+            }+            // An object of its own: filled as any object is.+            Some(id) => {+                let _ = app.run("object.fill", json!({"ids": [id.0], "swatch": name}));+            }+            // Corner patterns with the code: they become an object of their own in that colour.+            None if corners_none => {}+            None => {+                if let Some(mut p) = designcraft_engine::cmd::qr::edit_fields(&doc, parts.code) {+                    p["finderColor"] = json!(name);+                    let _ = app.run("selection.set", json!({"ids": [parts.frame.unwrap_or(parts.code).0]}));+                    let _ = app.run("object.qrCode", p);+                }+            }+        }+        ui.add_space(4.0);+    }+    ui.horizontal(|ui| {+        if ui+            .button(crate::i18n::tr(&lang, "Select Code"))+            .on_hover_text(crate::i18n::tr(&lang, "Select the code inside its frame, to colour it with any tool (a gradient too)"))+            .clicked()+        {+            let _ = app.run("tool.select", json!({"tool": "directSelection"}));+            let _ = app.run("selection.set", json!({"ids": [parts.code.0], "content": true}));+        }+        if ui.button(crate::i18n::tr(&lang, "Edit QR Code…")).clicked() {+            crate::menus::activate(app, "app.qrCode", &Value::Null);+        }+    });+}+ /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text.