Merge remote-tracking branch 'origin/main' into feat/cjk-and-fonts

# Conflicts:
#	crates/ui-egui/Cargo.toml
This commit is contained in:
Brandon Thomas 2026-10-07 22:04:42 -04:00
commit aee756d604
36 changed files with 2200 additions and 92 deletions

View File

@ -63,7 +63,7 @@ jobs:
macos: macos:
name: macOS universal name: macOS universal
needs: version 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 environment: release
env: env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}

View File

@ -1,7 +1,7 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 # 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 # 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 # 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 name: Windows ARM64
on: on:

View File

@ -1,6 +1,6 @@
# DesignCraft — instructions for agents # 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 ## 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). 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
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. `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.<username>]` 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).

View File

@ -1,6 +1,6 @@
# DesignCraft — instructions for agents # 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 ## 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). 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).

1
Cargo.lock generated
View File

@ -1113,6 +1113,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"toml",
"unicode-bidi", "unicode-bidi",
"unicode-bidi-mirroring", "unicode-bidi-mirroring",
"unicode-segmentation", "unicode-segmentation",

View File

@ -166,7 +166,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | | <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | | <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |

View File

@ -1,14 +1,28 @@
//! Loopback JSON-lines control server: one request per line, one reply per line. //! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`designcraft mcp --connect`) wraps. //! 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::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration; use std::time::Duration;
use designcraft_ui_egui::ControlRequest; use designcraft_ui_egui::ControlRequest;
use serde_json::{Value, json}; 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<ControlRequest> { pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>(); let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) { let listener = match TcpListener::bind(("127.0.0.1", port)) {
@ -19,44 +33,247 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
} }
}; };
eprintln!("designcraft: control server listening on 127.0.0.1:{port}"); eprintln!("designcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || { std::thread::spawn(move || accept_loop(listener, tx, ctx));
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx rx
} }
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) { fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return }; let open = Arc::new(AtomicUsize::new(0));
let mut out = stream; for mut stream in listener.incoming().flatten() {
for line in BufReader::new(read).lines() { if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
let Ok(line) = line else { break }; open.fetch_sub(1, Ordering::SeqCst);
if line.trim().is_empty() { let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue; continue;
} }
let reply = match serde_json::from_str::<Value>(&line) { let tx = tx.clone();
Ok(msg) => { let ctx = ctx.clone();
let id = msg.get("id").cloned().unwrap_or(Value::Null); let open = Arc::clone(&open);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); std::thread::spawn(move || {
let params = msg.get("params").cloned().unwrap_or(json!({})); serve(stream, &tx, &ctx);
let (req, rrx) = ControlRequest::new(method, params); open.fetch_sub(1, Ordering::SeqCst);
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"})); fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
if let Some(o) = r.as_object_mut() { let Ok(read) = stream.try_clone() else { return };
o.insert("id".into(), id); serve_lines(read, stream, |method, params| {
} let (req, rrx) = ControlRequest::new(method, params);
r tx.send(req).ok()?;
} ctx.request_repaint();
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
}; });
if writeln!(out, "{reply}").is_err() { }
break;
} /// 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::<Value>(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<u8>, max: usize) -> Result<bool, String> {
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<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
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<Value>, Vec<String>) {
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::<Value>(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::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (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);
} }
} }

View File

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

23
contributors/people.toml Normal file
View File

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

View File

@ -375,10 +375,12 @@ fn emit(
graphic: cell.graphic.clone(), graphic: cell.graphic.clone(),
}); });
// Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right. // 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_top = (y - top).abs() < 1e-6;
let at_bottom = (y1 - bottom).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| { let mut push = |a: Point, b: Point, s: CellStroke| {
if s.is_visible() { if s.is_visible() {
strokes.push(StrokeSeg { a, b, stroke: s }); 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)), 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());
}
}

View File

@ -60,7 +60,7 @@ pub struct Kinsoku {
impl Kinsoku { impl Kinsoku {
/// Explicitly supplied character sets take precedence over the fallback table. /// Explicitly supplied character sets take precedence over the fallback table.
pub fn allows(&self, a: char, b: char) -> bool { 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)))
} }
} }

View File

@ -116,6 +116,10 @@ pub struct Cell {
/// Edge strokes: top, left, bottom, right. /// Edge strokes: top, left, bottom, right.
#[serde(default)] #[serde(default)]
pub strokes: [CellStroke; 4], 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]). /// Applied cell style ("" = [None]).
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String, pub style: String,
@ -137,6 +141,7 @@ impl Default for Cell {
vj: VerticalJustification::Top, vj: VerticalJustification::Top,
rotation: 0.0, rotation: 0.0,
strokes: Default::default(), strokes: Default::default(),
border_overrides: [false; 4],
style: String::new(), style: String::new(),
graphic: None, graphic: None,
} }
@ -1105,6 +1110,20 @@ mod tests {
let st: Story = serde_json::from_str(old).unwrap(); let st: Story = serde_json::from_str(old).unwrap();
assert!(st.tables.is_empty()); 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)] #[cfg(test)]

View File

@ -103,18 +103,25 @@ fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> {
Ok((spread_param(p, "spread"), pi, i)) 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<Value> { fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?; 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"))?; let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?;
s.edit(|d, _| { s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d let g = d
.spread_mut(r) .spread_mut(r)
.and_then(|sp| sp.pages.get_mut(pi)) .and_then(|sp| sp.pages.get_mut(pi))
.and_then(|pg| pg.guides.get_mut(i)) .and_then(|pg| pg.guides.get_mut(i))
.ok_or_else(|| bad("guide.move", "no such guide"))?; .ok_or_else(|| bad("guide.move", "no such guide"))?;
if g.locked {
return Err(bad("guide.move", "the guide is locked"));
}
g.position = pos; g.position = pos;
Ok(Value::Null) Ok(Value::Null)
}) })
@ -123,6 +130,7 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> { fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.delete")?; let (r, pi, i) = guide_ref(p, "guide.delete")?;
s.edit(|d, _| { 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"))?; 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() { if i >= pg.guides.len() {
return Err(bad("guide.delete", "no such guide")); return Err(bad("guide.delete", "no such guide"));
@ -238,6 +246,26 @@ mod layer_tests {
use crate::Session; 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] #[test]
fn guides_follow_their_layer() { fn guides_follow_their_layer() {
let mut s = Session::new(); let mut s = Session::new();

View File

@ -34,16 +34,14 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). /// 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<Document> { pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
let dir = dir.map(|d| d.to_path_buf()); #[cfg(not(target_arch = "wasm32"))]
let read = move |link: &str| -> Option<Vec<u8>> { let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
let read = |link: &str| -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
if let Ok(b) = std::fs::read(link) { let (path, data) = read_packaged_link(link, dir)?;
return Some(b); resolved.borrow_mut().insert(link.to_string(), path);
} Some(data)
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()
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
@ -51,7 +49,53 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
None 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<u8>)> {
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::<std::path::PathBuf>()));
}
// 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::<std::path::PathBuf>()));
}
}
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<Value> { pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
@ -81,3 +125,58 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
let i = s.add_document(st); let i = s.add_document(st);
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) 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<u8>, Vec<u8>)> {
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();
}
}

View File

@ -594,6 +594,13 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
} }
} }
} }
// 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) { } else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
dirs.push(root.join("Fonts")); dirs.push(root.join("Fonts"));

View File

@ -2198,6 +2198,9 @@ impl<'a> Ex<'a> {
.attr("RotationAngle", num(cell.rotation)); .attr("RotationAngle", num(cell.rotation));
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); 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) { for psr in self.story_paras(&cell.text) {
ce.push(psr); ce.push(psr);

View File

@ -1548,6 +1548,9 @@ impl<'r> Importer<'r> {
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let base = cell.strokes[i].clone(); let base = cell.strokes[i].clone();
cell.strokes[i] = stroke(self, ce, side, &base); 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) { if let Some(slot) = t.cell_mut(r, c) {
*slot = cell; *slot = cell;
@ -2350,13 +2353,8 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
o.column_width = v; o.column_width = v;
} }
o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); o.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
if let Some(l) = e.prop_el("InsetSpacing") { if let Some(inset) = inset_spacing(e) {
let v: Vec<f64> = l.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect(); o.inset = inset;
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(v) = e.get("VerticalJustification") { if let Some(v) = e.get("VerticalJustification") {
o.vertical_justification = names::vj_in(v); o.vertical_justification = names::vj_in(v);
@ -2380,6 +2378,32 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
o 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::<f64>().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 { fn path_of(pg: &El) -> PathData {
let mut subs = Vec::new(); let mut subs = Vec::new();
for g in pg.find_all("GeometryPathType") { for g in pg.find_all("GeometryPathType") {

View File

@ -269,6 +269,119 @@ fn imports_hand_written_fixture() {
assert_eq!(a.name, "photo.jpg"); 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#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<RootObjectStyleGroup Self="ro">{object_styles}</RootObjectStyleGroup>
<idPkg:Spread src="Spreads/Spread_s.xml"/>
</Document>"#
);
let spread = format!(
r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="s">
<Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/>
<TextFrame Self="f" AppliedObjectStyle="ObjectStyle/Padded">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/>
<PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
{preference}
</TextFrame>
</Spread>
</idPkg:Spread>"#
);
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#"<ListItem type="unit">{v}</ListItem>"#)).collect::<String>();
format!(r#"<Properties><InsetSpacing type="list">{items}</InsetSpacing></Properties>"#)
}
#[test]
fn imports_scalar_inset_properties_like_lists_and_attributes() {
let scalar =
inset_fixture(r#"<TextFramePreference><Properties><InsetSpacing type="unit"> 9 </InsetSpacing></Properties></TextFramePreference>"#, "");
let list = inset_fixture(&format!("<TextFramePreference>{}</TextFramePreference>", inset_list(&["9"; 4])), "");
let attribute = inset_fixture(r#"<TextFramePreference InsetSpacing="9"/>"#, "");
let untyped = inset_fixture(r#"<TextFramePreference><Properties><InsetSpacing>9</InsetSpacing></Properties></TextFramePreference>"#, "");
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#"<Properties><InsetSpacing type="unit">9</InsetSpacing></Properties>"#.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#"<Properties><InsetSpacing type="unit">invalid</InsetSpacing></Properties>"#.to_string(), [0.0; 4]),
] {
let d = inset_fixture(&format!(r#"<TextFramePreference InsetSpacing="42">{property}</TextFramePreference>"#), "");
assert_eq!(frame_inset(&d), expected, "{property}");
}
}
#[test]
fn inset_spacing_rejects_malformed_and_nonfinite_values() {
let mut preferences = vec![
String::new(),
"<TextFramePreference/>".to_string(),
r#"<TextFramePreference><Properties><InsetSpacing type="list">9</InsetSpacing></Properties></TextFramePreference>"#.to_string(),
r#"<TextFramePreference><Properties><InsetSpacing type="unit"><Other>9</Other></InsetSpacing></Properties></TextFramePreference>"#
.to_string(),
];
for value in ["", "invalid", "NaN", "inf", "-inf", "1e309"] {
preferences.push(format!(r#"<TextFramePreference InsetSpacing="{value}"/>"#));
preferences
.push(format!(r#"<TextFramePreference><Properties><InsetSpacing type="unit">{value}</InsetSpacing></Properties></TextFramePreference>"#));
preferences.push(format!("<TextFramePreference>{}</TextFramePreference>", 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!("<TextFramePreference>{}</TextFramePreference>", inset_list(values)));
}
preferences.push(format!("<TextFramePreference>{}</TextFramePreference>", inset_list(&["1", "<Other>2</Other>", "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::<f64>().unwrap(); 4];
for preference in [
format!(r#"<TextFramePreference InsetSpacing="{value}"/>"#),
format!(r#"<TextFramePreference><Properties><InsetSpacing type="unit">{value}</InsetSpacing></Properties></TextFramePreference>"#),
format!("<TextFramePreference>{}</TextFramePreference>", inset_list(&[value; 4])),
] {
assert_eq!(frame_inset(&inset_fixture(&preference, "")), expected, "{preference}");
}
}
let d = inset_fixture(&format!("<TextFramePreference>{}</TextFramePreference>", 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#"<ObjectStyle Self="ObjectStyle/Padded" Name="Padded" EnableTextFrameGeneralOptions="true">
<TextFramePreference><Properties><InsetSpacing type="unit">9</InsetSpacing></Properties></TextFramePreference>
</ObjectStyle>"#;
let d = inset_fixture(r#"<TextFramePreference InsetSpacing="3"/>"#, 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] #[test]
fn rejects_non_idml() { fn rejects_non_idml() {
assert!(import_idml(b"not a zip").is_err()); 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")); 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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange>
<Table HeaderRowCount="0" FooterRowCount="0" TopBorderStrokeWeight="2">
<Row Name="0" MinimumHeight="24"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
<Cell Name="0:0" TopEdgeStrokeColor="Swatch/None" TopEdgeStrokePriority="1"
LeftEdgeStrokeColor="Color/Brand" LeftEdgeStrokeWeight="3" LeftEdgeStrokePriority="2"
BottomEdgeStrokeWeight="0" BottomEdgeStrokePriority="0" RightEdgeStrokeColor="Swatch/None">
<ParagraphStyleRange><CharacterStyleRange><Content>A</Content></CharacterStyleRange></ParagraphStyleRange>
</Cell>
<Cell Name="1:0"><ParagraphStyleRange><CharacterStyleRange><Content>B</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
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] #[test]
fn round_trips_footnotes_and_options() { fn round_trips_footnotes_and_options() {
let mut d = Document::new(&NewDocument::default()); let mut d = Document::new(&NewDocument::default());

View File

@ -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). // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
let mut warnings = ex.warnings; let mut warnings = ex.warnings;
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) { 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); 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. // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X.
let fields = crate::forms::collect(doc, &sheets); let fields = crate::forms::collect(doc, &sheets);

View File

@ -2,6 +2,12 @@
//! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a //! 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. //! 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}; use crate::transitions::{find, int_after, object, rfind};
/// Escape a PDF literal string. /// Escape a PDF literal string.
@ -187,7 +193,8 @@ fn dictionary_ranges(pdf: &[u8]) -> Option<Vec<std::ops::Range<usize>>> {
Some(out) 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<String> { pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
let mut issues = Vec::new(); let mut issues = Vec::new();
let text = String::from_utf8_lossy(pdf); let text = String::from_utf8_lossy(pdf);
@ -217,5 +224,445 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
if text.contains("/Subtype/Type3") { if text.contains("/Subtype/Type3") {
issues.push("Type 3 fonts aren't allowed".into()); issues.push("Type 3 fonts aren't allowed".into());
} }
issues.extend(device_rgb(pdf));
issues 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<String> {
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<usize>)> = 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::<Dict<'_>>(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<String> = 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::<Dict<'_>>(xref.root_id()) else { return false };
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return false };
intents.iter::<Dict<'_>>().any(|oi| {
oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")
&& oi.get::<Stream<'_>>(b"DestOutputProfile").and_then(|p| p.dict().get::<i32>(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::<Object<'_>>();
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<String>,
/// Forms, images and patterns already looked at on this page.
seen: HashSet<ObjectIdentifier>,
}
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::<Dict<'_>>(b"SMask"));
if let Some(form) = mask.and_then(|m| m.get::<Stream<'_>>(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::<Name<'_>>(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::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(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::<bool>(b"ImageMask") == Some(true) {
return;
}
if img.dict().get::<Object<'_>>(b"ColorSpace").is_some_and(|cs| is_device_rgb(&cs)) {
let (w, h) = (img.dict().get::<i32>(b"Width").unwrap_or(0), img.dict().get::<i32>(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::<Dict<'_>>(b"Resources").unwrap_or_default(), parent.clone());
if let Some(group) = form.dict().get::<Dict<'_>>(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::<Object<'_>>(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::<Object<'_>>(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::<Object<'_>>(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<u8>, 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<u8>>) -> Vec<u8> {
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<u8> {
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<u8>);
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<u8> {
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<String> {
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let mut out: Vec<String> = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>(b"Subtype").is_some_and(|n| n.as_str() == "Image") && s.dict().get::<bool>(b"ImageMask").is_none())
.filter_map(|s| match s.dict().get::<Object>(b"ColorSpace")? {
Object::Name(n) => Some(n.as_str().to_string()),
Object::Array(a) => a.iter::<Name>().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<u8> {
let objects = [
"<</Type/Catalog/Pages 2 0 R/OutputIntents[<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>]>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]{entries}/Contents 5 0 R>>"),
"<</N 4/Length 0>>\nstream\n\nendstream".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</N 3/Length 0>>\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<</Size {}/Root 1 0 R>>\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<</ColorSpace<</DefaultRGB[/ICCBased 6 0 R]>>>>", 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<</S/Transparency/CS/DeviceRGB>>", "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:?}");
}
}

View File

@ -695,16 +695,17 @@ impl Renderer {
ctx.pop_layer(); ctx.pop_layer();
} }
StrokeAlign::Outside if closed => { StrokeAlign::Outside if closed => {
let outline = // Subtract the filled path from a double-width stroke. Combining a stroked
kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px); // outline (already hollow) with the path using even-odd would paint its centre.
let mut p = outline; // Isolate the stroke so the subtraction leaves the item's fill/content and
// Remove the interior: even-odd with the path itself. // backdrop intact; the same nonzero fill rule also preserves compound holes.
for el in bp.elements() { ctx.push_layer(None, None, None, None, None);
p.push(*el); ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
} ctx.stroke_path(bp);
ctx.set_fill_rule(peniko::Fill::EvenOdd); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
ctx.fill_path(&p); ctx.fill_path(bp);
ctx.set_fill_rule(peniko::Fill::NonZero); ctx.pop_layer();
ctx.pop_layer();
} }
_ => { _ => {
ctx.set_stroke(stroke); ctx.set_stroke(stroke);

View File

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

View File

@ -30,3 +30,7 @@ log = { workspace = true }
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
designcraft-fonts = { workspace = true, features = ["testing"] } designcraft-fonts = { workspace = true, features = ["testing"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"

105
crates/ui-egui/build.rs Normal file
View File

@ -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::<Value>(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::<toml::Table>().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<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
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<Value> {
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
}

View File

@ -1,5 +1,6 @@
//! Help › About DesignCraft: the splash with the ArtCraft mark, version, and community links //! Help › About DesignCraft: tabs About · Contributors · Models. About is the splash with the
//! (Discord first and largest), plus the other ArtCraft apps. //! 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 designcraft_engine::links;
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
@ -22,10 +23,13 @@ pub const SIBLINGS: &[(&str, &str, &str)] = &[
("drawcraft", "VectorCraft", "vector illustration"), ("drawcraft", "VectorCraft", "vector illustration"),
("filmcraft", "FilmCraft", "video editing, color and sound"), ("filmcraft", "FilmCraft", "video editing, color and sound"),
("lightcraft", "LightCraft", "photo library and raw development"), ("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"), ("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. /// Paint the ArtCraft mark into `rect` (square), rasterised once per size and cached.
pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) { pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) {
use designcraft_render::vello_cpu::{self, kurbo, peniko}; 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 t = Tokens::get(ctx);
let mut open: Option<&'static str> = None; let mut open: Option<&'static str> = None;
let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| { 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.vertical_centered(|ui| {
ui.add_space(8.0); ui.add_space(8.0);
let (r, _) = ui.allocate_exact_size(vec2(72.0, 72.0), Sense::hover()); let (r, _) = ui.allocate_exact_size(vec2(72.0, 72.0), Sense::hover());

View File

@ -328,7 +328,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// Cursor. // Cursor.
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { 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 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)); 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 // 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); 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()); let d = xf.unrotate_delta(resp.drag_delta());
if let Some(v) = app.view_mut() { 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); v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom);

View File

@ -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 f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") { let button = match s("button") {
Some("right") | Some("secondary") => egui::PointerButton::Secondary, Some("right") | Some("secondary") => egui::PointerButton::Secondary,
Some("middle") => egui::PointerButton::Middle,
_ => egui::PointerButton::Primary, _ => egui::PointerButton::Primary,
}; };
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);

View File

@ -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::<View>(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(""), "—");
}
}

View File

@ -2396,10 +2396,14 @@ pub fn command_fields(doc: &str) -> Vec<CommandField> {
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 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(); let mut out = Vec::new();
for part in parts { for part in parts {
// `key`, `key?`, `key: spec` or `key (spec)`: the key is the leading word.
let part = part.trim(); let part = part.trim();
let (k, spec) = match part.split_once(':') { let k_end = part.find(|c: char| c.is_whitespace() || c == ':' || c == '(').unwrap_or(part.len());
Some((k, v)) => (k.trim(), v.trim()), let (k, rest) = part.split_at(k_end);
None => (part, ""), 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 optional = k.ends_with('?');
let key = k.trim_end_matches('?'); let key = k.trim_end_matches('?');
@ -2660,6 +2664,18 @@ mod tests {
assert!(f[4].boolean); assert!(f[4].boolean);
assert!(command_fields("{}").is_empty()); assert!(command_fields("{}").is_empty());
assert!(command_fields("no params").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. // Every command with a "…" label parses without panicking.
for c in designcraft_engine::command_specs() { for c in designcraft_engine::command_specs() {
let _ = command_fields(c.params); let _ = command_fields(c.params);

View File

@ -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(&[(7.5, 12.5), (14.0, 3.0)]);
pen.line(&[(12.5, 12.5), (6.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.rect(4.0, 4.0, 13.0, 13.0);
pen.frect(2.8, 2.8, 5.2, 5.2); pen.frect(2.8, 2.8, 5.2, 5.2);
pen.frect(11.8, 11.8, 14.2, 14.2); pen.frect(11.8, 11.8, 14.2, 14.2);
pen.line(&[(14.0, 14.0), (18.0, 18.0)]); pen.line(&[(14.0, 14.0), (18.0, 18.0)]);
pen.line(&[(15.0, 18.0), (18.0, 18.0), (18.0, 15.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" => { "tool-rotate" => {
let pts: Vec<(f32, f32)> = (0..24) let pts: Vec<(f32, f32)> = (0..24)
.map(|i| { .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::<Vec<_>>())
}
#[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<String> = 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");
}
}

View File

@ -10,6 +10,7 @@ pub mod about;
pub mod canvas; pub mod canvas;
pub mod chrome; pub mod chrome;
pub mod control; pub mod control;
pub mod credits;
pub mod dialogs; pub mod dialogs;
pub mod dock; pub mod dock;
pub mod i18n; pub mod i18n;
@ -190,6 +191,9 @@ pub struct UiState {
pub task_bar: bool, pub task_bar: bool,
/// Help › About DesignCraft is open. /// Help › About DesignCraft is open.
pub about: bool, 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). /// URLs to open in the browser on the next frame (Help links, About, start screen).
pub pending_urls: Vec<String>, pub pending_urls: Vec<String>,
#[serde(skip)] #[serde(skip)]
@ -261,6 +265,7 @@ impl Default for UiState {
snap_zone: 4.0, snap_zone: 4.0,
task_bar: true, task_bar: true,
about: false, about: false,
about_tab: 0,
pending_urls: Vec::new(), pending_urls: Vec::new(),
status: String::new(), status: String::new(),
dialog: None, dialog: None,

View File

@ -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.issues", "Report an Issue…", None, "{} — opens the GitHub issue tracker"),
("help.website", "ArtCraft Website…", None, "{} — opens https://getartcraft.com"), ("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.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.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
( (
"window.workspace", "window.workspace",
@ -1279,6 +1284,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
} }
"window.taskBar" => flag(&mut app.ui.task_bar), "window.taskBar" => flag(&mut app.ui.task_bar),
"help.about" => { "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); app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true);
Ok(json!(app.ui.about)) 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] #[test]
fn snap_preferences_are_a_command() { fn snap_preferences_are_a_command() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); 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"); 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<egui::Event>| {
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::<usize>();
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] #[test]
fn tool_shortcuts_work_after_drawing_on_the_canvas() { fn tool_shortcuts_work_after_drawing_on_the_canvas() {
// Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch

View File

@ -3,7 +3,7 @@
use designcraft_tools::TOOL_GROUPS; use designcraft_tools::TOOL_GROUPS;
use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
use serde_json::json; use serde_json::{Value, json};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::{DesignApp, icons}; 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(); let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default();
app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); 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 if g.len() > 1
&& (resp.secondary_clicked() && (resp.secondary_clicked()
|| resp.long_touched() || 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) { fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) {
let q = r.right_bottom() - vec2(2.0, 2.0); 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)); p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE));

55
docs/contributors.md Normal file
View File

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

View File

@ -3,6 +3,16 @@
Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on Start the app with `--control <port>` (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. `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:<port>`) 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 ```json
{"id": 1, "method": "engine.execute", "params": {"command": "frame.create", "params": {"rect": [36, 36, 300, 200], "content": "text"}}} {"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}} {"id": 1, "ok": true, "result": {"id": 10, "story": 11}}
@ -18,7 +28,7 @@ Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The serve
| `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `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.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.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.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.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 | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |

View File

@ -93,4 +93,4 @@ key {"key": "Escape"}
## Community links ## 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.