Merge remote-tracking branch 'origin/main' into feat/cjk-and-fonts
# Conflicts: # crates/ui-egui/Cargo.toml
This commit is contained in:
commit
aee756d604
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-release # org larger runner (M2 Pro), release.yml@release only; capped by the actions_macos_xl budget
|
||||
environment: release
|
||||
env:
|
||||
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
|
|
|
|||
20
AGENTS.md
20
AGENTS.md
|
|
@ -1,6 +1,6 @@
|
|||
# DesignCraft — instructions for agents
|
||||
|
||||
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
|
||||
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
|
||||
|
|
@ -39,3 +39,21 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
|
|||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<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).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# DesignCraft — instructions for agents
|
||||
|
||||
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
|
||||
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,7 @@ dependencies = [
|
|||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-segmentation",
|
||||
|
|
|
|||
|
|
@ -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/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/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/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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`designcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use designcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
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}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -375,10 +375,12 @@ fn emit(
|
|||
graphic: cell.graphic.clone(),
|
||||
});
|
||||
// Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right.
|
||||
// The table border replaces outer edges.
|
||||
// The table border replaces outer edges unless the cell explicitly overrides it.
|
||||
let at_top = (y - top).abs() < 1e-6;
|
||||
let at_bottom = (y1 - bottom).abs() < 1e-6;
|
||||
let edge = |i: usize, outer: bool| if outer { o.border.clone() } else { cell.strokes[i].clone() };
|
||||
let edge = |i: usize, outer: bool| {
|
||||
if outer && !cell.border_overrides[i] { o.border.clone() } else { cell.strokes[i].clone() }
|
||||
};
|
||||
let mut push = |a: Point, b: Point, s: CellStroke| {
|
||||
if s.is_visible() {
|
||||
strokes.push(StrokeSeg { a, b, stroke: s });
|
||||
|
|
@ -474,3 +476,52 @@ pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: u
|
|||
None => Some((fi, c.origin.x, c.origin.y + 10.0, 9.0, 3.0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod border_tests {
|
||||
use super::*;
|
||||
use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument};
|
||||
|
||||
fn compose_table(table: Table) -> ComposedStory {
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
let layer = doc.default_layer();
|
||||
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap();
|
||||
doc.story_mut(sid).unwrap().insert_table(0, table);
|
||||
crate::compose_story(&doc, sid, &ComposeOptions::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cell_edges_override_table_border_without_changing_native_defaults() {
|
||||
let mut table = Table::new(77, 1, 1, 0, 0, 100.0);
|
||||
table.options.border.weight = 2.0;
|
||||
table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none());
|
||||
let native = compose_table(table.clone());
|
||||
let strokes = &native.frames[0].tables[0].strokes;
|
||||
assert_eq!(strokes.len(), 4, "default cells still use all four table borders");
|
||||
assert!(strokes.iter().all(|s| s.stroke.weight == 2.0));
|
||||
|
||||
let cell = table.cell_mut(0, 0).unwrap();
|
||||
cell.border_overrides = [true, true, true, false];
|
||||
cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() };
|
||||
let composed = compose_table(table);
|
||||
let fragment = &composed.frames[0].tables[0];
|
||||
assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders");
|
||||
let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap();
|
||||
assert_eq!(bottom.stroke.weight, 3.0);
|
||||
let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap();
|
||||
assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_cell_keeps_explicit_border_suppression() {
|
||||
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
|
||||
table.merge(CellRange::new(0, 0, 1, 1)).unwrap();
|
||||
let cell = table.cell_mut(0, 0).unwrap();
|
||||
cell.border_overrides = [true; 4];
|
||||
cell.strokes = std::array::from_fn(|_| CellStroke::none());
|
||||
let composed = compose_table(table);
|
||||
let fragment = &composed.frames[0].tables[0];
|
||||
assert_eq!(fragment.cells.len(), 1);
|
||||
assert!(fragment.strokes.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub struct Kinsoku {
|
|||
impl Kinsoku {
|
||||
/// Explicitly supplied character sets take precedence over the fallback table.
|
||||
pub fn allows(&self, a: char, b: char) -> bool {
|
||||
!self.no_end.contains(a) && !self.no_start.contains(b) && !(a == b && self.inseparable.contains(a))
|
||||
!(self.no_end.contains(a) || self.no_start.contains(b) || (a == b && self.inseparable.contains(a)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ pub struct Cell {
|
|||
/// Edge strokes: top, left, bottom, right.
|
||||
#[serde(default)]
|
||||
pub strokes: [CellStroke; 4],
|
||||
/// Explicit cell-edge formatting takes precedence over the table border, in the same
|
||||
/// top, left, bottom, right order as `strokes`. Older documents keep their table border.
|
||||
#[serde(default)]
|
||||
pub border_overrides: [bool; 4],
|
||||
/// Applied cell style ("" = [None]).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
|
|
@ -137,6 +141,7 @@ impl Default for Cell {
|
|||
vj: VerticalJustification::Top,
|
||||
rotation: 0.0,
|
||||
strokes: Default::default(),
|
||||
border_overrides: [false; 4],
|
||||
style: String::new(),
|
||||
graphic: None,
|
||||
}
|
||||
|
|
@ -1105,6 +1110,20 @@ mod tests {
|
|||
let st: Story = serde_json::from_str(old).unwrap();
|
||||
assert!(st.tables.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_border_overrides_default_for_old_documents_and_roundtrip() {
|
||||
let cell = Cell::default();
|
||||
let mut old = serde_json::to_value(&cell).unwrap();
|
||||
old.as_object_mut().unwrap().remove("borderOverrides");
|
||||
let back: Cell = serde_json::from_value(old).unwrap();
|
||||
assert_eq!(back.border_overrides, [false; 4]);
|
||||
|
||||
let explicit = Cell { border_overrides: [true, false, true, false], ..cell };
|
||||
let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap();
|
||||
assert_eq!(back, explicit);
|
||||
assert_eq!(back.blank_like().border_overrides, explicit.border_overrides);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -103,18 +103,25 @@ fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> {
|
|||
Ok((spread_param(p, "spread"), pi, i))
|
||||
}
|
||||
|
||||
/// Commands must use the same guide/layer protection as pointer editing.
|
||||
fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str) -> Result<()> {
|
||||
let g = d.spread(r).and_then(|sp| sp.pages.get(pi)).and_then(|pg| pg.guides.get(i)).ok_or_else(|| bad(command, "no such guide"))?;
|
||||
if !g.editable_in(d) {
|
||||
return Err(bad(command, "the guide or its layer is locked or hidden"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (r, pi, i) = guide_ref(p, "guide.move")?;
|
||||
let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?;
|
||||
s.edit(|d, _| {
|
||||
check_editable(d, r, pi, i, "guide.move")?;
|
||||
let g = d
|
||||
.spread_mut(r)
|
||||
.and_then(|sp| sp.pages.get_mut(pi))
|
||||
.and_then(|pg| pg.guides.get_mut(i))
|
||||
.ok_or_else(|| bad("guide.move", "no such guide"))?;
|
||||
if g.locked {
|
||||
return Err(bad("guide.move", "the guide is locked"));
|
||||
}
|
||||
g.position = pos;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
|
|
@ -123,6 +130,7 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (r, pi, i) = guide_ref(p, "guide.delete")?;
|
||||
s.edit(|d, _| {
|
||||
check_editable(d, r, pi, i, "guide.delete")?;
|
||||
let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?;
|
||||
if i >= pg.guides.len() {
|
||||
return Err(bad("guide.delete", "no such guide"));
|
||||
|
|
@ -238,6 +246,26 @@ mod layer_tests {
|
|||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn guide_commands_respect_layer_locks() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.activate", &json!({"id": layer})).unwrap();
|
||||
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
|
||||
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
|
||||
for command in ["guide.move", "guide.delete"] {
|
||||
assert!(s.execute(command, &json!({"page": 0, "index": 0, "position": 200})).is_err(), "{command} changed a locked layer");
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 100.0);
|
||||
}
|
||||
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
|
||||
s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 200})).unwrap();
|
||||
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap(), json!([]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[0]["position"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_follow_their_layer() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
let dir = dir.map(|d| d.to_path_buf());
|
||||
let read = move |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Ok(b) = std::fs::read(link) {
|
||||
return Some(b);
|
||||
}
|
||||
let name = link.rsplit(['/', '\\']).next()?;
|
||||
let dir = dir.as_ref()?;
|
||||
std::fs::read(dir.join(name)).or_else(|_| std::fs::read(dir.join("Links").join(name))).ok()
|
||||
let (path, data) = read_packaged_link(link, dir)?;
|
||||
resolved.borrow_mut().insert(link.to_string(), path);
|
||||
Some(data)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
|
@ -51,7 +49,53 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
|||
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> {
|
||||
|
|
@ -81,3 +125,58 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let i = s.add_document(st);
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn linked_idml(link: &str) -> Result<(Vec<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
|
|
|
|||
|
|
@ -2198,6 +2198,9 @@ impl<'a> Ex<'a> {
|
|||
.attr("RotationAngle", num(cell.rotation));
|
||||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]);
|
||||
if cell.border_overrides[i] {
|
||||
ce.set(&format!("{side}StrokePriority"), 1);
|
||||
}
|
||||
}
|
||||
for psr in self.story_paras(&cell.text) {
|
||||
ce.push(psr);
|
||||
|
|
|
|||
|
|
@ -1548,6 +1548,9 @@ impl<'r> Importer<'r> {
|
|||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
let base = cell.strokes[i].clone();
|
||||
cell.strokes[i] = stroke(self, ce, side, &base);
|
||||
// A positive edge priority records a local cell override, including an
|
||||
// explicit None stroke. Keep it when this edge meets the table perimeter.
|
||||
cell.border_overrides[i] = ce.num(&format!("{side}StrokePriority")).is_some_and(|priority| priority > 0.0);
|
||||
}
|
||||
if let Some(slot) = t.cell_mut(r, c) {
|
||||
*slot = cell;
|
||||
|
|
@ -2350,13 +2353,8 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
|
|||
o.column_width = v;
|
||||
}
|
||||
o.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
|
||||
if let Some(l) = e.prop_el("InsetSpacing") {
|
||||
let v: Vec<f64> = l.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect();
|
||||
if v.len() == 4 {
|
||||
o.inset = [v[0], v[1], v[2], v[3]];
|
||||
}
|
||||
} else if let Some(v) = e.num("InsetSpacing") {
|
||||
o.inset = [v; 4];
|
||||
if let Some(inset) = inset_spacing(e) {
|
||||
o.inset = inset;
|
||||
}
|
||||
if let Some(v) = e.get("VerticalJustification") {
|
||||
o.vertical_justification = names::vj_in(v);
|
||||
|
|
@ -2380,6 +2378,32 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
|
|||
o
|
||||
}
|
||||
|
||||
/// A scalar applies to every edge; a list is top, left, bottom, right. Keep the
|
||||
/// existing property-element precedence over the legacy scalar attribute, even
|
||||
/// when the property is malformed. Finite negative insets remain supported.
|
||||
fn inset_spacing(e: &El) -> Option<[f64; 4]> {
|
||||
let number = |s: &str| s.trim().parse::<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 {
|
||||
let mut subs = Vec::new();
|
||||
for g in pg.find_all("GeometryPathType") {
|
||||
|
|
|
|||
|
|
@ -269,6 +269,119 @@ fn imports_hand_written_fixture() {
|
|||
assert_eq!(a.name, "photo.jpg");
|
||||
}
|
||||
|
||||
/// Hand-written inset encodings, independent of the exporter's four-item list.
|
||||
fn inset_fixture(preference: &str, object_styles: &str) -> Document {
|
||||
let designmap = format!(
|
||||
r#"<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]
|
||||
fn rejects_non_idml() {
|
||||
assert!(import_idml(b"not a zip").is_err());
|
||||
|
|
@ -383,6 +496,40 @@ fn round_trips_tables() {
|
|||
assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_and_round_trips_explicit_cell_border_overrides() {
|
||||
// Hand-written IDML: the outer cell edge may explicitly suppress or replace the
|
||||
// table border. An absent/zero priority keeps the table border's precedence.
|
||||
let story = r#"<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]
|
||||
fn round_trips_footnotes_and_options() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
|
|
|||
|
|
@ -207,11 +207,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
|||
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
|
||||
let mut warnings = ex.warnings;
|
||||
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) {
|
||||
// The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any
|
||||
// group that got past it, with its page.
|
||||
crate::pdfx::cmyk_group_spaces(&mut bytes);
|
||||
// The rewrite matches krilla's exact output; say so if a group got past it.
|
||||
if opts.standard == Standard::PdfX4 && crate::pdfx::has_rgb_groups(&bytes) {
|
||||
warnings.push("PDF/X-4: some transparency groups still blend in RGB".into());
|
||||
}
|
||||
}
|
||||
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X.
|
||||
let fields = crate::forms::collect(doc, &sheets);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
//! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a
|
||||
//! check of the requirements this exporter is responsible for.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use hayro_syntax::content::UntypedIter;
|
||||
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream};
|
||||
use hayro_syntax::page::Resources;
|
||||
|
||||
use crate::transitions::{find, int_after, object, rfind};
|
||||
|
||||
/// Escape a PDF literal string.
|
||||
|
|
@ -187,7 +193,8 @@ fn dictionary_ranges(pdf: &[u8]) -> Option<Vec<std::ops::Range<usize>>> {
|
|||
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> {
|
||||
let mut issues = Vec::new();
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
|
|
@ -217,5 +224,445 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
|
|||
if text.contains("/Subtype/Type3") {
|
||||
issues.push("Type 3 fonts aren't allowed".into());
|
||||
}
|
||||
issues.extend(device_rgb(pdf));
|
||||
issues
|
||||
}
|
||||
|
||||
/// Device-dependent RGB under a CMYK output intent. PDF/X-4 (ISO 15930-7) lets RGB in only when
|
||||
/// it is colour-managed: ICCBased RGB, or DeviceRGB remapped by a `DefaultRGB` colour space in the
|
||||
/// resources. DeviceCMYK and DeviceGray are the output intent's own device values, so they stay.
|
||||
/// This walks every page's content, the images, forms, patterns, shadings and soft masks it uses,
|
||||
/// and the transparency groups, and reports each kind of device RGB it finds with the pages it is
|
||||
/// on. Nothing is reported when the file has no CMYK PDF/X output intent.
|
||||
fn device_rgb(pdf: &[u8]) -> Vec<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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -695,16 +695,17 @@ impl Renderer {
|
|||
ctx.pop_layer();
|
||||
}
|
||||
StrokeAlign::Outside if closed => {
|
||||
let outline =
|
||||
kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px);
|
||||
let mut p = outline;
|
||||
// Remove the interior: even-odd with the path itself.
|
||||
for el in bp.elements() {
|
||||
p.push(*el);
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
ctx.fill_path(&p);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
// Subtract the filled path from a double-width stroke. Combining a stroked
|
||||
// outline (already hollow) with the path using even-odd would paint its centre.
|
||||
// Isolate the stroke so the subtraction leaves the item's fill/content and
|
||||
// backdrop intact; the same nonzero fill rule also preserves compound holes.
|
||||
ctx.push_layer(None, None, None, None, None);
|
||||
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
|
||||
ctx.stroke_path(bp);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
|
||||
ctx.fill_path(bp);
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
_ => {
|
||||
ctx.set_stroke(stroke);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -30,3 +30,7 @@ log = { workspace = true }
|
|||
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Help › About DesignCraft: the splash with the ArtCraft mark, version, and community links
|
||||
//! (Discord first and largest), plus the other ArtCraft apps.
|
||||
//! Help › About DesignCraft: tabs About · Contributors · Models. About is the splash with the
|
||||
//! ArtCraft mark, version, and community links (Discord first and largest), plus the other ArtCraft
|
||||
//! apps; Contributors and Models are the compiled-in credits (`crate::credits`).
|
||||
|
||||
use designcraft_engine::links;
|
||||
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
|
|
@ -22,10 +23,13 @@ pub const SIBLINGS: &[(&str, &str, &str)] = &[
|
|||
("drawcraft", "VectorCraft", "vector illustration"),
|
||||
("filmcraft", "FilmCraft", "video editing, color and sound"),
|
||||
("lightcraft", "LightCraft", "photo library and raw development"),
|
||||
("printcraft", "PrintCraft", "reading, organizing and protecting PDFs"),
|
||||
("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"),
|
||||
("effectcraft", "EffectCraft", "motion graphics and visual effects"),
|
||||
];
|
||||
|
||||
/// The About window's tabs, in `UiState::about_tab` order (`help.about {tab}` names them in lowercase).
|
||||
pub const ABOUT_TABS: [&str; 3] = ["About", "Contributors", "Models"];
|
||||
|
||||
/// Paint the ArtCraft mark into `rect` (square), rasterised once per size and cached.
|
||||
pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) {
|
||||
use designcraft_render::vello_cpu::{self, kurbo, peniko};
|
||||
|
|
@ -103,7 +107,34 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
|||
let t = Tokens::get(ctx);
|
||||
let mut open: Option<&'static str> = None;
|
||||
let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 });
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in ABOUT_TABS.iter().enumerate() {
|
||||
let i = i as u8;
|
||||
if ui.selectable_label(app.ui.about_tab == i, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||||
app.ui.about_tab = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if app.ui.about_tab != 0 {
|
||||
let h = 400.0_f32.min(ctx.content_rect().height() * 0.7).max(160.0);
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), h), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui.set_min_height(h);
|
||||
if app.ui.about_tab == 1 {
|
||||
crate::credits::contributors_ui(ui);
|
||||
} else {
|
||||
crate::credits::models_ui(ui);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical_centered(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, " Close "))).clicked() {
|
||||
app.ui.about = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(8.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(72.0, 72.0), Sense::hover());
|
||||
|
|
|
|||
|
|
@ -328,7 +328,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
// Cursor.
|
||||
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) {
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text();
|
||||
let c = if space { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) };
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let c = if middle {
|
||||
Cursor::HandGrab
|
||||
} else if space {
|
||||
Cursor::Hand
|
||||
} else {
|
||||
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
|
||||
};
|
||||
ui.ctx().set_cursor_icon(cursor_icon(c));
|
||||
}
|
||||
}
|
||||
|
|
@ -1345,9 +1352,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
|||
}
|
||||
}
|
||||
// Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live
|
||||
// Distribute while resizing).
|
||||
// Distribute while resizing). Middle-drag pans too, with any tool; tools only
|
||||
// see the primary button.
|
||||
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false);
|
||||
if space && !tool_drag && resp.dragged() {
|
||||
if !tool_drag && ((space && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) {
|
||||
let d = xf.unrotate_delta(resp.drag_delta());
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom);
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
|
|||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let button = match s("button") {
|
||||
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
||||
|
|
|
|||
|
|
@ -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(""), "—");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 mut out = Vec::new();
|
||||
for part in parts {
|
||||
// `key`, `key?`, `key: spec` or `key (spec)`: the key is the leading word.
|
||||
let part = part.trim();
|
||||
let (k, spec) = match part.split_once(':') {
|
||||
Some((k, v)) => (k.trim(), v.trim()),
|
||||
None => (part, ""),
|
||||
let k_end = part.find(|c: char| c.is_whitespace() || c == ':' || c == '(').unwrap_or(part.len());
|
||||
let (k, rest) = part.split_at(k_end);
|
||||
let rest = rest.trim_start();
|
||||
let spec = match rest.strip_prefix(':') {
|
||||
Some(v) => v.trim(),
|
||||
None => rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')).unwrap_or(rest).trim(),
|
||||
};
|
||||
let optional = k.ends_with('?');
|
||||
let key = k.trim_end_matches('?');
|
||||
|
|
@ -2660,6 +2664,18 @@ mod tests {
|
|||
assert!(f[4].boolean);
|
||||
assert!(command_fields("{}").is_empty());
|
||||
assert!(command_fields("no params").is_empty());
|
||||
// A parenthetical after the key describes the value.
|
||||
let f = command_fields("{angle (degrees, CCW), ids?}");
|
||||
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
|
||||
assert_eq!(keys, ["angle", "ids"]);
|
||||
assert!(!f[0].optional && f[1].optional);
|
||||
assert_eq!(f[0].hint, "degrees, CCW");
|
||||
let f = command_fields("{scaleX? (%), ref?: 0..8, ids?: parent items (default: all)}");
|
||||
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
|
||||
assert_eq!(keys, ["scaleX", "ref", "ids"]);
|
||||
assert!(f.iter().all(|f| f.optional));
|
||||
assert_eq!(f[0].hint, "%");
|
||||
assert_eq!(f[2].hint, "parent items (default: all)");
|
||||
// Every command with a "…" label parses without panicking.
|
||||
for c in designcraft_engine::command_specs() {
|
||||
let _ = command_fields(c.params);
|
||||
|
|
|
|||
|
|
@ -147,13 +147,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
|
|||
pen.line(&[(7.5, 12.5), (14.0, 3.0)]);
|
||||
pen.line(&[(12.5, 12.5), (6.0, 3.0)]);
|
||||
}
|
||||
"tool-free-transform" | "tool-scale" | "tool-shear" => {
|
||||
"tool-free-transform" => {
|
||||
pen.rect(4.0, 4.0, 13.0, 13.0);
|
||||
pen.frect(2.8, 2.8, 5.2, 5.2);
|
||||
pen.frect(11.8, 11.8, 14.2, 14.2);
|
||||
pen.line(&[(14.0, 14.0), (18.0, 18.0)]);
|
||||
pen.line(&[(15.0, 18.0), (18.0, 18.0), (18.0, 15.0)]);
|
||||
}
|
||||
"tool-scale" => {
|
||||
// A small square grown into a larger one from their shared corner.
|
||||
Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 3.0, 17.0, 17.0);
|
||||
pen.rect(3.0, 10.0, 10.0, 17.0);
|
||||
pen.line(&[(10.0, 10.0), (15.0, 5.0)]);
|
||||
pen.line(&[(11.5, 5.0), (15.0, 5.0), (15.0, 8.5)]);
|
||||
}
|
||||
"tool-shear" => {
|
||||
// A rectangle slanted into a parallelogram along its base.
|
||||
Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 8.0, 13.0, 17.0);
|
||||
pen.closed(&[(7.0, 8.0), (17.0, 8.0), (13.0, 17.0), (3.0, 17.0)]);
|
||||
pen.line(&[(7.0, 4.0), (16.0, 4.0)]);
|
||||
pen.line(&[(13.5, 2.0), (16.0, 4.0), (13.5, 6.0)]);
|
||||
}
|
||||
"tool-rotate" => {
|
||||
let pts: Vec<(f32, f32)> = (0..24)
|
||||
.map(|i| {
|
||||
|
|
@ -791,3 +805,31 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What icon `name` paints into a 20×20 rect, as the painter's shape list.
|
||||
fn drawing(name: &str) -> String {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::<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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub mod about;
|
|||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod dock;
|
||||
pub mod i18n;
|
||||
|
|
@ -190,6 +191,9 @@ pub struct UiState {
|
|||
pub task_bar: bool,
|
||||
/// Help › About DesignCraft is open.
|
||||
pub about: bool,
|
||||
/// The About window's tab: 0 About, 1 Contributors, 2 Models (`about::ABOUT_TABS`).
|
||||
#[serde(skip)]
|
||||
pub about_tab: u8,
|
||||
/// URLs to open in the browser on the next frame (Help links, About, start screen).
|
||||
pub pending_urls: Vec<String>,
|
||||
#[serde(skip)]
|
||||
|
|
@ -261,6 +265,7 @@ impl Default for UiState {
|
|||
snap_zone: 4.0,
|
||||
task_bar: true,
|
||||
about: false,
|
||||
about_tab: 0,
|
||||
pending_urls: Vec::new(),
|
||||
status: String::new(),
|
||||
dialog: None,
|
||||
|
|
|
|||
|
|
@ -135,7 +135,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
("help.issues", "Report an Issue…", None, "{} — opens the GitHub issue tracker"),
|
||||
("help.website", "ArtCraft Website…", None, "{} — opens https://getartcraft.com"),
|
||||
("help.app", "ArtCraft App Page…", None, "{app} — opens https://getartcraft.com/apps/{app} (e.g. photocraft)"),
|
||||
("help.about", "About DesignCraft", None, "{open?: true} — the About splash with community links (open: false closes it)"),
|
||||
(
|
||||
"help.about",
|
||||
"About DesignCraft",
|
||||
None,
|
||||
"{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)",
|
||||
),
|
||||
("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
|
||||
(
|
||||
"window.workspace",
|
||||
|
|
@ -1279,6 +1284,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
}
|
||||
"window.taskBar" => flag(&mut app.ui.task_bar),
|
||||
"help.about" => {
|
||||
if let Some(tab) = p.get("tab").and_then(Value::as_str) {
|
||||
let Some(i) = crate::about::ABOUT_TABS.iter().position(|t| t.eq_ignore_ascii_case(tab)) else {
|
||||
return Some(Err(format!("help.about: unknown tab `{tab}` (about, contributors, models)")));
|
||||
};
|
||||
app.ui.about_tab = u8::try_from(i).unwrap_or(0);
|
||||
}
|
||||
app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true);
|
||||
Ok(json!(app.ui.about))
|
||||
}
|
||||
|
|
@ -2006,6 +2017,16 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_menu_items_open_their_dialogs() {
|
||||
for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
activate(&mut app, id, &Value::Null);
|
||||
let dialog = app.ui.dialog.as_ref().map(|d| d.id.clone());
|
||||
assert_eq!(dialog.as_deref(), Some(format!("cmd:{id}").as_str()), "{id} opens its dialog");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_preferences_are_a_command() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
|
@ -2259,6 +2280,43 @@ mod tests {
|
|||
assert!((app.view().unwrap().zoom - z0).abs() < 1e-9, "back at the zoom it started from");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_with_any_tool() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<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]
|
||||
fn tool_shortcuts_work_after_drawing_on_the_canvas() {
|
||||
// Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use designcraft_tools::TOOL_GROUPS;
|
||||
use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{DesignApp, icons};
|
||||
|
|
@ -91,6 +91,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default();
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur));
|
||||
}
|
||||
// Double-click a transform tool: its Object › Transform dialog.
|
||||
if resp.double_clicked()
|
||||
&& let Some(cmd) = transform_dialog_of(shown.id)
|
||||
{
|
||||
crate::menus::activate(app, cmd, &Value::Null);
|
||||
}
|
||||
if g.len() > 1
|
||||
&& (resp.secondary_clicked()
|
||||
|| resp.long_touched()
|
||||
|
|
@ -343,6 +349,17 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move).
|
||||
fn transform_dialog_of(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
"selection" => Some("transform.move"),
|
||||
"rotate" => Some("transform.rotate"),
|
||||
"scale" => Some("transform.scale"),
|
||||
"shear" => Some("transform.shear"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) {
|
||||
let q = r.right_bottom() - vec2(2.0, 2.0);
|
||||
p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -3,6 +3,16 @@
|
|||
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.
|
||||
|
||||
**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
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "frame.create", "params": {"rect": [36, 36, 300, 200], "content": "text"}}}
|
||||
{"id": 1, "ok": true, "result": {"id": 10, "story": 11}}
|
||||
|
|
@ -18,7 +28,7 @@ Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The serve
|
|||
| `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) |
|
||||
| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and panel |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state |
|
||||
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) |
|
||||
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
|
||||
|
|
|
|||
|
|
@ -93,4 +93,4 @@ key {"key": "Escape"}
|
|||
|
||||
## Community links
|
||||
|
||||
`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about` shows the About splash. The server's `serverInfo.websiteUrl` is the app page.
|
||||
`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about {tab?}` shows the About window (tabs `about`, `contributors`, `models`). The server's `serverInfo.websiteUrl` is the app page.
|
||||
|
|
|
|||
Loading…
Reference in New Issue