diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 086efc5..66a15fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: macos: name: macOS universal needs: version - runs-on: macos-15 + runs-on: macos-release # org larger runner (M2 Pro), release.yml@release only; capped by the actions_macos_xl budget environment: release env: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2476e51..f2c5437 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,7 +1,7 @@ # Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 # runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed # binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left -# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 on: diff --git a/AGENTS.md b/AGENTS.md index d72bf63..3a2bcb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # DesignCraft — instructions for agents -DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). +DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app). @@ -39,3 +39,21 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou ## Roadmap `ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. + +## Contributor credits (About window) + +- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` + (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + themselves). See `docs/contributors.md`. +- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub + username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once + whether they want to be credited by more than their username: a real name, a display name, and/or + their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry + (copy the template at the top of the file, or run + `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + and include it in their PR, committed as them. If no, change nothing: they are credited as + `@username` anyway. +- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or + GitHub profiles). Never hand-edit `contributors.json`. +- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also + re-verifies who wrote each `people.toml` entry; `--check` only verifies). diff --git a/CLAUDE.md b/CLAUDE.md index d72bf63..7d06739 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # DesignCraft — instructions for agents -DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). +DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app). diff --git a/Cargo.lock b/Cargo.lock index 67c769e..7c7ac83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,6 +1113,7 @@ dependencies = [ "regex", "serde", "serde_json", + "toml", "unicode-bidi", "unicode-bidi-mirroring", "unicode-segmentation", diff --git a/README.md b/README.md index 22c7d52..ff321c2 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | diff --git a/apps/designcraft/src/control_server.rs b/apps/designcraft/src/control_server.rs index aa4688d..50e2e4b 100644 --- a/apps/designcraft/src/control_server.rs +++ b/apps/designcraft/src/control_server.rs @@ -1,14 +1,28 @@ //! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (`designcraft mcp --connect`) wraps. +//! +//! The server only ever reads requests: a line that is not a JSON object with a string `method` +//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets +//! one error reply and the connection is closed, so nothing after it is executed. Lines are +//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk +//! never reaches the UI thread. See `docs/control-protocol.md`. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; use designcraft_ui_egui::ControlRequest; use serde_json::{Value, json}; +/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; +/// anything longer is refused and the connection closed. +pub const MAX_LINE: usize = 4 * 1024 * 1024; +/// Connections served at once; further ones get an error line and are closed. +pub const MAX_CONNECTIONS: usize = 16; + pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); let listener = match TcpListener::bind(("127.0.0.1", port)) { @@ -19,44 +33,247 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }; eprintln!("designcraft: control server listening on 127.0.0.1:{port}"); - std::thread::spawn(move || { - for stream in listener.incoming().flatten() { - let tx = tx.clone(); - let ctx = ctx.clone(); - std::thread::spawn(move || serve(stream, tx, ctx)); - } - }); + std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { - let Ok(read) = stream.try_clone() else { return }; - let mut out = stream; - for line in BufReader::new(read).lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { +fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { + let open = Arc::new(AtomicUsize::new(0)); + for mut stream in listener.incoming().flatten() { + if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + open.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); continue; } - let reply = match serde_json::from_str::(&line) { - Ok(msg) => { - let id = msg.get("id").cloned().unwrap_or(Value::Null); - let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); - let params = msg.get("params").cloned().unwrap_or(json!({})); - let (req, rrx) = ControlRequest::new(method, params); - if tx.send(req).is_err() { - break; - } - ctx.request_repaint(); - let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); - if let Some(o) = r.as_object_mut() { - o.insert("id".into(), id); - } - r - } - Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), - }; - if writeln!(out, "{reply}").is_err() { - break; - } + let tx = tx.clone(); + let ctx = ctx.clone(); + let open = Arc::clone(&open); + std::thread::spawn(move || { + serve(stream, &tx, &ctx); + open.fetch_sub(1, Ordering::SeqCst); + }); + } +} + +fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + serve_lines(read, stream, |method, params| { + let (req, rrx) = ControlRequest::new(method, params); + tx.send(req).ok()?; + ctx.request_repaint(); + Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) + }); +} + +/// One parsed line. +enum Line { + Request { + id: Value, + method: String, + params: Value, + }, + Blank, + /// Not a request: reply with this error and close the connection. + Reject(String), +} + +fn parse_line(bytes: &[u8]) -> Line { + let Ok(text) = std::str::from_utf8(bytes) else { + return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); + }; + let text = text.trim(); + if text.is_empty() { + return Line::Blank; + } + let msg = match serde_json::from_str::(text) { + Ok(v) => v, + Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")), + }; + let Some(obj) = msg.as_object() else { + return Line::Reject("not a control request (expected a JSON object); closing the connection".into()); + }; + let Some(method) = obj.get("method").and_then(Value::as_str) else { + return Line::Reject("not a control request (missing string `method`); closing the connection".into()); + }; + Line::Request { + id: obj.get("id").cloned().unwrap_or(Value::Null), + method: method.to_string(), + params: obj.get("params").cloned().unwrap_or(json!({})), + } +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). +/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { + buf.clear(); + // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(false); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(true); + } + if buf.len() > max { + return Err(format!("request line longer than {max} bytes; closing the connection")); + } + // Last line without a newline, then end of stream. + Ok(true) +} + +/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is +/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and +/// returns the reply object (the request's `id` is added here). +fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option) { + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { + Ok(false) => return, + Err(e) => e, + Ok(true) => match parse_line(&buf) { + Line::Blank => continue, + Line::Reject(e) => e, + Line::Request { id, method, params } => { + let Some(mut r) = handle(method, params) else { return }; + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() { + return; + } + continue; + } + }, + }; + let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush()); + return; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(input: &[u8]) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut calls = Vec::new(); + serve_lines(input, &mut out, |m, _p| { + calls.push(m); + Some(json!({"ok": true, "result": null})) + }); + let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()).collect(); + (replies, calls) + } + + #[test] + fn requests_are_answered_in_order_with_ids() { + let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); + assert_eq!(calls, ["a", "b", "c"]); + assert_eq!(r.len(), 3); + assert_eq!(r[0]["id"], 1); + assert_eq!(r[1]["id"], 2); + assert_eq!(r[2]["id"], Value::Null); + } + + #[test] + fn http_request_closes_before_the_body_runs() { + // A browser's cross-origin `fetch` POST: the request line is rejected and the + // connection closed, so the JSON body is never executed. + let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n"; + let (r, calls) = run(http); + assert!(calls.is_empty(), "nothing may run: {calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + assert!(r[0]["error"].as_str().unwrap().contains("closing the connection")); + } + + #[test] + fn non_request_json_closes_the_connection() { + for junk in [ + &b"[1,2]\n{\"method\":\"a\"}\n"[..], + b"{\"id\":1}\n{\"method\":\"a\"}\n", + b"{\"method\":5}\n{\"method\":\"a\"}\n", + b"\xff\xfe\n{\"method\":\"a\"}\n", + ] { + let (r, calls) = run(junk); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["ok"], false); + } + } + + #[test] + fn a_request_before_junk_still_runs() { + let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n"); + assert_eq!(calls, ["a"]); + assert_eq!(r.len(), 2); + assert_eq!(r[1]["ok"], false); + } + + #[test] + fn overlong_line_is_refused() { + // One byte over the limit, then a valid request: neither runs. + let mut input = vec![b'x'; MAX_LINE + 1]; + input.extend_from_slice(b"\n{\"method\":\"a\"}\n"); + let (r, calls) = run(&input); + assert!(calls.is_empty(), "{calls:?}"); + assert_eq!(r.len(), 1); + assert!(r[0]["error"].as_str().unwrap().contains("longer than")); + } + + #[test] + fn line_at_the_limit_is_accepted() { + let mut buf = Vec::new(); + let line = vec![b'a'; 10]; + let mut input = line.clone(); + input.push(b'\n'); + assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true)); + assert_eq!(buf, line); + assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); + } + + #[test] + fn tcp_connection_is_closed_after_junk() { + // End to end over a real loopback socket (the transport the app uses). + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let read = s.try_clone().unwrap(); + serve_lines(read, s, |_, _| Some(json!({"ok": true}))); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + let mut text = String::new(); + c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection + server.join().unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].contains("\"id\":7")); + assert!(lines[1].contains("closing the connection")); + } + + #[test] + fn extra_connections_are_refused() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, _rx) = channel::(); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + // Idle connections are accepted in order and held open. + let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); + let mut extra = TcpStream::connect(addr).unwrap(); + extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut line = String::new(); + BufReader::new(&mut extra).read_line(&mut line).unwrap(); + assert!(line.contains("too many control connections"), "{line}"); + drop(held); } } diff --git a/contributors/contributors.json b/contributors/contributors.json new file mode 100644 index 0000000..c21fcc6 --- /dev/null +++ b/contributors/contributors.json @@ -0,0 +1,118 @@ +{ + "schema": 1, + "repo": "storytold/designcraft", + "branch": "main", + "head": "f17397c305ecda9d644105f7a4e9b9d65d34bb60", + "head_date": "2026-10-07T18:55:09Z", + "commits": 294, + "unattributed_commits": 0, + "contributors": [ + { + "login": "echelon", + "people_entry": true, + "github_name": null, + "prs": 22, + "commits": 260, + "lines_added": 99427, + "lines_deleted": 6061, + "binary_added": 42, + "binary_deleted": 2, + "first_commit": "2026-10-01T13:00:02Z", + "last_commit": "2026-10-07T18:55:09Z" + }, + { + "login": "storybored379", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 150, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-03T16:31:40Z", + "last_commit": "2026-10-05T03:53:26Z" + }, + { + "login": "CameronGilroy", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 18, + "lines_added": 5236, + "lines_deleted": 381, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-05T06:15:59Z", + "last_commit": "2026-10-05T18:56:22Z" + }, + { + "login": "midasdf", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 161, + "lines_deleted": 0, + "binary_added": 1, + "binary_deleted": 0, + "first_commit": "2026-10-05T15:01:53Z", + "last_commit": "2026-10-05T15:01:53Z" + }, + { + "login": "ShaoWenbinSaleh", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 6020, + "lines_deleted": 1278, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-05T19:15:57Z", + "last_commit": "2026-10-06T14:32:13Z" + }, + { + "login": "japbcoelho", + "people_entry": false, + "github_name": null, + "prs": 9, + "commits": 9, + "lines_added": 782, + "lines_deleted": 91, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T01:43:44Z", + "last_commit": "2026-10-06T01:45:41Z" + } + ], + "models": [ + { + "id": "anthropic/claude-opus/5.5", + "company": "Anthropic", + "model": "Claude Opus", + "version": "5.5", + "commits": 266, + "lines_added": 83821, + "lines_deleted": 6091 + }, + { + "id": "anthropic/claude-opus/4.8", + "company": "Anthropic", + "model": "Claude Opus", + "version": "4.8", + "commits": 1, + "lines_added": 1633, + "lines_deleted": 3 + }, + { + "id": "openai/codex", + "company": "OpenAI", + "model": "Codex", + "version": "", + "commits": 1, + "lines_added": 26, + "lines_deleted": 5 + } + ] +} diff --git a/contributors/people.toml b/contributors/people.toml new file mode 100644 index 0000000..89c6db2 --- /dev/null +++ b/contributors/people.toml @@ -0,0 +1,23 @@ +# Contributor names for the About window (craftrules standards/contributors.md). +# +# Everyone who contributes is credited by GitHub username automatically. Anything more is up to +# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a +# maintainer may add a person only when that person asked, recording where in consent_source. +# +# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ +# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] +# +# Or copy this block, replacing your-github-username (every field is optional): +# +# [people.your-github-username] +# real_name = "Your Name" # shown when the viewer picks "Real name" +# display_name = "Nickname" # shown when the viewer picks "Display name" +# sync_github_name = true # or: use the name on your public GitHub profile as display name +# +# To remove your names, delete your entry (or set fields empty) in a PR. The next +# contributors.py run, and the next build, drop them. + +[people.echelon] +real_name = "Brandon Thomas" +display_name = "Brandon Thomas" +sync_github_name = false diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 05d97a2..529c9b7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -375,10 +375,12 @@ fn emit( graphic: cell.graphic.clone(), }); // Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right. - // The table border replaces outer edges. + // The table border replaces outer edges unless the cell explicitly overrides it. let at_top = (y - top).abs() < 1e-6; let at_bottom = (y1 - bottom).abs() < 1e-6; - let edge = |i: usize, outer: bool| if outer { o.border.clone() } else { cell.strokes[i].clone() }; + let edge = |i: usize, outer: bool| { + if outer && !cell.border_overrides[i] { o.border.clone() } else { cell.strokes[i].clone() } + }; let mut push = |a: Point, b: Point, s: CellStroke| { if s.is_visible() { strokes.push(StrokeSeg { a, b, stroke: s }); @@ -474,3 +476,52 @@ pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: u None => Some((fi, c.origin.x, c.origin.y + 10.0, 9.0, 3.0)), } } + +#[cfg(test)] +mod border_tests { + use super::*; + use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; + + fn compose_table(table: Table) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) + } + + #[test] + fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.options.border.weight = 2.0; + table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); + let native = compose_table(table.clone()); + let strokes = &native.frames[0].tables[0].strokes; + assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); + assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); + + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true, true, true, false]; + cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); + let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); + assert_eq!(bottom.stroke.weight, 3.0); + let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); + assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); + } + + #[test] + fn merged_cell_keeps_explicit_border_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 1); + assert!(fragment.strokes.is_empty()); + } +} diff --git a/crates/doc/src/cjk.rs b/crates/doc/src/cjk.rs index 3af20dc..1ad2221 100644 --- a/crates/doc/src/cjk.rs +++ b/crates/doc/src/cjk.rs @@ -60,7 +60,7 @@ pub struct Kinsoku { impl Kinsoku { /// Explicitly supplied character sets take precedence over the fallback table. pub fn allows(&self, a: char, b: char) -> bool { - !self.no_end.contains(a) && !self.no_start.contains(b) && !(a == b && self.inseparable.contains(a)) + !(self.no_end.contains(a) || self.no_start.contains(b) || (a == b && self.inseparable.contains(a))) } } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index e31ecae..ff7844b 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -116,6 +116,10 @@ pub struct Cell { /// Edge strokes: top, left, bottom, right. #[serde(default)] pub strokes: [CellStroke; 4], + /// Explicit cell-edge formatting takes precedence over the table border, in the same + /// top, left, bottom, right order as `strokes`. Older documents keep their table border. + #[serde(default)] + pub border_overrides: [bool; 4], /// Applied cell style ("" = [None]). #[serde(default, skip_serializing_if = "String::is_empty")] pub style: String, @@ -137,6 +141,7 @@ impl Default for Cell { vj: VerticalJustification::Top, rotation: 0.0, strokes: Default::default(), + border_overrides: [false; 4], style: String::new(), graphic: None, } @@ -1105,6 +1110,20 @@ mod tests { let st: Story = serde_json::from_str(old).unwrap(); assert!(st.tables.is_empty()); } + + #[test] + fn cell_border_overrides_default_for_old_documents_and_roundtrip() { + let cell = Cell::default(); + let mut old = serde_json::to_value(&cell).unwrap(); + old.as_object_mut().unwrap().remove("borderOverrides"); + let back: Cell = serde_json::from_value(old).unwrap(); + assert_eq!(back.border_overrides, [false; 4]); + + let explicit = Cell { border_overrides: [true, false, true, false], ..cell }; + let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap(); + assert_eq!(back, explicit); + assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index f3856bf..df578e3 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -103,18 +103,25 @@ fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { Ok((spread_param(p, "spread"), pi, i)) } +/// Commands must use the same guide/layer protection as pointer editing. +fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str) -> Result<()> { + let g = d.spread(r).and_then(|sp| sp.pages.get(pi)).and_then(|pg| pg.guides.get(i)).ok_or_else(|| bad(command, "no such guide"))?; + if !g.editable_in(d) { + return Err(bad(command, "the guide or its layer is locked or hidden")); + } + Ok(()) +} + fn move_guide(s: &mut Session, p: &Value) -> Result { let (r, pi, i) = guide_ref(p, "guide.move")?; let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?; s.edit(|d, _| { + check_editable(d, r, pi, i, "guide.move")?; let g = d .spread_mut(r) .and_then(|sp| sp.pages.get_mut(pi)) .and_then(|pg| pg.guides.get_mut(i)) .ok_or_else(|| bad("guide.move", "no such guide"))?; - if g.locked { - return Err(bad("guide.move", "the guide is locked")); - } g.position = pos; Ok(Value::Null) }) @@ -123,6 +130,7 @@ fn move_guide(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let (r, pi, i) = guide_ref(p, "guide.delete")?; s.edit(|d, _| { + check_editable(d, r, pi, i, "guide.delete")?; let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?; if i >= pg.guides.len() { return Err(bad("guide.delete", "no such guide")); @@ -238,6 +246,26 @@ mod layer_tests { use crate::Session; + #[test] + fn guide_commands_respect_layer_locks() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": layer})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap(); + s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap(); + for command in ["guide.move", "guide.delete"] { + assert!(s.execute(command, &json!({"page": 0, "index": 0, "position": 200})).is_err(), "{command} changed a locked layer"); + assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 100.0); + } + s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap(); + s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 200})).unwrap(); + s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap(), json!([])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 200.0); + } + #[test] fn guides_follow_their_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 6d16417..030cf1b 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -34,16 +34,14 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { - let dir = dir.map(|d| d.to_path_buf()); - let read = move |link: &str| -> Option> { + #[cfg(not(target_arch = "wasm32"))] + let resolved = std::cell::RefCell::new(std::collections::HashMap::new()); + let read = |link: &str| -> Option> { #[cfg(not(target_arch = "wasm32"))] { - if let Ok(b) = std::fs::read(link) { - return Some(b); - } - let name = link.rsplit(['/', '\\']).next()?; - let dir = dir.as_ref()?; - std::fs::read(dir.join(name)).or_else(|_| std::fs::read(dir.join("Links").join(name))).ok() + let (path, data) = read_packaged_link(link, dir)?; + resolved.borrow_mut().insert(link.to_string(), path); + Some(data) } #[cfg(target_arch = "wasm32")] { @@ -51,7 +49,53 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { None } }; - designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string())) + let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; + #[cfg(not(target_arch = "wasm32"))] + let document = { + let mut document = document; + for asset in document.assets.values_mut() { + if let Some(path) = asset.link.as_ref().and_then(|link| resolved.borrow().get(link).cloned()) { + // Relocated packages must also use the new path for preflight and link updates. + std::sync::Arc::make_mut(asset).link = Some(path); + } + } + document + }; + Ok(document) +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { + let mut candidates = vec![std::path::PathBuf::from(link)]; + if let Some(dir) = dir { + // IDML may retain Windows paths even when the package is opened on another platform. + let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect(); + let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':'); + if !link.starts_with(['/', '\\']) && parts.iter().all(safe_part) { + candidates.push(dir.join(parts.iter().collect::())); + } + // Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai). + // Preserve that suffix instead of flattening every resource to its basename. + if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) { + let suffix = &parts[index + 1..]; + if !suffix.is_empty() && suffix.iter().all(safe_part) { + candidates.push(dir.join("Links").join(suffix.iter().collect::())); + } + } + if let Some(name) = parts.last().filter(|part| safe_part(part)) { + candidates.push(dir.join(name)); + candidates.push(dir.join("Links").join(name)); + } + } + for path in candidates { + if let Ok(bytes) = std::fs::read(&path) { + // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize + // would introduce a verbatim prefix that is not an IDML file URI. + let path = std::path::absolute(&path).unwrap_or(path); + return Some((path.to_string_lossy().into_owned(), bytes)); + } + } + None } pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { @@ -81,3 +125,58 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { let i = s.add_document(st); Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::sync::Arc; + + use super::*; + + fn linked_idml(link: &str) -> Result<(Vec, Vec)> { + let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png(); + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + session.execute("file.place", &json!({"base64": base64_encode(&png), "name": "logo.png"}))?; + session.edit(|doc, _| { + let asset = doc.assets.values_mut().next().ok_or_else(|| bad("fixture", "missing graphic"))?; + Arc::make_mut(asset).link = Some(link.into()); + Ok(Value::Null) + })?; + let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images: false }); + Ok((bytes, png)) + } + + #[test] + fn opens_nested_packaged_links_from_a_different_computer() { + let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id())); + let path = dir.join("Links").join("illustrations").join("logo.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png").unwrap(); + std::fs::write(&path, &png).unwrap(); + std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap(); + + let doc = import(&idml, Some(&dir)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists"); + let resolved = std::path::absolute(&path).unwrap(); + assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file"); + assert_eq!(super::super::links::status(asset), "ok"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn remembers_flat_packaged_link_location() { + let dir = std::env::temp_dir().join(format!("dc-idml-flat-links-{}", std::process::id())); + let path = dir.join("Links").join("logo.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let (idml, png) = linked_idml("/original-computer/project/logo.png").unwrap(); + std::fs::write(&path, &png).unwrap(); + + let doc = import(&idml, Some(&dir)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png); + assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); + assert_eq!(super::super::links::status(asset), "ok"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 543c947..4177f94 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -594,6 +594,13 @@ pub fn system_font_dirs() -> Vec { } } } + // Microsoft Office bundles its fonts (Calibri, Cambria, …) inside each app; read them in + // place from the first installed one (they share the same set). + let office = ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"] + .iter() + .map(|app| std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts"))) + .find(|p| p.is_dir()); + dirs.extend(office); } else if cfg!(windows) { let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); dirs.push(root.join("Fonts")); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 5192aa3..919991e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2198,6 +2198,9 @@ impl<'a> Ex<'a> { .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); + if cell.border_overrides[i] { + ce.set(&format!("{side}StrokePriority"), 1); + } } for psr in self.story_paras(&cell.text) { ce.push(psr); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index da498a2..3c88244 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1548,6 +1548,9 @@ impl<'r> Importer<'r> { for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let base = cell.strokes[i].clone(); cell.strokes[i] = stroke(self, ce, side, &base); + // A positive edge priority records a local cell override, including an + // explicit None stroke. Keep it when this edge meets the table perimeter. + cell.border_overrides[i] = ce.num(&format!("{side}StrokePriority")).is_some_and(|priority| priority > 0.0); } if let Some(slot) = t.cell_mut(r, c) { *slot = cell; @@ -2350,13 +2353,8 @@ fn text_frame_options(e: &El) -> TextFrameOptions { o.column_width = v; } o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); - if let Some(l) = e.prop_el("InsetSpacing") { - let v: Vec = l.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect(); - if v.len() == 4 { - o.inset = [v[0], v[1], v[2], v[3]]; - } - } else if let Some(v) = e.num("InsetSpacing") { - o.inset = [v; 4]; + if let Some(inset) = inset_spacing(e) { + o.inset = inset; } if let Some(v) = e.get("VerticalJustification") { o.vertical_justification = names::vj_in(v); @@ -2380,6 +2378,32 @@ fn text_frame_options(e: &El) -> TextFrameOptions { o } +/// A scalar applies to every edge; a list is top, left, bottom, right. Keep the +/// existing property-element precedence over the legacy scalar attribute, even +/// when the property is malformed. Finite negative insets remain supported. +fn inset_spacing(e: &El) -> Option<[f64; 4]> { + let number = |s: &str| s.trim().parse::().ok().filter(|v| v.is_finite()); + let Some(property) = e.prop_el("InsetSpacing") else { + return e.get("InsetSpacing").and_then(number).map(|v| [v; 4]); + }; + let mut items = property.elements(); + if property.get("type") == Some("list") || property.find("ListItem").is_some() { + let mut inset = [0.0; 4]; + for edge in &mut inset { + let item = items.next()?; + if item.local() != "ListItem" || item.elements().next().is_some() { + return None; + } + *edge = number(&item.text_content())?; + } + items.next().is_none().then_some(inset) + } else if items.next().is_none() { + number(&property.text_content()).map(|v| [v; 4]) + } else { + None + } +} + fn path_of(pg: &El) -> PathData { let mut subs = Vec::new(); for g in pg.find_all("GeometryPathType") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b0eb583..b636a2a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -269,6 +269,119 @@ fn imports_hand_written_fixture() { assert_eq!(a.name, "photo.jpg"); } +/// Hand-written inset encodings, independent of the exporter's four-item list. +fn inset_fixture(preference: &str, object_styles: &str) -> Document { + let designmap = format!( + r#" + {object_styles} + + "# + ); + let spread = format!( + r#" + + + + + + + + {preference} + + + "# + ); + import_idml(&zip_files(&[("designmap.xml", &designmap), ("Spreads/Spread_s.xml", &spread)])).unwrap() +} + +fn frame_inset(d: &Document) -> [f64; 4] { + d.spreads[0].items[0].text_frame().unwrap().options.inset +} + +fn inset_list(values: &[&str]) -> String { + let items = values.iter().map(|v| format!(r#"{v}"#)).collect::(); + format!(r#"{items}"#) +} + +#[test] +fn imports_scalar_inset_properties_like_lists_and_attributes() { + let scalar = + inset_fixture(r#" 9 "#, ""); + let list = inset_fixture(&format!("{}", inset_list(&["9"; 4])), ""); + let attribute = inset_fixture(r#""#, ""); + let untyped = inset_fixture(r#"9"#, ""); + assert_eq!(frame_inset(&scalar), [9.0; 4]); + assert_eq!(frame_inset(&scalar), frame_inset(&list)); + assert_eq!(frame_inset(&scalar), frame_inset(&attribute)); + assert_eq!(frame_inset(&scalar), frame_inset(&untyped)); + assert_eq!(scalar.spreads[0].items[0].text_area(), Rect::new(9.0, 9.0, 91.0, 91.0)); + let round_trip = import_idml(&export_idml(&scalar)).unwrap(); + assert_eq!(frame_inset(&round_trip), [9.0; 4]); +} + +#[test] +fn inset_property_keeps_precedence_over_attribute() { + for (property, expected) in [ + (r#"9"#.to_string(), [9.0; 4]), + (inset_list(&["1", "2", "3", "4"]), [1.0, 2.0, 3.0, 4.0]), + (inset_list(&["1", "2", "3"]), [0.0; 4]), + (r#"invalid"#.to_string(), [0.0; 4]), + ] { + let d = inset_fixture(&format!(r#"{property}"#), ""); + assert_eq!(frame_inset(&d), expected, "{property}"); + } +} + +#[test] +fn inset_spacing_rejects_malformed_and_nonfinite_values() { + let mut preferences = vec![ + String::new(), + "".to_string(), + r#"9"#.to_string(), + r#"9"# + .to_string(), + ]; + for value in ["", "invalid", "NaN", "inf", "-inf", "1e309"] { + preferences.push(format!(r#""#)); + preferences + .push(format!(r#"{value}"#)); + preferences.push(format!("{}", inset_list(&["1", value, "3", "4"]))); + } + for values in [&[][..], &["9"][..], &["1", "2", "3"][..], &["1", "2", "3", "4", "5"][..], &["1", "bad", "2", "3", "4"][..]] { + preferences.push(format!("{}", inset_list(values))); + } + preferences.push(format!("{}", inset_list(&["1", "2", "3", "4"]))); + for preference in preferences { + assert_eq!(frame_inset(&inset_fixture(&preference, "")), [0.0; 4], "{preference}"); + } +} + +#[test] +fn inset_spacing_preserves_finite_signed_values_and_edge_order() { + for value in ["0", "-2.5", " 4.5 "] { + let expected = [value.trim().parse::().unwrap(); 4]; + for preference in [ + format!(r#""#), + format!(r#"{value}"#), + format!("{}", inset_list(&[value; 4])), + ] { + assert_eq!(frame_inset(&inset_fixture(&preference, "")), expected, "{preference}"); + } + } + let d = inset_fixture(&format!("{}", inset_list(&["1", "-2", "3.5", "4"])), ""); + assert_eq!(frame_inset(&d), [1.0, -2.0, 3.5, 4.0], "top, left, bottom, right"); +} + +#[test] +fn imports_scalar_insets_in_object_style_without_replacing_frame_override() { + let styles = r#" + 9 + "#; + let d = inset_fixture(r#""#, styles); + assert_eq!(d.styles.object.iter().find(|s| s.name == "Padded").unwrap().text_frame.as_ref().unwrap().inset, [9.0; 4]); + assert_eq!(frame_inset(&d), [3.0; 4]); +} + #[test] fn rejects_non_idml() { assert!(import_idml(b"not a zip").is_err()); @@ -383,6 +496,40 @@ fn round_trips_tables() { assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand")); } +#[test] +fn imports_and_round_trips_explicit_cell_border_overrides() { + // Hand-written IDML: the outer cell edge may explicitly suppress or replace the + // table border. An absent/zero priority keeps the table border's precedence. + let story = r#" + + + + + + A + + B +
"#; + let d = import_idml(&fixture_with_story(story)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let cell = table.cell(0, 0).unwrap(); + assert_eq!(cell.border_overrides, [true, true, false, false]); + assert!(!cell.strokes[0].is_visible(), "explicit None is retained"); + assert_eq!(cell.strokes[1].color, "Brand"); + assert_eq!(cell.strokes[1].weight, 3.0); + assert_eq!(table.options.border.weight, 2.0); + assert_eq!(table.cell(0, 1).unwrap().border_overrides, [false; 4]); + + let back = import_idml(&export_idml(&d)).unwrap(); + let roundtrip = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(roundtrip.cell(0, 0).unwrap().border_overrides, cell.border_overrides); + assert_eq!(roundtrip.cell(0, 0).unwrap().strokes, cell.strokes); + assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]); +} + #[test] fn round_trips_footnotes_and_options() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index db2fe6f..903313e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -207,11 +207,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X). let mut warnings = ex.warnings; if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) { + // The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any + // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); - // The rewrite matches krilla's exact output; say so if a group got past it. - if opts.standard == Standard::PdfX4 && crate::pdfx::has_rgb_groups(&bytes) { - warnings.push("PDF/X-4: some transparency groups still blend in RGB".into()); - } } // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X. let fields = crate::forms::collect(doc, &sheets); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index df05552..ad3a140 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -2,6 +2,12 @@ //! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a //! check of the requirements this exporter is responsible for. +use std::collections::HashSet; + +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream}; +use hayro_syntax::page::Resources; + use crate::transitions::{find, int_after, object, rfind}; /// Escape a PDF literal string. @@ -187,7 +193,8 @@ fn dictionary_ranges(pdf: &[u8]) -> Option>> { Some(out) } -/// PDF/X-4 checks on an exported file: what's missing (empty = passes these checks). +/// PDF/X-4 checks on an exported file: what's missing or not allowed, device RGB under the CMYK +/// output intent included (empty = passes these checks). pub fn check_pdfx4(pdf: &[u8]) -> Vec { let mut issues = Vec::new(); let text = String::from_utf8_lossy(pdf); @@ -217,5 +224,445 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if text.contains("/Subtype/Type3") { issues.push("Type 3 fonts aren't allowed".into()); } + issues.extend(device_rgb(pdf)); issues } + +/// Device-dependent RGB under a CMYK output intent. PDF/X-4 (ISO 15930-7) lets RGB in only when +/// it is colour-managed: ICCBased RGB, or DeviceRGB remapped by a `DefaultRGB` colour space in the +/// resources. DeviceCMYK and DeviceGray are the output intent's own device values, so they stay. +/// This walks every page's content, the images, forms, patterns, shadings and soft masks it uses, +/// and the transparency groups, and reports each kind of device RGB it finds with the pages it is +/// on. Nothing is reported when the file has no CMYK PDF/X output intent. +fn device_rgb(pdf: &[u8]) -> Vec { + let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { + return vec!["the file couldn't be read to check its colour spaces".into()]; + }; + if !cmyk_output_intent(&file) { + return Vec::new(); + } + let mut found: Vec<(String, Vec)> = Vec::new(); + for (i, page) in file.pages().iter().enumerate() { + let mut scan = RgbScan::default(); + let res = page.resources(); + if let Some(group) = page.raw().get::>(b"Group") { + scan.group(&group, res); + } + if let Some(content) = page.page_stream() { + scan.content(content, res, 0); + } + for what in scan.found { + match found.iter_mut().find(|(w, _)| *w == what) { + Some((_, pages)) => pages.push(i + 1), + None => found.push((what, vec![i + 1])), + } + } + } + found + .into_iter() + .map(|(what, pages)| { + let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let more = if pages.len() > 10 { ", …" } else { "" }; + let s = if pages.len() == 1 { "" } else { "s" }; + format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", ")) + }) + .collect() +} + +/// Whether the file's PDF/X output intent has a four-channel (CMYK) destination profile. +fn cmyk_output_intent(file: &hayro_syntax::Pdf) -> bool { + let xref = file.xref(); + let Some(catalog) = xref.get::>(xref.root_id()) else { return false }; + let Some(intents) = catalog.get::>(b"OutputIntents") else { return false }; + intents.iter::>().any(|oi| { + oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX") + && oi.get::>(b"DestOutputProfile").and_then(|p| p.dict().get::(b"N")) == Some(4) + }) +} + +/// Whether a colour space is device RGB: `/DeviceRGB` (`/RGB` in inline images), or an +/// `/Indexed` space over it. +fn is_device_rgb(cs: &Object<'_>) -> bool { + match cs { + Object::Name(n) => matches!(n.as_ref(), b"DeviceRGB" | b"RGB"), + Object::Array(a) => { + let mut items = a.iter::>(); + match items.next() { + Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I") => { + // The base space is a name or a (non-indexed) array; no deeper nesting. + matches!(items.next(), Some(Object::Name(b)) if matches!(b.as_ref(), b"DeviceRGB" | b"RGB")) + } + _ => false, + } + } + _ => false, + } +} + +/// How deep forms, patterns and soft masks may nest before the walk stops. +const MAX_DEPTH: usize = 16; + +/// What one page uses in device RGB, in the order found. +#[derive(Default)] +struct RgbScan { + found: Vec, + /// Forms, images and patterns already looked at on this page. + seen: HashSet, +} + +impl RgbScan { + fn add(&mut self, what: String) { + if !self.found.contains(&what) { + self.found.push(what); + } + } + + /// `DefaultRGB` in scope makes DeviceRGB colour-managed. + fn managed(res: &Resources<'_>) -> bool { + res.get_color_space(&Name::new_unescaped(b"DefaultRGB")).is_some() + } + + /// A content stream (a page's or a form's) with its resources. + fn content(&mut self, data: &[u8], res: &Resources<'_>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let managed = Self::managed(res); + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + let operator: &[u8] = op.operator; + let last = op.operands().last().cloned(); + let name = || last.clone().and_then(Object::into_name); + match operator { + b"rg" | b"RG" if !managed => self.add("RGB fills or strokes".into()), + b"cs" | b"CS" if !managed => { + let rgb = name().is_some_and(|n| n.as_ref() == b"DeviceRGB" || res.get_color_space(&n).is_some_and(|cs| is_device_rgb(&cs))); + if rgb { + self.add("RGB fills or strokes".into()); + } + } + b"sh" => { + if let Some(sh) = name().and_then(|n| res.get_shading(&n)) { + self.shading(sh, managed); + } + } + b"scn" | b"SCN" => { + if let Some(p) = name().and_then(|n| res.get_pattern(&n)) { + self.pattern(p, res, managed, depth); + } + } + b"gs" => { + let mask = name().and_then(|n| res.get_ext_g_state(&n)).and_then(|gs| gs.get::>(b"SMask")); + if let Some(form) = mask.and_then(|m| m.get::>(b"G")) { + self.form(&form, res, depth); + } + } + b"Do" => { + if let Some(xo) = name().and_then(|n| res.get_x_object(&n)) { + match xo.dict().get::>(b"Subtype").as_deref() { + Some(b"Image") => self.image(&xo, managed), + Some(b"Form") => self.form(&xo, res, depth), + _ => {} + } + } + } + b"BI" | b"EI" if !managed => { + if let Some(Object::Stream(img)) = last { + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + let cs = match cs { + Some(Object::Name(n)) + if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => + { + res.get_color_space(&n) + } + cs => cs, + }; + if cs.is_some_and(|cs| is_device_rgb(&cs)) { + self.add("an RGB inline image".into()); + } + } + } + _ => {} + } + } + } + + fn image(&mut self, img: &Stream<'_>, managed: bool) { + let id = img.obj_id(); + if managed || !self.seen.insert(id) || img.dict().get::(b"ImageMask") == Some(true) { + return; + } + if img.dict().get::>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) { + let (w, h) = (img.dict().get::(b"Width").unwrap_or(0), img.dict().get::(b"Height").unwrap_or(0)); + self.add(format!("an RGB image (object {}, {w} × {h} px)", id.obj_number)); + } + } + + /// A form XObject (placed PDF page, group, soft mask): its group, then its content. + fn form(&mut self, form: &Stream<'_>, parent: &Resources<'_>, depth: usize) { + if depth >= MAX_DEPTH || !self.seen.insert(form.obj_id()) { + return; + } + let res = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), parent.clone()); + if let Some(group) = form.dict().get::>(b"Group") { + self.group(&group, &res); + } + if let Ok(data) = form.decoded() { + self.content(&data, &res, depth + 1); + } + } + + fn group(&mut self, group: &Dict<'_>, res: &Resources<'_>) { + if !Self::managed(res) && group.get::>(b"CS").is_some_and(|cs| is_device_rgb(&cs)) { + self.add("a transparency group blending in RGB".into()); + } + } + + /// A shading dictionary or stream (gradients). + fn shading(&mut self, sh: Object<'_>, managed: bool) { + let dict = match sh { + Object::Dict(d) => d, + Object::Stream(s) => s.dict().clone(), + _ => return, + }; + if !managed && dict.get::>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) { + self.add("RGB gradients".into()); + } + } + + /// A shading pattern (its shading) or a tiling pattern (its cell, like a form). + fn pattern(&mut self, p: Object<'_>, res: &Resources<'_>, managed: bool, depth: usize) { + match p { + Object::Dict(d) => { + if let Some(sh) = d.get::>(b"Shading") { + self.shading(sh, managed); + } + } + Object::Stream(s) => self.form(&s, res, depth), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use designcraft_color::{Color, Swatch}; + use designcraft_compose::Cache; + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Fitting, Graphic, Item, ItemId, Shape, SpreadRef}; + use designcraft_geom::{Affine, Rect}; + use hayro_syntax::object::{Name, Object}; + use image::{ExtendedColorType, ImageEncoder}; + + use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report}; + + fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { + let asset = AssetId(d.alloc()); + let mime = designcraft_images::mime(&data).into(); + let pixels = designcraft_images::pixel_size(&data); + d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, link: None, data: data.into(), pixels, page: 0 })); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); + let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.content = Content::Graphic(Graphic { + asset, + size, + xf: Affine::translate(at) * Affine::scale(100.0 / size.0), + auto_fit: Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + + fn add_box(d: &mut Document, r: Rect, swatch: &str) { + let lid = d.default_layer(); + let mut b = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + b.fill = Fill::swatch(swatch); + d.insert_item(SpreadRef::Doc(0), b, None).unwrap(); + } + + /// A 4 × 2 PNG: red RGB or mid grey, optionally carrying an ICC profile. + fn png(gray: bool, icc: Option>) -> Vec { + let mut b = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut b); + if let Some(p) = icc { + enc.set_icc_profile(p).unwrap(); + } + let (px, ty) = if gray { (vec![128u8; 8], ExtendedColorType::L8) } else { ([255u8, 0, 0].repeat(8), ExtendedColorType::Rgb8) }; + enc.write_image(&px, 4, 2, ty).unwrap(); + b + } + + /// The header of an ICC v2.1 RGB display profile: what a PDF writer reads to tag an image + /// `/ICCBased` (the check looks at the colour space, not at the profile's tables). + fn rgb_icc() -> Vec { + let mut p = vec![0u8; 132]; + p[..4].copy_from_slice(&132u32.to_be_bytes()); + p[8] = 2; + p[9] = 0x10; + p[12..16].copy_from_slice(b"mntr"); + p[16..20].copy_from_slice(b"RGB "); + p[20..24].copy_from_slice(b"XYZ "); + p[36..40].copy_from_slice(b"acsp"); + p + } + + /// A 4 × 2 CMYK image (100% K) as krilla writes it. + #[derive(Clone, Hash)] + struct CmykImage(Vec); + + impl krilla::image::CustomImage for CmykImage { + fn color_channel(&self) -> &[u8] { + &self.0 + } + fn alpha_channel(&self) -> Option<&[u8]> { + None + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + (4, 2) + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Cmyk + } + } + + /// A one-page PDF 1.6 holding a CMYK image, as another app would hand over a placed graphic. + fn pdf_with_cmyk_image() -> Vec { + let configuration = krilla::configure::ConfigurationBuilder::new().with_version(krilla::configure::PdfVersion::Pdf16).finish().unwrap(); + let mut pdf = krilla::Document::new_with(krilla::SerializeSettings { configuration, ..Default::default() }); + let size = krilla::geom::Size::from_wh(100.0, 50.0).unwrap(); + let mut page = pdf.start_page_with(krilla::page::PageSettings::new(size)); + let mut s = page.surface(); + let img = krilla::image::Image::from_custom(CmykImage([0u8, 0, 0, 255].repeat(8)), false).unwrap(); + s.draw_image(img, size); + s.finish(); + page.finish(); + pdf.finish().unwrap() + } + + fn x4(d: &Document) -> ExportReport { + export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap() + } + + /// The `/ColorSpace` of every image XObject: a name, or `[` for an array (ICCBased, Indexed…). + fn image_spaces(bytes: &[u8]) -> Vec { + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + let mut out: Vec = pdf + .objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::(b"Subtype").is_some_and(|n| n.as_str() == "Image") && s.dict().get::(b"ImageMask").is_none()) + .filter_map(|s| match s.dict().get::(b"ColorSpace")? { + Object::Name(n) => Some(n.as_str().to_string()), + Object::Array(a) => a.iter::().next().map(|n| format!("[{}", n.as_str())), + _ => None, + }) + .collect(); + out.sort(); + out + } + + /// A placed RGB PNG went into PDF/X-4 as DeviceRGB under the CMYK output intent with no + /// warning (#35's fallback). + #[test] + fn device_rgb_image_is_flagged() { + let mut d = Document::new(&NewDocument::default()); + place(&mut d, "photo.png", png(false, None), (4.0, 2.0), (72.0, 72.0)); + let r = x4(&d); + assert_eq!(image_spaces(&r.bytes), ["DeviceRGB"]); + let rgb: Vec<&String> = r.warnings.iter().filter(|w| w.contains("RGB")).collect(); + assert_eq!(rgb.len(), 1, "{:?}", r.warnings); + assert!(rgb[0].starts_with("PDF/X-4: an RGB image (object ") && rgb[0].contains("4 × 2 px) on page 1:"), "{}", rgb[0]); + // Without a PDF/X output intent there is nothing to check RGB against. + let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(!check_pdfx4(&plain.bytes).iter().any(|i| i.contains("RGB"))); + } + + /// CMYK and grey are the output intent's own values and ICC-tagged RGB is colour-managed: + /// none of them is flagged, whether placed directly or inside a placed PDF. + #[test] + fn cmyk_grey_and_icc_rgb_pass() { + let mut d = Document::new(&NewDocument::default()); + add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "C=100 M=0 Y=0 K=0"); + place(&mut d, "grey.png", png(true, None), (4.0, 2.0), (72.0, 72.0)); + place(&mut d, "tagged.png", png(false, Some(rgb_icc())), (4.0, 2.0), (72.0, 172.0)); + place(&mut d, "logo.pdf", pdf_with_cmyk_image(), (100.0, 50.0), (72.0, 272.0)); + let r = x4(&d); + assert_eq!(image_spaces(&r.bytes), ["DeviceCMYK", "DeviceGray", "[ICCBased"]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + + /// RGB swatches paint in DeviceRGB. + #[test] + fn device_rgb_fills_are_flagged() { + let mut d = Document::new(&NewDocument { pages: 2, ..NewDocument::default() }); + d.swatches.push(Swatch::color("Brand Red", Color::rgb(0.9, 0.1, 0.1))); + add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "Brand Red"); + let r = x4(&d); + assert_eq!(r.warnings, ["PDF/X-4: RGB fills or strokes on page 1: under a CMYK output intent RGB needs an ICC profile"]); + } + + /// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its + /// resources, a group…) and `content`. + fn tiny_pdf(entries: &str, content: &str) -> Vec { + let objects = [ + "<>]>>".to_string(), + "<>".to_string(), + format!("<>"), + "<>\nstream\n\nendstream".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>\nstream\n\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out + } + + /// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed. + #[test] + fn default_rgb_makes_device_rgb_managed() { + let rgb = |i: &String| i.contains("RGB"); + let content = "1 0 0 rg 0 0 10 10 re f /DeviceRGB CS 0 0 1 SC 0 0 m 10 10 l S"; + let bare = check_pdfx4(&tiny_pdf("/Resources<<>>", content)); + assert_eq!(bare.iter().filter(|i| rgb(i)).count(), 1, "{bare:?}"); + let managed = check_pdfx4(&tiny_pdf("/Resources<>>>", content)); + assert!(!managed.iter().any(rgb), "{managed:?}"); + // CMYK and grey need no default space. + let cmyk = check_pdfx4(&tiny_pdf("/Resources<<>>", "0 0 0 1 k 0 0 10 10 re f 0.5 G 0 0 m 10 10 l S")); + assert!(!cmyk.iter().any(rgb), "{cmyk:?}"); + } + + /// An RGB blending space on the page group and an RGB inline image are device RGB too. + #[test] + fn rgb_groups_and_inline_images_are_flagged() { + let group = check_pdfx4(&tiny_pdf("/Resources<<>>/Group<>", "0 0 0 1 k")); + assert!( + group.iter().any(|i| i == "a transparency group blending in RGB on page 1: under a CMYK output intent RGB needs an ICC profile"), + "{group:?}" + ); + let inline = check_pdfx4(&tiny_pdf("/Resources<<>>", "q BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q")); + assert!(inline.iter().any(|i| i.starts_with("an RGB inline image on page 1")), "{inline:?}"); + let grey = check_pdfx4(&tiny_pdf("/Resources<<>>", "q BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 80> EI Q")); + assert!(!grey.iter().any(|i| i.contains("RGB")), "{grey:?}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6695d2c..c1b288d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -695,16 +695,17 @@ impl Renderer { ctx.pop_layer(); } StrokeAlign::Outside if closed => { - let outline = - kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px); - let mut p = outline; - // Remove the interior: even-odd with the path itself. - for el in bp.elements() { - p.push(*el); - } - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.fill_path(&p); - ctx.set_fill_rule(peniko::Fill::NonZero); + // Subtract the filled path from a double-width stroke. Combining a stroked + // outline (already hollow) with the path using even-odd would paint its centre. + // Isolate the stroke so the subtraction leaves the item's fill/content and + // backdrop intact; the same nonzero fill rule also preserves compound holes. + ctx.push_layer(None, None, None, None, None); + ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke }); + ctx.stroke_path(bp); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + ctx.fill_path(bp); + ctx.pop_layer(); + ctx.pop_layer(); } _ => { ctx.set_stroke(stroke); diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/stroke_alignment.rs new file mode 100644 index 0000000..eee8450 --- /dev/null +++ b/crates/render/tests/stroke_alignment.rs @@ -0,0 +1,156 @@ +//! Synthetic pixel regressions for stroke alignment. No imported artwork is needed. +use designcraft_color::{Color, swatch::Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Join, Shape, SpreadRef, Stroke, StrokeAlign, StrokeType}; +use designcraft_geom::{Affine, PathData, Point, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +fn render(path: PathData, fill: bool, stroke: Stroke, xf: Affine, threads: u16) -> Rendered { + render_with(path, fill, stroke, xf, threads, 1.0, Some(BLUE)) +} + +fn render_with(path: PathData, fill: bool, stroke: Stroke, xf: Affine, threads: u16, opacity: f32, background: Option<[u8; 4]>) -> Rendered { + let mut d = Document::new(&NewDocument { width: 96.0, height: 96.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0))); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, path); + item.fill = if fill { Fill::swatch(designcraft_color::swatch::PAPER) } else { Fill::default() }; + item.stroke = stroke; + item.xf = xf; + item.opacity = opacity; + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = threads; + renderer.render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 96, + 96, + Affine::IDENTITY, + &RenderOptions { paper: false, background, ..Default::default() }, + ) +} + +fn stroke(align: StrokeAlign) -> Stroke { + Stroke { swatch: "Red".into(), weight: 6.0, align, ..Default::default() } +} + +fn rectangle() -> PathData { + shapes::rectangle(Rect::new(24.0, 24.0, 72.0, 72.0)) +} + +#[test] +fn outside_stroke_keeps_white_center_and_does_not_cover_fill() { + let img = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), WHITE, "an outside stroke must not paint the center"); + assert_eq!(img.pixel(26, 48), WHITE, "the inside half of the doubled stroke is excluded"); + assert_eq!(img.pixel(20, 48), RED, "the full stroke weight is outside the path"); + assert_eq!(img.pixel(16, 48), BLUE, "the outside stroke remains bounded"); +} + +#[test] +fn outside_stroke_preserves_backdrop_without_a_fill() { + let img = render(rectangle(), false, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), BLUE); + assert_eq!(img.pixel(26, 48), BLUE, "removing the inner stroke must not erase the backdrop"); + assert_eq!(img.pixel(20, 48), RED); +} + +#[test] +fn outside_stroke_preserves_transparency() { + let img = render_with(rectangle(), false, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0, 1.0, None); + assert_eq!(img.pixel(48, 48), [0; 4]); + assert_eq!(img.pixel(26, 48), [0; 4]); + assert_eq!(img.pixel(20, 48), RED); + assert_eq!(img.pixel(16, 48), [0; 4]); +} + +#[test] +fn outside_stroke_applies_item_opacity_once() { + let img = render_with(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0, 0.5, None); + for (x, expected) in [(48, [255, 255, 255]), (26, [255, 255, 255]), (20, [255, 0, 0])] { + let pixel = img.pixel(x, 48); + assert_eq!(&pixel[..3], &expected, "colour at {x}"); + assert!(pixel[3].abs_diff(128) <= 1, "opacity at {x}: {pixel:?}"); + } + assert_eq!(img.pixel(16, 48), [0; 4]); +} + +#[test] +fn outside_stroke_follows_compound_holes_and_nonzero_winding() { + // Opposite winding creates a hole. Its boundary strokes toward the hole. + let mut path = rectangle(); + let mut hole = shapes::rectangle(Rect::new(36.0, 36.0, 60.0, 60.0)); + for subpath in &mut hole.subpaths { + subpath.reverse(); + } + path.subpaths.extend(hole.subpaths); + let img = render(path, true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(30, 48), WHITE, "stroke must stay out of the filled area"); + assert_eq!(img.pixel(38, 48), RED, "the hole is outside the compound shape"); + assert_eq!(img.pixel(48, 48), BLUE, "the hole center remains unpainted"); + assert_eq!(img.pixel(20, 48), RED); + + // Same-winding nested paths do not make a hole under the renderer's nonzero fill rule. + let mut path = rectangle(); + path.subpaths.extend(shapes::rectangle(Rect::new(36.0, 36.0, 60.0, 60.0)).subpaths); + let img = render(path, true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + assert_eq!(img.pixel(38, 48), WHITE, "nested filled contours must not acquire interior ink"); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(20, 48), RED); +} + +#[test] +fn stroke_alignment_preserves_widths_and_open_path_behavior() { + for (align, exterior, interior) in [(StrokeAlign::Outside, RED, WHITE), (StrokeAlign::Inside, BLUE, RED), (StrokeAlign::Center, BLUE, WHITE)] { + let img = render(rectangle(), true, stroke(align), Affine::IDENTITY, 0); + assert_eq!(img.pixel(20, 48), exterior, "{align:?} exterior"); + assert_eq!(img.pixel(28, 48), interior, "{align:?} interior"); + assert_eq!(img.pixel(48, 48), WHITE); + } + let line = shapes::line(Point::new(24.0, 48.0), Point::new(72.0, 48.0)); + let centered = render(line.clone(), false, stroke(StrokeAlign::Center), Affine::IDENTITY, 0); + for align in [StrokeAlign::Inside, StrokeAlign::Outside] { + let img = render(line.clone(), false, stroke(align), Affine::IDENTITY, 0); + assert_eq!(img.pixels, centered.pixels, "open paths always use centered strokes"); + } +} + +#[test] +fn outside_stroke_retains_joins_dashes_and_transforms() { + for join in [Join::Miter, Join::Round, Join::Bevel] { + let mut st = stroke(StrokeAlign::Outside); + st.join = join; + let img = render(rectangle(), true, st, Affine::IDENTITY, 0); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(20, 48), RED); + assert_eq!(img.pixel(18, 18), if join == Join::Miter { RED } else { BLUE }, "{join:?} corner"); + } + let mut dashed = stroke(StrokeAlign::Outside); + dashed.kind = StrokeType::Dashed { pattern: vec![12.0, 12.0] }; + let img = render(rectangle(), true, dashed, Affine::IDENTITY, 0); + assert_eq!(img.pixel(30, 20), RED, "dash stays outside"); + assert_eq!(img.pixel(42, 20), BLUE, "dash gap stays empty"); + assert_eq!(img.pixel(30, 26), WHITE, "dash stays out of the fill"); + assert_eq!(img.pixel(48, 48), WHITE); + + let xf = Affine::translate((48.0, 48.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-48.0, -48.0)); + let img = render(rectangle(), true, stroke(StrokeAlign::Outside), xf, 0); + assert_eq!(img.pixel(48, 48), WHITE); + assert_eq!(img.pixel(28, 28), RED); + assert_eq!(img.pixel(34, 34), WHITE); + assert_eq!(img.pixel(22, 22), BLUE); +} + +#[test] +fn outside_stroke_is_identical_with_multiple_render_threads() { + let single = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 0); + let multi = render(rectangle(), true, stroke(StrokeAlign::Outside), Affine::IDENTITY, 2); + assert_eq!(single.pixels, multi.pixels); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 36767af..369b101 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,3 +30,7 @@ log = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } designcraft-fonts = { workspace = true, features = ["testing"] } + +[build-dependencies] +serde_json = { workspace = true } +toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 0000000..f40007f --- /dev/null +++ b/crates/ui-egui/build.rs @@ -0,0 +1,105 @@ +//! Bake the contributor credits into the binary as static Rust tables (craftrules +//! standards/contributors.md). Nothing is read at run time. +//! +//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules +//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/people.toml: the names people chose for themselves. A name is used only when the +//! JSON says the entry was verified (`people_entry`). +//! +//! A missing or malformed file gives empty tables (or no names) and a warning. + +use std::fmt::Write as _; + +use serde_json::Value; + +fn main() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); + let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| { + serde_json::from_str::(t).map_err(|e| e.to_string()) + }) + .unwrap_or(Value::Null); + let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) + .unwrap_or_default(); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); + if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + println!("cargo::warning=writing {}: {e}", out.display()); + } +} + +fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { + println!("cargo::rerun-if-changed={}", path.display()); + let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t)); + if let Err(e) = &r { + println!("cargo::warning={}: {e}; {fallback}", path.display()); + } + r.ok() +} + +/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal. +fn name(v: Option<&str>) -> String { + match v.map(str::trim).filter(|s| !s.is_empty()) { + Some(x) => format!("Some({x:?})"), + None => "None".into(), + } +} + +fn s(v: &Value, k: &str) -> String { + format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or("")) +} + +fn n(v: &Value, k: &str) -> u64 { + v.get(k).and_then(Value::as_u64).unwrap_or(0) +} + +fn rows(json: &Value, k: &str) -> Vec { + json.get(k).and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn generate(json: &Value, people: &toml::Table) -> String { + let mut o = String::new(); + let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits")); + let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &["); + for c in rows(json, "contributors") { + let login = c.get("login").and_then(Value::as_str).unwrap_or(""); + if login.is_empty() { + continue; + } + // Names only from a verified, self-chosen people.toml entry. + let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true)); + let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str); + let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true); + let display = field("display_name") + .filter(|s| !s.trim().is_empty()) + .or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None }); + let _ = writeln!( + o, + " Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},", + name(display), + name(field("real_name")), + n(&c, "prs"), + n(&c, "commits"), + n(&c, "lines_added"), + n(&c, "lines_deleted"), + n(&c, "binary_added"), + n(&c, "binary_deleted"), + s(&c, "first_commit"), + s(&c, "last_commit"), + ); + } + let _ = writeln!(o, "];"); + let _ = writeln!(o, "pub static MODELS: &[Model] = &["); + for m in rows(json, "models") { + let _ = writeln!( + o, + " Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},", + s(&m, "company"), + s(&m, "model"), + s(&m, "version"), + n(&m, "commits"), + n(&m, "lines_added"), + n(&m, "lines_deleted"), + ); + } + let _ = writeln!(o, "];"); + o +} diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 41eb43a..a387560 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -1,5 +1,6 @@ -//! Help › About DesignCraft: the splash with the ArtCraft mark, version, and community links -//! (Discord first and largest), plus the other ArtCraft apps. +//! Help › About DesignCraft: tabs About · Contributors · Models. About is the splash with the +//! ArtCraft mark, version, and community links (Discord first and largest), plus the other ArtCraft +//! apps; Contributors and Models are the compiled-in credits (`crate::credits`). use designcraft_engine::links; use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; @@ -22,10 +23,13 @@ pub const SIBLINGS: &[(&str, &str, &str)] = &[ ("drawcraft", "VectorCraft", "vector illustration"), ("filmcraft", "FilmCraft", "video editing, color and sound"), ("lightcraft", "LightCraft", "photo library and raw development"), - ("printcraft", "PrintCraft", "reading, organizing and protecting PDFs"), + ("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"), ("effectcraft", "EffectCraft", "motion graphics and visual effects"), ]; +/// The About window's tabs, in `UiState::about_tab` order (`help.about {tab}` names them in lowercase). +pub const ABOUT_TABS: [&str; 3] = ["About", "Contributors", "Models"]; + /// Paint the ArtCraft mark into `rect` (square), rasterised once per size and cached. pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) { use designcraft_render::vello_cpu::{self, kurbo, peniko}; @@ -103,7 +107,34 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut open: Option<&'static str> = None; let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| { - ui.set_width(460.0); + ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 }); + ui.horizontal(|ui| { + for (i, l) in ABOUT_TABS.iter().enumerate() { + let i = i as u8; + if ui.selectable_label(app.ui.about_tab == i, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { + app.ui.about_tab = i; + } + } + }); + ui.separator(); + if app.ui.about_tab != 0 { + let h = 400.0_f32.min(ctx.content_rect().height() * 0.7).max(160.0); + ui.allocate_ui_with_layout(vec2(ui.available_width(), h), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_height(h); + if app.ui.about_tab == 1 { + crate::credits::contributors_ui(ui); + } else { + crate::credits::models_ui(ui); + } + }); + ui.separator(); + ui.vertical_centered(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, " Close "))).clicked() { + app.ui.about = false; + } + }); + return; + } ui.vertical_centered(|ui| { ui.add_space(8.0); let (r, _) = ui.allocate_exact_size(vec2(72.0, 72.0), Sense::hover()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6753764..8288dd3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -328,7 +328,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); - let c = if space { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; + let middle = ui.input(|i| i.pointer.middle_down()); + let c = if middle { + Cursor::HandGrab + } else if space { + Cursor::Hand + } else { + app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) + }; ui.ctx().set_cursor_icon(cursor_icon(c)); } } @@ -1345,9 +1352,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } } // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live - // Distribute while resizing). + // Distribute while resizing). Middle-drag pans too, with any tool; tools only + // see the primary button. let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); - if space && !tool_drag && resp.dragged() { + if !tool_drag && ((space && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { let d = xf.unrotate_delta(resp.drag_delta()); if let Some(v) = app.view_mut() { v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7ff3d79..3245025 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -183,6 +183,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs new file mode 100644 index 0000000..765dec1 --- /dev/null +++ b/crates/ui-egui/src/credits.rs @@ -0,0 +1,399 @@ +//! The About window's credits: every contributor (GitHub login always; display and real names only +//! with recorded consent) and the AI models that helped. The tables are baked into the binary by +//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! read at run time. + +use std::cmp::Ordering; + +use egui::RichText; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Contributor { + pub login: &'static str, + pub display_name: Option<&'static str>, + pub real_name: Option<&'static str>, + pub prs: u64, + pub commits: u64, + pub lines_added: u64, + pub lines_deleted: u64, + pub binary_added: u64, + pub binary_deleted: u64, + /// ISO 8601 UTC; empty for someone credited only through a merged PR. + pub first_commit: &'static str, + pub last_commit: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Model { + pub company: &'static str, + pub model: &'static str, + pub version: &'static str, + pub commits: u64, + pub lines_added: u64, + pub lines_deleted: u64, +} + +include!(concat!(env!("OUT_DIR"), "/credits.rs")); + +/// Which name to show. Display and real names fall back to the `@username` when not given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NameMode { + #[default] + Username, + DisplayName, + RealName, +} + +impl NameMode { + pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName]; + + pub fn label(self) -> &'static str { + match self { + NameMode::Username => "Username", + NameMode::DisplayName => "Display name", + NameMode::RealName => "Real name", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SortKey { + Name, + Prs, + Commits, + LinesAdded, + LinesDeleted, + LinesDelta, + BinaryAdded, + BinaryDeleted, + #[default] + FirstCommit, + LastCommit, +} + +impl SortKey { + pub const ALL: [SortKey; 10] = [ + SortKey::Name, + SortKey::Prs, + SortKey::Commits, + SortKey::LinesAdded, + SortKey::LinesDeleted, + SortKey::LinesDelta, + SortKey::BinaryAdded, + SortKey::BinaryDeleted, + SortKey::FirstCommit, + SortKey::LastCommit, + ]; + + /// Menu label and table header. + pub fn label(self) -> (&'static str, &'static str) { + match self { + SortKey::Name => ("Name (A–Z)", "Name"), + SortKey::Prs => ("Merged PRs", "PRs"), + SortKey::Commits => ("Commits", "Commits"), + SortKey::LinesAdded => ("Lines added", "+LOC"), + SortKey::LinesDeleted => ("Lines deleted", "−LOC"), + SortKey::LinesDelta => ("Line delta", "ΔLOC"), + SortKey::BinaryAdded => ("Binary assets added", "+Bin"), + SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"), + SortKey::FirstCommit => ("First commit", "First"), + SortKey::LastCommit => ("Last commit", "Last"), + } + } + + /// Names and dates read naturally oldest/A first; counts read biggest first. + pub fn default_ascending(self) -> bool { + matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit) + } +} + +impl Contributor { + pub fn name(&self, mode: NameMode) -> String { + let chosen = match mode { + NameMode::Username => None, + NameMode::DisplayName => self.display_name, + NameMode::RealName => self.real_name, + }; + chosen.map_or_else(|| format!("@{}", self.login), str::to_string) + } + + pub fn lines_delta(&self) -> i128 { + i128::from(self.lines_added) - i128::from(self.lines_deleted) + } + + /// One line with everything we know, for tooltips. + pub fn summary(&self) -> String { + format!( + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}", + self.login, + self.prs, + self.commits, + group(self.lines_added), + group(self.lines_deleted), + signed(self.lines_delta()), + self.binary_added, + self.binary_deleted, + day(self.first_commit), + day(self.last_commit), + ) + } +} + +/// Alphabetical key: case-insensitive, ignoring the `@` of usernames. +fn name_key(name: &str) -> String { + name.trim_start_matches('@').to_lowercase() +} + +fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering { + match key { + SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))), + SortKey::Prs => a.prs.cmp(&b.prs), + SortKey::Commits => a.commits.cmp(&b.commits), + SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added), + SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted), + SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()), + SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added), + SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted), + // A PR-only contributor (no commit date) sorts after everyone with a date. + SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)), + SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)), + } +} + +fn dated(d: &str) -> (bool, &str) { + (d.is_empty(), d) +} + +/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable. +pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> { + let mut v: Vec<&Contributor> = list.iter().collect(); + v.sort_by(|a, b| { + let o = compare(a, b, mode, key); + let o = if ascending { o } else { o.reverse() }; + o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login)) + }); + v +} + +fn group(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +fn signed(n: i128) -> String { + let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX)); + if n < 0 { format!("−{g}") } else { format!("+{g}") } +} + +fn day(iso: &str) -> &str { + if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct View { + names: NameMode, + key: SortKey, + ascending: bool, + table: bool, +} + +impl Default for View { + fn default() -> Self { + View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false } + } +} + +/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +pub fn contributors_ui(ui: &mut egui::Ui) { + let id = egui::Id::new("credits_view"); + let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + ui.label("Show"); + for m in NameMode::ALL { + if ui.selectable_label(v.names == m, m.label()).clicked() { + v.names = m; + } + } + ui.separator(); + ui.label("Sort"); + egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + for k in SortKey::ALL { + if ui.selectable_label(v.key == k, k.label().0).clicked() { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + v.ascending = !v.ascending; + } + ui.separator(); + if ui.selectable_label(!v.table, "Grab bag").clicked() { + v.table = false; + } + if ui.selectable_label(v.table, "Table").clicked() { + v.table = true; + } + }); + let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); + ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); + ui.separator(); + egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { + if list.is_empty() { + ui.label("No contributor data was built into this copy."); + } else if v.table { + table(ui, &list, &mut v); + } else { + ui.horizontal_wrapped(|ui| { + for (i, c) in list.iter().enumerate() { + if i > 0 { + ui.label(RichText::new("·").weak()); + } + ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); + } + }); + } + }); + ui.data_mut(|d| d.insert_temp(id, v)); +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { + for k in SortKey::ALL { + let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; + if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + } + ui.end_row(); + for c in list { + ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); + ui.label(group(c.prs)); + ui.label(group(c.commits)); + ui.label(group(c.lines_added)); + ui.label(group(c.lines_deleted)); + ui.label(signed(c.lines_delta())); + ui.label(group(c.binary_added)); + ui.label(group(c.binary_deleted)); + ui.label(day(c.first_commit)); + ui.label(day(c.last_commit)); + ui.end_row(); + } + }); +} + +/// About ▸ Models: AI models credited in Co-Authored-By trailers. +pub fn models_ui(ui: &mut egui::Ui) { + if MODELS.is_empty() { + ui.label("No model credits were built into this copy."); + return; + } + let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); + egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { + egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { + for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { + ui.label(RichText::new(h).strong()); + } + ui.end_row(); + for m in MODELS { + ui.label(m.company); + ui.label(m.model); + ui.label(m.version); + ui.label(group(m.commits)); + ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); + ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); + ui.end_row(); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c( + login: &'static str, + display: Option<&'static str>, + real: Option<&'static str>, + added: u64, + deleted: u64, + first: &'static str, + ) -> Contributor { + Contributor { + login, + display_name: display, + real_name: real, + prs: 0, + commits: 1, + lines_added: added, + lines_deleted: deleted, + binary_added: 0, + binary_deleted: 0, + first_commit: first, + last_commit: first, + } + } + + fn people() -> [Contributor; 4] { + [ + c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"), + c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"), + c("alice", None, None, 7, 1, ""), + c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"), + ] + } + + fn logins(v: &[&Contributor]) -> Vec<&'static str> { + v.iter().map(|c| c.login).collect() + } + + #[test] + fn names_fall_back_to_the_username() { + let p = c("bob", Some("Bobby"), None, 0, 0, ""); + assert_eq!(p.name(NameMode::Username), "@bob"); + assert_eq!(p.name(NameMode::DisplayName), "Bobby"); + assert_eq!(p.name(NameMode::RealName), "@bob"); + } + + #[test] + fn alphabetical_ignores_at_and_case() { + let all = people(); + let v = sorted(&all, NameMode::Username, SortKey::Name, true); + assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); + let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true); + assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames + let v = sorted(&all, NameMode::RealName, SortKey::Name, false); + assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed + } + + #[test] + fn delta_and_dates() { + let all = people(); + let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false); + assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]); + let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true); + assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last + } + + #[test] + fn formatting() { + assert_eq!(group(1234567), "1,234,567"); + assert_eq!(group(12), "12"); + assert_eq!(signed(-1000), "−1,000"); + assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); + assert_eq!(day(""), "—"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cd292b1..34832db 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2396,10 +2396,14 @@ pub fn command_fields(doc: &str) -> Vec { let ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !s.starts_with(|c: char| c.is_ascii_digit()); let mut out = Vec::new(); for part in parts { + // `key`, `key?`, `key: spec` or `key (spec)`: the key is the leading word. let part = part.trim(); - let (k, spec) = match part.split_once(':') { - Some((k, v)) => (k.trim(), v.trim()), - None => (part, ""), + let k_end = part.find(|c: char| c.is_whitespace() || c == ':' || c == '(').unwrap_or(part.len()); + let (k, rest) = part.split_at(k_end); + let rest = rest.trim_start(); + let spec = match rest.strip_prefix(':') { + Some(v) => v.trim(), + None => rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')).unwrap_or(rest).trim(), }; let optional = k.ends_with('?'); let key = k.trim_end_matches('?'); @@ -2660,6 +2664,18 @@ mod tests { assert!(f[4].boolean); assert!(command_fields("{}").is_empty()); assert!(command_fields("no params").is_empty()); + // A parenthetical after the key describes the value. + let f = command_fields("{angle (degrees, CCW), ids?}"); + let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect(); + assert_eq!(keys, ["angle", "ids"]); + assert!(!f[0].optional && f[1].optional); + assert_eq!(f[0].hint, "degrees, CCW"); + let f = command_fields("{scaleX? (%), ref?: 0..8, ids?: parent items (default: all)}"); + let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect(); + assert_eq!(keys, ["scaleX", "ref", "ids"]); + assert!(f.iter().all(|f| f.optional)); + assert_eq!(f[0].hint, "%"); + assert_eq!(f[2].hint, "parent items (default: all)"); // Every command with a "…" label parses without panicking. for c in designcraft_engine::command_specs() { let _ = command_fields(c.params); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 00169d4..fee8369 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -147,13 +147,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, 12.5), (14.0, 3.0)]); pen.line(&[(12.5, 12.5), (6.0, 3.0)]); } - "tool-free-transform" | "tool-scale" | "tool-shear" => { + "tool-free-transform" => { pen.rect(4.0, 4.0, 13.0, 13.0); pen.frect(2.8, 2.8, 5.2, 5.2); pen.frect(11.8, 11.8, 14.2, 14.2); pen.line(&[(14.0, 14.0), (18.0, 18.0)]); pen.line(&[(15.0, 18.0), (18.0, 18.0), (18.0, 15.0)]); } + "tool-scale" => { + // A small square grown into a larger one from their shared corner. + Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 3.0, 17.0, 17.0); + pen.rect(3.0, 10.0, 10.0, 17.0); + pen.line(&[(10.0, 10.0), (15.0, 5.0)]); + pen.line(&[(11.5, 5.0), (15.0, 5.0), (15.0, 8.5)]); + } + "tool-shear" => { + // A rectangle slanted into a parallelogram along its base. + Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 8.0, 13.0, 17.0); + pen.closed(&[(7.0, 8.0), (17.0, 8.0), (13.0, 17.0), (3.0, 17.0)]); + pen.line(&[(7.0, 4.0), (16.0, 4.0)]); + pen.line(&[(13.5, 2.0), (16.0, 4.0), (13.5, 6.0)]); + } "tool-rotate" => { let pts: Vec<(f32, f32)> = (0..24) .map(|i| { @@ -791,3 +805,31 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st }) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What icon `name` paints into a 20×20 rect, as the painter's shape list. + fn drawing(name: &str) -> String { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); + }); + out.textures_delta.clear(); + format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::>()) + } + + #[test] + fn transform_tools_have_their_own_icons() { + let names = ["tool-free-transform", "tool-scale", "tool-shear"]; + let unknown = drawing("no-such-icon"); + let drawn: Vec = names.iter().map(|n| drawing(n)).collect(); + for (n, d) in names.iter().zip(&drawn) { + assert_ne!(*d, unknown, "{n} falls back to the placeholder"); + } + assert_ne!(drawn[0], drawn[1], "Scale draws like Free Transform"); + assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); + assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 10a6000..7b474fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod about; pub mod canvas; pub mod chrome; pub mod control; +pub mod credits; pub mod dialogs; pub mod dock; pub mod i18n; @@ -190,6 +191,9 @@ pub struct UiState { pub task_bar: bool, /// Help › About DesignCraft is open. pub about: bool, + /// The About window's tab: 0 About, 1 Contributors, 2 Models (`about::ABOUT_TABS`). + #[serde(skip)] + pub about_tab: u8, /// URLs to open in the browser on the next frame (Help links, About, start screen). pub pending_urls: Vec, #[serde(skip)] @@ -261,6 +265,7 @@ impl Default for UiState { snap_zone: 4.0, task_bar: true, about: false, + about_tab: 0, pending_urls: Vec::new(), status: String::new(), dialog: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bdd50d0..04a7ab3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -135,7 +135,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("help.issues", "Report an Issue…", None, "{} — opens the GitHub issue tracker"), ("help.website", "ArtCraft Website…", None, "{} — opens https://getartcraft.com"), ("help.app", "ArtCraft App Page…", None, "{app} — opens https://getartcraft.com/apps/{app} (e.g. photocraft)"), - ("help.about", "About DesignCraft", None, "{open?: true} — the About splash with community links (open: false closes it)"), + ( + "help.about", + "About DesignCraft", + None, + "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", + ), ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), ( "window.workspace", @@ -1279,6 +1284,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.task_bar), "help.about" => { + if let Some(tab) = p.get("tab").and_then(Value::as_str) { + let Some(i) = crate::about::ABOUT_TABS.iter().position(|t| t.eq_ignore_ascii_case(tab)) else { + return Some(Err(format!("help.about: unknown tab `{tab}` (about, contributors, models)"))); + }; + app.ui.about_tab = u8::try_from(i).unwrap_or(0); + } app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true); Ok(json!(app.ui.about)) } @@ -2006,6 +2017,16 @@ mod tests { } } + #[test] + fn transform_menu_items_open_their_dialogs() { + for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + activate(&mut app, id, &Value::Null); + let dialog = app.ui.dialog.as_ref().map(|d| d.id.clone()); + assert_eq!(dialog.as_deref(), Some(format!("cmd:{id}").as_str()), "{id} opens its dialog"); + } + } + #[test] fn snap_preferences_are_a_command() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -2259,6 +2280,43 @@ mod tests { assert!((app.view().unwrap().zoom - z0).abs() < 1e-9, "back at the zoom it started from"); } + #[test] + fn middle_drag_pans_with_any_tool() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // A drawing tool: a primary drag would make a frame, a middle drag must not. + app.select_tool("rectangleFrame"); + let p = app.canvas_rect.unwrap().center(); + let v0 = *app.view().unwrap(); + let items = |app: &crate::DesignApp| app.session.active().unwrap().doc.spreads.iter().map(|s| s.items.len()).sum::(); + let items0 = items(&app); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(p), button(p, true)]); + for i in 1..=8 { + frame(&mut app, vec![egui::Event::PointerMoved(p + egui::vec2(10.0 * i as f32, 5.0 * i as f32))]); + } + frame(&mut app, vec![button(p + egui::vec2(80.0, 40.0), false)]); + let v = *app.view().unwrap(); + assert!((v.zoom - v0.zoom).abs() < 1e-9, "panning keeps the zoom"); + assert!(v.origin.x < v0.origin.x && v.origin.y < v0.origin.y, "dragging right/down moves the view: {v0:?} -> {v:?}"); + assert_eq!(items(&app), items0, "the tool saw nothing"); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4f110df..a3d4954 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -3,7 +3,7 @@ use designcraft_tools::TOOL_GROUPS; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; -use serde_json::json; +use serde_json::{Value, json}; use crate::theme::Tokens; use crate::{DesignApp, icons}; @@ -91,6 +91,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } + // Double-click a transform tool: its Object › Transform dialog. + if resp.double_clicked() + && let Some(cmd) = transform_dialog_of(shown.id) + { + crate::menus::activate(app, cmd, &Value::Null); + } if g.len() > 1 && (resp.secondary_clicked() || resp.long_touched() @@ -343,6 +349,17 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }); } +/// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move). +fn transform_dialog_of(tool: &str) -> Option<&'static str> { + match tool { + "selection" => Some("transform.move"), + "rotate" => Some("transform.rotate"), + "scale" => Some("transform.scale"), + "shear" => Some("transform.shear"), + _ => None, + } +} + fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) { let q = r.right_bottom() - vec2(2.0, 2.0); p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE)); diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 0000000..410a940 --- /dev/null +++ b/docs/contributors.md @@ -0,0 +1,55 @@ +# Contributors in the About window + +**About ▸ Contributors** credits everyone who contributed to DesignCraft, and **About ▸ Models** credits +the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard +[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); +this page is the local copy of the decision. + +## Decision + +- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from + disk or the network at run time; the web build has them too): + - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub + username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + - [`contributors/people.toml`](../contributors/people.toml): the names people chose for + themselves. **Each contributor adds only their own entry**, in their own PR. +- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced + GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines + deleted, binary assets added, binary assets removed, first and last commit date. +- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It + sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed, + first or last commit date. The name toggle cycles **Username → Display name → Real name**; a + missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`. + +## In DesignCraft + +- The About window is `crates/ui-egui/src/about.rs` (Help ▸ About DesignCraft); `help.about {"tab":"contributors"}` opens a tab. + +## Credit yourself + +Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional): + +```toml +[people.your-github-username] +real_name = "Your Name" +display_name = "Nickname" +sync_github_name = true # use the name on your public GitHub profile as display name +``` + +or, with craftrules checked out next to `craft-apps/`: + +```sh +python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name +``` + +Names show after the next refresh: the script checks that each entry was committed by its own +user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. + +## Refreshing (maintainers) + +```sh +python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh +python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only +``` + +Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c6f334..b24894f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,16 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` +are optional; blank lines are skipped). Anything else gets one error reply +(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so +nothing sent after it on that connection runs. That covers text that isn't JSON, a JSON array or number, +an object without `method`, invalid UTF-8, and a line longer than 4 MiB. An HTTP request (for example a +web page's cross-origin `fetch` to `127.0.0.1:`) therefore can't smuggle a command in its body: +its request line is rejected first. At most 16 connections are served at once; further ones get an error +line and are closed. Clients that get an error reply should reconnect. The port has no authentication, +so only enable it while you use it. Transport: `apps/designcraft/src/control_server.rs`. + ```json {"id": 1, "method": "engine.execute", "params": {"command": "frame.create", "params": {"rect": [36, 36, 300, 200], "content": "text"}}} {"id": 1, "ok": true, "result": {"id": 10, "story": 11}} @@ -18,7 +28,7 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 228fc4e..2181fef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -93,4 +93,4 @@ key {"key": "Escape"} ## Community links -`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about` shows the About splash. The server's `serverInfo.websiteUrl` is the app page. +`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about {tab?}` shows the About window (tabs `about`, `contributors`, `models`). The server's `serverInfo.websiteUrl` is the app page.