From 5a7ca767db03ccd5dd0872067d10064e77900463 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:50:23 +0100 Subject: [PATCH 01/18] UI: the Control panel shows its second row (#142) In every workspace that shows the Control panel (Advanced, Typography, Printing and Proofing, Book), the second row of fields was cut off at the panel's lower edge: Y and H in object mode, leading, tracking and the indents in text mode. The panel lays its groups out in a row that centres each child vertically. The two-row grids were placed as if they were one row tall and then grew downward, so they started 13.5 pt below the panel's top instead of at it and ended 5.5 pt past the 59 pt panel. The row is now top-aligned, which is where the font menus, the separators and the reference-point proxy already sat. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/lib.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 23876f3a7..4d93517d6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -164,7 +164,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { // Wider than the window: the bar scrolls sideways. crate::widgets::overflow_scrolling(ui); egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| { - ui.horizontal(|ui| { + ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); if text_mode { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4ec0b8b08..89ffd1a71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -912,6 +912,30 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { mod tests { use super::*; + /// The Control panel's two-row groups were centred as if they were one row tall, so the second + /// row hung below the panel and was cut off. + #[test] + fn control_bar_shows_its_second_row() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.newSample", json!({})).unwrap(); + app.ui.control_bar = true; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("control_bar")).unwrap().outer_rect; + assert!(bar.height() > 40.0, "{bar:?}"); + let rows: Vec = h.query_all_by_label("Y:").map(|n| n.rect()).filter(|r| bar.contains(r.left_top())).collect(); + assert_eq!(rows.len(), 1, "the Y: caption of the Control panel"); + assert!(rows[0].bottom() <= bar.bottom(), "Y: caption {:?} hangs below the Control panel {bar:?}", rows[0]); + } + #[test] fn key_names_sent_as_text_are_dropped() { let text = |t: &str| egui::Event::Text(t.to_string()); From bbe234aaa6cad692a057362065f3adc1ca063cce Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:50:29 +0100 Subject: [PATCH 02/18] PDF: a damaged PNG, GIF or WebP is skipped, not fatal (#147) A placed PNG, GIF or WebP that is cut off (an interrupted download) or has a bad checksum made the whole PDF export fail with "PDF writer error: Image(Image(ImageRepr {..}), None, "unexpected end of file")", which names no file. A damaged TIFF or BMP in the same place is skipped with the warning "image `name` could not be decoded and was skipped", and the rest of the document is exported. The exporter decides whether an image can be embedded by asking krilla to open it. krilla opens PNG, GIF and WebP lazily: it reads the header, accepts the file, and decodes the pixels only when the PDF is written, where an error ends the export. TIFF and BMP are decoded by us first, so they took the skip path. lossless() now decodes the image first for every format. A file that does not decode returns None and takes the existing skip-with-warning path. This costs one extra decode per PNG, GIF or WebP asset: the sample magazine (two PNGs, 820 KB) exports in 0.19 s instead of 0.17 s in a debug build. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/pdf/src/export.rs | 5 ++++- crates/pdf/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 095ea77cc..e5ebb6765 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1281,6 +1281,10 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { } fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { + // krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole + // export when the PDF is written. Decoding it first means it is skipped, like any other + // image that can't be decoded. + let rgba = designcraft_images::decode_rgba(data)?; let direct = match fmt { Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), @@ -1289,7 +1293,6 @@ fn lossless(data: &Arc>, fmt: Option, interpolate: b }; direct.or_else(|| { // TIFF, BMP, PSD composites… - let rgba = designcraft_images::decode_rgba(data)?; let (w, h) = rgba.dimensions(); Some(Image::from_rgba8(rgba.into_raw(), w, h)) }) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 83744a3e1..18aa5b418 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -240,6 +240,31 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } +/// krilla reads PNG, GIF and WebP lazily, so a damaged one passed the exporter's "does it decode" +/// check and failed the whole export when the PDF was written ("PDF writer error: … unexpected +/// end of file"). A cut-off download must be skipped with a warning, as a damaged TIFF is. +#[test] +fn a_cut_off_image_is_skipped_not_fatal() { + let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 37 + y * 11) as u8, (x * 5) as u8, (y * 91) as u8, 255])); + for format in [image::ImageFormat::Png, image::ImageFormat::Gif, image::ImageFormat::WebP] { + let mut whole = Vec::new(); + px.write_to(&mut std::io::Cursor::new(&mut whole), format).unwrap(); + let cut = whole[..whole.len() * 9 / 10].to_vec(); + let mut d = doc_with_text("still here"); + place(&mut d, cut, (64, 64)); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}")); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "{format:?}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "{format:?}"); + assert!(extract_text(&r.bytes).concat().contains("still"), "{format:?}: the rest of the page is exported"); + // The undamaged file still goes in. + let mut d = doc_with_text("x"); + place(&mut d, whole, (64, 64)); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(r.warnings.is_empty(), "{format:?}: {:?}", r.warnings); + assert_eq!(image_xobjects(&r.bytes).len(), 1, "{format:?}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name; From 5369687497dfc7611beee3757384a470abde482a Mon Sep 17 00:00:00 2001 From: kubuntugamer <52578831+kubuntugamer@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:34 -0500 Subject: [PATCH 03/18] fix(cli): --help prints usage to stdout and exits 0 (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking for help is not an error, but `designcraft-cli --help` shared the catch-all arm with unknown commands, so it wrote the usage block to stderr and exited 1 — unusable in a pipeline and wrong by convention (`--version` already exits 0). Split the arm: `--help`, `-h` and `help` now print the same usage block to stdout and succeed, while an unknown command keeps it on stderr and still fails. The usage text and links line are hoisted into a shared `USAGE` const and `usage_footer()` so the two paths can't drift. Fixes #243 🤖 Generated with Codebuff Co-authored-by: Codebuff Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/designcraft-cli/src/main.rs | 31 ++++++++++++++++++++++--------- apps/designcraft-cli/tests/cli.rs | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index a564befc2..11514f709 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -34,6 +34,19 @@ mod perf; use designcraft_engine::Session; use serde_json::{Value, json}; +/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; + +/// The links line under the usage block. +fn usage_footer() -> String { + format!( + "\nCommunity: {} · {} · {}", + designcraft_engine::links::DISCORD, + designcraft_engine::links::APP_PAGE, + designcraft_engine::links::GITHUB + ) +} + /// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as /// ripgrep does; any other write error is reported. fn stdout_failed(e: std::io::Error) -> ! { @@ -76,16 +89,16 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); ExitCode::SUCCESS } + // Asking for help is not an error: it goes to stdout and succeeds. An unknown command + // still prints the same usage to stderr and fails. + Some("--help" | "-h" | "help") => { + outln!("{USAGE}"); + outln!("{}", usage_footer()); + ExitCode::SUCCESS + } _ => { - eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version" - ); - eprintln!( - "\nCommunity: {} · {} · {}", - designcraft_engine::links::DISCORD, - designcraft_engine::links::APP_PAGE, - designcraft_engine::links::GITHUB - ); + eprintln!("{USAGE}"); + eprintln!("{}", usage_footer()); ExitCode::FAILURE } } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 8c2b0f14b..1a06d13b0 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -67,6 +67,26 @@ fn run_cmd_references_and_describe() { assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote"))); } +/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an +/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same +/// usage on stderr and fails. +#[test] +fn help_goes_to_stdout_and_unknown_args_to_stderr() { + for flag in ["--help", "-h", "help"] { + let out = cli().arg(flag).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?}", out.status); + assert!(stdout.starts_with("usage: designcraft-cli"), "{flag}: {stdout:?}"); + assert!(stdout.contains("designcraft-cli --version"), "{flag}: {stdout:?}"); + assert!(out.stderr.is_empty(), "{flag} wrote to stderr: {}", String::from_utf8_lossy(&out.stderr)); + } + // An unknown option is still an error: usage on stderr, nothing on stdout, non-zero exit. + let out = cli().arg("--no-such-option").output().unwrap(); + assert!(!out.status.success(), "{:?}", out.status); + assert!(out.stdout.is_empty(), "unexpected stdout: {}", String::from_utf8_lossy(&out.stdout)); + assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] From 3d2bda4d6b056e2ddc3c7b24af97da2605aa680c Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 10 Oct 2026 07:50:38 +0200 Subject: [PATCH 04/18] Desktop: write log records to standard error and a rotating log file (#122) * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error! in the workspace was discarded: the crash guard's panic report, a failed crash-recovery save, font folders that can't be read. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/designcraft/src/logging.rs (no new dependency): - standard error plus /logs/designcraft.log (beside ui.json and prefs.json); each launch rotates the previous log to designcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for designcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink The logger is installed first in main (before the engine's panic hook) and the file is opened after argument parsing (--version leaves no file). The settings directory moves out of prefs_path into config_dir. A --control or DESIGNCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). * Desktop: no eprintln! on the log and panic paths eprintln! panics when standard error is a broken pipe (a GUI launch on Windows), and a panic inside the panic hook aborts. The "no log file" message goes through log::warn! (standard error only by then); the panic hook's fallback writes to standard error with write_fmt and ignores the error. Same fix as storytold/vectorcraft#465. --- README.md | 17 ++ apps/designcraft/src/logging.rs | 437 ++++++++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 50 +++- 3 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 apps/designcraft/src/logging.rs diff --git a/README.md b/README.md index c6c08faf6..6884a15e5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,23 @@ include it. To drive a running app, send JSON lines to `127.0.0.1:7979`. The protocol is described in [`docs/control-protocol.md`](docs/control-protocol.md). +### Logs + +The desktop app writes its `log` records to standard error and to `logs/designcraft.log` in the +settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or +`$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`; +Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no +terminal, so this file is what to attach to a bug report: the crash guard's panic report and a +failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log` +(and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start. +The file stops growing at 16 MiB. `--version` writes no file. + +| Variable | Effect | +|---|---| +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | + +The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. + ### Web ```sh diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs new file mode 100644 index 000000000..d3e0dbea7 --- /dev/null +++ b/apps/designcraft/src/logging.rs @@ -0,0 +1,437 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic +//! report, a failed crash-recovery save, font folders that can't be read) vanished. A launch from +//! a desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/designcraft.log` (the directory beside `ui.json` and `prefs.json`, see +//! `config_dir` in `main.rs`). Each start moves the previous log to `designcraft.1.log` (and that +//! one to `.2`), so the log of a run that crashed survives the next launch. +//! +//! Levels: `info` for DesignCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,designcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every +//! target that starts with it (`designcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "designcraft.log"; +/// How many previous logs are kept (`designcraft.1.log` … `designcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,designcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + u64::from(m <= 2); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. + pub fn attach_dir(&self, dir: &Path) -> Result { + let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + Ok(path) + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("designcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn the_default_filter_shows_designcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("designcraft"), LevelFilter::Info); + assert_eq!(f.level_for("designcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,designcraft_render=trace,designcraft_render::tile=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("designcraft_render::raster"), LevelFilter::Trace); + assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + for spec in ["", ",,,", "=", "=debug", "designcraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,designcraft=DEBUG"] { + let f = Filter::parse(spec); + let _ = f.level_for("designcraft"); + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,designcraft=DEBUG").level_for("designcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + // 2026-10-08T07:59:17.728Z + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap days, including the century rule (2000 is a leap year, 2100 is not). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(std::fs::read_to_string(&path).expect("read"), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4"); + assert_eq!(std::fs::read_to_string(dir.join("designcraft.1.log")).expect("1"), "run 3"); + assert_eq!(std::fs::read_to_string(dir.join("designcraft.2.log")).expect("2"), "run 2"); + assert!(!dir.join("designcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Error, "main", "designcraft_engine::guard", "command `frame.create` panicked: boom"), + "2026-10-08T07:59:17.728Z ERROR [main] designcraft_engine::guard: command `frame.create` panicked: boom\n" + ); + // A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + let record = |level, target: &'static str, msg: &'static str| { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + }; + record(log::Level::Info, "designcraft::prefs", "settings directory"); + record(log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(log::Level::Debug, "designcraft", "below info"); + let text = read(&path); + assert!(text.contains(" INFO [") && text.contains("designcraft::prefs: settings directory\n"), "{text}"); + assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}"); + assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("designcraft_render").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + use log::Log; + let dir = temp_dir("unusable"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); + let _ = std::fs::remove_file(&dir); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index a5cb13fb8..fa413e3ac 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -41,8 +42,9 @@ impl eframe::App for App { } } -fn prefs_path() -> Option { - let base = if cfg!(target_os = "macos") { +/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here. +fn config_dir() -> Option { + if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) } else if cfg!(windows) { std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft")) @@ -51,8 +53,11 @@ fn prefs_path() -> Option { .map(std::path::PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) .map(|c| c.join("designcraft")) - }; - base.map(|b| b.join("ui.json")) + } +} + +fn prefs_path() -> Option { + config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { @@ -182,14 +187,28 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +/// The control port from `--control ` or `DESIGNCRAFT_CONTROL_PORT` (`source`); a value that +/// is not a port is logged and ignored instead of silently starting no control channel. +fn control_port_from(source: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{source}: {value:?} is not a port number; the control channel is off"); + } + port +} + fn main() -> eframe::Result { - let mut control_port: Option = std::env::var("DESIGNCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + // First, so every start-up record (and the engine's panic hook, installed with the first + // Session) is captured; see `logging`. + let logger = logging::install(); + let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok()); let mut files = Vec::new(); let mut sample = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", args.next()), "--sample" => sample = true, "--version" => { println!("designcraft {}", env!("CARGO_PKG_VERSION")); @@ -198,6 +217,15 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // The log file lives under the settings directory; opened after the arguments, so `--version` + // leaves no file behind. Records logged until now are written to it first. + if let (Some(logger), Some(dir)) = (logger, config_dir()) { + match logger.attach_dir(&dir.join("logs")) { + Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + } + } let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("DesignCraft") @@ -260,6 +288,16 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { + #[test] + fn control_port_values_that_are_not_ports_are_ignored() { + assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979)); + assert_eq!(super::control_port_from("--control", Some(" 7979 ".into())), Some(7979)); + assert_eq!(super::control_port_from("--control", None), None); + for bad in ["", "abc", "-1", "65536", "99999999999999999999"] { + assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}"); + } + } + #[test] fn data_merge_open_dialog_lists_table_extensions() { let filters = super::open_filters("dataMerge"); From 67d4539df775dd0f4a17e0faad62438b87bcd47c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:50:43 +1100 Subject: [PATCH 05/18] Distribute selected table rows evenly and keep the edit undoable. (#224) The rows share one fixed height that preserves their composed total. Merged rows and rows that are not laid out are refused, and the table stays unchanged. --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 165 +++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 21 ++++ crates/ui-egui/src/panels/table.rs | 8 ++ 4 files changed, 195 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 588240138..506408a9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,6 +93,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 3bb188b49..6836d1bf8 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -203,6 +203,15 @@ fn table_specs() -> Vec { cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), + cmd!( + "table.distributeRows", + "Distribute Rows Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", + in_table, + distribute_rows + ), cmd!( "table.options", "Table Options", @@ -743,6 +752,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result { }) } +fn distribute_rows(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.distributeRows"; + if !p.is_object() { + return Err(bad(ID, "parameters must be an object")); + } + if let Some(rows) = p.get("rows") { + let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); + if !valid { + return Err(bad(ID, "rows must be two non-negative row indices")); + } + } + for key in ["row", "story", "table"] { + if let Some(v) = p.get(key) + && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + { + return Err(bad(ID, format!("{key} must be a non-negative integer"))); + } + } + let g = target(s, p, ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad(ID, "unmerge cells that span rows first")); + } + let cs = s.cache.get(&st.doc, g.story, None); + let mut total = 0.0; + for row in g.range.r0..=g.range.r1 { + let height = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == g.table) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row) + .map(|c| c.rect.height()) + .reduce(f64::max) + .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; + if !height.is_finite() || height <= 0.0 { + return Err(bad(ID, "invalid composed row height")); + } + total += height; + } + let count = g.range.r1 - g.range.r0 + 1; + let height = total / count as f64; + if !height.is_finite() { + return Err(bad(ID, "invalid total row height")); + } + edit_table(s, &g, ID, |t| { + let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; + for row in rows { + row.height = height; + row.mode = RowHeightMode::Exactly; + } + Ok(json!({"rows": count, "height": height})) + }) +} + fn distribute_cols(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.distributeColumns")?; edit_table(s, &g, "table.distributeColumns", |t| { @@ -1326,6 +1392,105 @@ mod sort_tests { use crate::Session; + fn distribution_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); + } + s + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + #[test] + fn distribute_selected_rows_preserves_height_content_and_undo() { + let mut s = distribution_session(); + s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); + let before = table(&s); + let selection = s.doc().unwrap().selection.cells; + let result = s.execute("table.distributeRows", &json!({})).unwrap(); + assert_eq!(result, json!({"rows": 2, "height": 60.0})); + let after = table(&s); + assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); + assert_eq!(after.cells, before.cells); + assert_eq!(after.rows[0].kind, before.rows[0].kind); + assert_eq!(s.doc().unwrap().selection.cells, selection); + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + let heights: Vec<_> = + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); + assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table(&s), after); + } + + #[test] + fn distribution_uses_content_grown_heights_and_explicit_table() { + let mut s = distribution_session(); + s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); + s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); + let st = s.doc().unwrap(); + let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); + let cs = s.cache.get(&st.doc, sid, None); + let total: f64 = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .flat_map(|f| &f.cells) + .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) + .map(|c| c.rect.height()) + .sum(); + let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); + assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); + assert!(table(&s).rows[1].height > 3.0); + assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); + } + + #[test] + fn distribution_keeps_column_spans_and_caret() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + let before = table(&s); + let caret = s.doc().unwrap().selection.text; + let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); + assert_eq!(result["height"], 60.0); + assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); + assert_eq!(table(&s).cells, before.cells); + assert_eq!(s.doc().unwrap().selection.text, caret); + } + + #[test] + fn distribution_rejects_merges_and_missing_rows_without_changes() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); + assert_eq!(table(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); + assert_eq!(table(&s), before); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); + for params in + [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] + { + assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); + assert_eq!(table(&s), before); + } + } + #[test] fn sort_body_rows_by_a_column() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 43795f61a..793276b6c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -576,6 +576,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.splitHorizontally", "cmd:table.splitVertically", "cmd:table.distributeColumns", + "cmd:table.distributeRows", "-", "ui:app.tablePanel", ], @@ -2705,6 +2706,25 @@ mod tests { assert!(quick_apply_items(&s, "preferences").iter().any(|i| i.id == "app.preferences")); } + #[test] + fn distribute_rows_menu_dispatches_an_undoable_edit() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("table.setRowHeight", json!({"row": 0, "height": 40, "mode": "exactly"})).unwrap(); + app.run("table.setRowHeight", json!({"row": 1, "height": 80, "mode": "exactly"})).unwrap(); + app.run("table.selectTable", json!({})).unwrap(); + activate(&mut app, "table.distributeRows", &Value::Null); + assert!(app.ui.dialog.is_none()); + let info = app.run("table.get", json!({})).unwrap(); + assert_eq!(info["rows"][0]["height"], 60.0); + assert_eq!(info["rows"][1]["height"], 60.0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("table.get", json!({})).unwrap()["rows"][0]["height"], 40.0); + } + #[test] fn every_menu_entry_is_a_command() { let mut all = Vec::new(); @@ -2712,6 +2732,7 @@ mod tests { walk(&items, &mut all); } assert!(all.len() > 80, "{}", all.len()); + assert!(all.iter().any(|(label, id, _)| label == "Distribute Rows Evenly" && id == "table.distributeRows")); for (label, id, params) in &all { assert!(ui_label(id).is_some() || designcraft_engine::find_command(id).is_some(), "menu entry {label}: unknown command {id}"); assert!(!label.is_empty() && label != id, "menu entry {id} has no label"); diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 7a1cb3aae..dd319e667 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -139,6 +139,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.end_row(); }); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Distribute Rows Evenly"))) + .on_hover_text("Equal fixed heights for the selected rows; cell text may become overset.") + .clicked() + && let Err(e) = app.run("table.distributeRows", json!({})) + { + app.status(format!("Table: {e}")); + } divider(ui); caption(ui, crate::i18n::tr(&app.ui.language, "Cell Text")); ui.horizontal(|ui| { From cfac46a8d9971b28d00faa3e48755b3581b0f8c0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:50:47 -0600 Subject: [PATCH 06/18] FID1: prevent mixed gradient color spaces from aborting PDF export (#229) --- ROADMAP.md | 2 + crates/pdf/src/export.rs | 29 ++++- crates/pdf/src/lib.rs | 2 + crates/pdf/tests/gradient_color_spaces.rs | 145 ++++++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/tests/gradient_color_spaces.rs diff --git a/ROADMAP.md b/ROADMAP.md index 506408a9d..c3608b9b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. + - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e5ebb6765..72b378a97 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -941,22 +941,43 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { + let kind = g.kind; + let expanded = g.expanded_stops(); + // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even + // when every authored stop is CMYK/Gray; PDF/A can also change the output space. + let components = |c: &Color| match c { + Color::Cmyk { .. } if !self.rgb_only => 4, + Color::Gray { .. } => 1, + _ => 3, + }; + let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first))); + if mixed { + self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)")); + } let mut stops: Vec = Vec::new(); let mut last = 0.0f32; - for (o, c, a) in g.expanded_stops() { + for (o, c, a) in expanded { let o = o.clamp(last, 1.0); last = o; - stops.push(Stop { offset: norm(o), color: device(&c, self.rgb_only), opacity: norm(a) }); + let color = if mixed && !matches!(c, Color::Rgb { .. }) { + let [r, g, b] = c.to_rgb(); + rgb::Color::new(q(r), q(g), q(b)).into() + } else { + // Preserve RGB values, including already-interpolated display RGB, and + // leave every homogeneous gradient on its existing device-space path. + device(&c, self.rgb_only) + }; + stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); } if stops.is_empty() { return None; } let c = bounds.center(); let v = fill.gradient_vector; - return Some(match g.kind { + return Some(match kind { GradientKind::Radial => { let (c, r) = match v { Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b03d8dbe7..080bb5981 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -12,6 +12,8 @@ //! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their //! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]` //! as `/Separation /All`. +//! Gradients with mixed output colour spaces are converted to RGB with an export warning; +//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion). //! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded //! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). //! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs new file mode 100644 index 000000000..c523a4187 --- /dev/null +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -0,0 +1,145 @@ +//! Synthetic regressions for gradients that previously aborted inside the PDF writer. +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Rect, shapes}; +use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report}; +use hayro_syntax::{ + Pdf, + object::{Array, Dict, Name}, +}; + +fn document(colors: &[Color], kind: GradientKind, midpoint: f32, opacity: f32) -> Document { + let mut doc = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() }); + let stops = colors + .iter() + .enumerate() + .map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint }) + .collect(); + doc.swatches.push(Swatch { + name: "Mixed inks".into(), + value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, + locked: false, + named: true, + hidden: false, + }); + // Two uses of the same swatch must not duplicate the warning. + for x in [10.0, 65.0] { + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 10.0, x + 40.0, 100.0))); + item.fill = Fill::swatch("Mixed inks"); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +fn conversion_warnings(warnings: &[String]) -> usize { + warnings.iter().filter(|w| w.contains("Mixed inks") && w.contains("converted to RGB")).count() +} + +/// A subprocess is essential: the original assertion panicked again in Surface::drop and aborted. +#[test] +fn mixed_gradient_export_survives_in_subprocess() { + const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(out.status.success(), "gradient export child failed: {}\n{}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stderr).contains("gradient matrix completed"), "child must execute the regression matrix"); + return; + } + let rgb = Color::rgb(1.0, 0.0, 0.0); + let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); + let gray = Color::gray(0.4); + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { + let d = document(&colors, kind, 0.5, 0.7); + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert_eq!(Pdf::new(report.bytes.clone()).unwrap().pages().len(), 1); + let archival_rgb = standard == Standard::PdfA2b && !colors.iter().any(|c| matches!(c, Color::Gray { .. })); + assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_rgb), "{standard:?}: {:?}", report.warnings); + if standard == Standard::PdfX4 { + assert!( + report.warnings.iter().any(|w| w.starts_with("PDF/X-4:") && w.contains("RGB")), + "RGB conformance warning must survive: {:?}", + report.warnings + ); + } + if standard == Standard::PdfA2b { + assert!( + report.warnings.iter().any(|w| w.contains("PDF/A") && w.contains("RGB")), + "archival policy warning must survive: {:?}", + report.warnings + ); + } + } + } + } + // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { + let report = + export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) + .unwrap(); + let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); + assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); + } + } + } + eprintln!("gradient matrix completed"); +} + +fn endpoint(function: &Dict<'_>, first: bool) -> Vec { + if let Some(functions) = function.get::>(b"Functions") { + let functions: Vec<_> = functions.iter::>().collect(); + return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first); + } + function.get::>(if first { b"C0" } else { b"C1" }).unwrap().iter::().collect() +} + +#[test] +fn homogeneous_gradients_keep_device_spaces_and_components() { + for (colors, space, start, end) in [ + ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), + ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), + ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + ] { + for standard in [Standard::None, Standard::PdfX4] { + let report = export_pdf_with_report( + &document(&colors, GradientKind::Linear, 0.5, 1.0), + &Cache::new(), + &PdfOptions { standard, ..Default::default() }, + ) + .unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 0); + let pdf = Pdf::new(report.bytes).unwrap(); + let patterns = &pdf.pages()[0].resources().patterns; + let mut count = 0; + for (name, _) in patterns.entries() { + let pattern = patterns.get::>(name.as_ref()).unwrap(); + let shading = pattern.get::>(b"Shading").unwrap(); + assert_eq!(shading.get::>(b"ColorSpace").unwrap().as_ref(), space.as_bytes()); + let function = shading.get::>(b"Function").unwrap(); + for (actual, expected) in [(endpoint(&function, true), &start), (endpoint(&function, false), &end)] { + assert_eq!(actual.len(), expected.len()); + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.005), "{space}: {actual:?} vs {expected:?}"); + } + count += 1; + } + assert!(count > 0, "inspect actual shading resources, not unrelated blend spaces"); + } + } +} + +#[test] +fn mixed_gradient_booklet_reports_conversion() { + let d = document(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0)], GradientKind::Linear, 0.5, 1.0); + let report = designcraft_pdf::export_booklet(&d, &Cache::new(), &Default::default()).unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 1); + assert!(!Pdf::new(report.bytes).unwrap().pages().is_empty()); +} From 452134c13b8de5329eb649bcda208d5d20f668ce Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:50:52 -0600 Subject: [PATCH 07/18] FID2: preserve decoration color resets, geometry and paint order (#230) --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 12 +- crates/engine/tests/text_decorations.rs | 165 ++++++++++++++++++++++++ crates/idml/src/export.rs | 4 +- crates/idml/src/import.rs | 4 +- crates/idml/src/tests.rs | 2 + crates/idml/src/tests/decorations.rs | 83 ++++++++++++ crates/pdf/src/text.rs | 48 +++++-- crates/render/src/text.rs | 39 ++++-- 9 files changed, 326 insertions(+), 32 deletions(-) create mode 100644 crates/engine/tests/text_decorations.rs create mode 100644 crates/idml/src/tests/decorations.rs diff --git a/ROADMAP.md b/ROADMAP.md index c3608b9b6..b678d059f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3db5070d5..b67bb1ee8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -71,7 +71,7 @@ pub struct RunStyle { pub kenten_character: String, } -/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = +/// An underline or strikethrough bar: its centre `offset` below the baseline (negative = /// above), thickness and colour. #[derive(Clone, Debug, PartialEq)] pub struct Rule { @@ -81,13 +81,21 @@ pub struct Rule { pub tint: f32, } +impl Rule { + /// The stroke is centred on the offset, matching Underline / Strikethrough Options. + pub fn rect(&self, x0: f64, x1: f64, baseline: f64) -> Rect { + let center = baseline + self.offset; + Rect::new(x0, center - self.weight / 2.0, x1, center + self.weight / 2.0) + } +} + impl RunStyle { /// The bars this run draws between `x0` and `x1` on a line at `baseline`. pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator { [(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)] .into_iter() .filter(|(on, _)| *on) - .map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight))) + .map(move |(_, r)| (r, r.rect(x0, x1, baseline))) } } diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs new file mode 100644 index 000000000..cd3388f76 --- /dev/null +++ b/crates/engine/tests/text_decorations.rs @@ -0,0 +1,165 @@ +//! The same synthetic overlapping runs must paint correctly on screen and in exported PDF. +use designcraft_color::{Color, Swatch}; +use designcraft_compose::{Cache, compose_story}; +use designcraft_doc::{CharAttrs, Document, ParaFormat, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect}; +use designcraft_pdf::{PdfOptions, export_pdf}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +fn document(decoration: u8, hide_first: bool, hide_second: bool) -> Document { + let mut d = Document::new(&NewDocument { width: 240.0, height: 240.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Cyan rule", Color::rgb(0.0, 1.0, 1.0))); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(0.0, 0.0, 240.0, 180.0), + d.default_layer(), + "HI", + ParaFormat { + chars: CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(72.0), fill: Some("[Black]".into()), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.skew = Some(45.0); + if hide_first { + f.over.fill = Some("[None]".into()); + } + }); + d.story_mut(sid).unwrap().format_chars(1..2, |f| { + if hide_second { + f.over.fill = Some("[None]".into()); + } + f.over.underline = Some(decoration == 1 || decoration == 3); + f.over.strikethrough = Some(decoration == 2); + f.over.underline_color = Some(if decoration == 3 { "[None]" } else { "Cyan rule" }.into()); + f.over.strikethrough_color = Some("Cyan rule".into()); + f.over.underline_weight = Some(Some(18.0)); + f.over.strikethrough_weight = Some(Some(18.0)); + f.over.underline_offset = Some(Some(-24.0)); + // Strike offsets are positive above the baseline in the document model. + f.over.strikethrough_offset = Some(Some(24.0)); + }); + d +} + +fn raster(d: &Document, threads: u16) -> Rendered { + let cache = Cache::new(); + let mut renderer = Renderer::new(); + renderer.threads = threads; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order"); + first +} + +fn pdf(d: &Document) -> Rendered { + let bytes = export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap(); + let image = designcraft_render::render_pdf_page(&bytes, 0, 480).unwrap(); + assert_eq!((image.width(), image.height()), (480, 480)); + // hayro renders on transparent paper; composite onto the same white backdrop as the canvas. + let pixels = image + .data() + .iter() + .flat_map(|p| { + let white = 255 - p.a; + [p.r.saturating_add(white), p.g.saturating_add(white), p.b.saturating_add(white), 255] + }) + .collect(); + Rendered { width: 480, height: 480, pixels } +} + +fn dark(p: [u8; 4]) -> bool { + // Process black is color managed (about 52/51/52 with the bundled profile). + p[0] < 80 && p[1] < 80 && p[2] < 80 +} +fn white(p: [u8; 4]) -> bool { + p[0] > 245 && p[1] > 245 && p[2] > 245 +} +fn cyan(p: [u8; 4]) -> bool { + p[0] < 35 && p[1] > 220 && p[2] > 220 +} + +fn check_paint_order(render: impl Fn(&Document) -> Rendered) { + let plain_doc = document(0, false, false); + let plain = render(&plain_doc); + let first_only = render(&document(0, false, true)); + let second_only = render(&document(0, true, false)); + let under = render(&document(1, false, false)); + let strike = render(&document(2, false, false)); + assert_eq!(render(&document(3, false, false)).pixels, plain.pixels, "explicit None must draw no rule"); + let sid = *plain_doc.stories.keys().next().unwrap(); + let cs = compose_story(&plain_doc, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let g = line.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let rect = Rect::new(g.x, line.baseline - 24.0 - 9.0, g.x + g.adv, line.baseline - 24.0 + 9.0); + let mut cross_run = 0; + let mut exposed = 0; + for y in (rect.y0 * 2.0).ceil() as u32 + 2..(rect.y1 * 2.0).floor() as u32 - 2 { + for x in (rect.x0 * 2.0).ceil() as u32 + 2..(rect.x1 * 2.0).floor() as u32 - 2 { + // The first run's skewed H overlaps the SECOND run's rule. A per-run under/glyph + // loop would still cover these pixels, so the witness is stronger than one run. + if dark(first_only.pixel(x, y)) && white(second_only.pixel(x, y)) { + cross_run += 1; + assert!(dark(under.pixel(x, y)), "underline covered an earlier run at {x},{y}: {:?}", under.pixel(x, y)); + assert!(cyan(strike.pixel(x, y)), "strike must remain above glyphs at {x},{y}"); + } + if white(plain.pixel(x, y)) { + exposed += 1; + assert!(cyan(under.pixel(x, y)), "the visible underline must retain its fill"); + } + } + } + assert!(cross_run > 20, "fixture needs cross-run interior-ink witnesses, got {cross_run}"); + assert!(exposed > 20, "fixture needs visible rule pixels, got {exposed}"); +} + +#[test] +fn raster_underlines_are_behind_all_runs_and_strikes_remain_above() { + for threads in [0, 2] { + check_paint_order(|d| raster(d, threads)); + } +} + +#[test] +fn pdf_underlines_are_behind_all_runs_and_strikes_remain_above() { + check_paint_order(pdf); +} + +#[test] +fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { + let mut d = document(1, false, false); + let sid = *d.stories.keys().next().unwrap(); + for (automatic, size) in [(false, 72.0), (true, 28.0)] { + d.story_mut(sid).unwrap().format_chars(0..2, |f| { + f.over.size = Some(size); + f.over.underline = Some(true); + f.over.strikethrough = Some(true); + f.over.underline_color = Some(String::new()); + f.over.strikethrough_color = Some(String::new()); + f.over.fill_tint = Some(0.4); + f.over.underline_tint = Some(0.9); + f.over.strikethrough_tint = Some(0.8); + f.over.underline_weight = Some(if automatic { None } else { Some(12.0) }); + f.over.strikethrough_weight = Some(if automatic { None } else { Some(6.0) }); + f.over.underline_offset = Some(if automatic { None } else { Some(-3.0) }); + f.over.strikethrough_offset = Some(if automatic { None } else { Some(8.0) }); + }); + let cs = compose_story(&d, sid, &Default::default()); + for style in &cs.styles { + let baseline = 84.222687; + let rules: Vec<_> = style.rules(10.0, 50.0, baseline).collect(); + assert_eq!(rules.len(), 2); + let expected = if automatic { [(size * 0.12, size / 14.0), (-size * 0.3, size / 14.0)] } else { [(-3.0, 12.0), (-8.0, 6.0)] }; + for ((rule, rect), (offset, weight)) in rules.iter().zip(expected) { + assert!((rect.center().y - (baseline + offset)).abs() < 1e-9, "offset must locate the center: {rect:?}"); + assert!((rect.height() - weight).abs() < 1e-9); + assert_eq!(rule.color, style.fill); + assert_eq!(rule.tint, 0.4); + } + } + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb0..62f50d0f9 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1098,8 +1098,8 @@ impl<'a> Ex<'a> { if let Some(o) = o { el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0))); } - if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) { - el.set(&format!("{k}Color"), self.sw(c)); + if let Some(c) = c { + el.set(&format!("{k}Color"), if c.is_empty() { "Text Color".into() } else { self.sw(c) }); } if let Some(t) = t { el.set(&format!("{k}Tint"), num(t as f64 * 100.0)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..f6e711d51 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1095,7 +1095,9 @@ impl<'r> Importer<'r> { // Underline / strikethrough options (-9999 = automatic). for k in ["Underline", "StrikeThru"] { let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0); - let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty()); + // An explicit Text Color resets an inherited swatch; absence still inherits. + // Resolve the sentinel before swatch_ref, which maps unknown names to [None]. + let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) }); let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); if k == "Underline" { a.underline_weight = num("Weight").map(Some); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac1..910c89a00 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes}; use super::*; +mod decorations; + fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); diff --git a/crates/idml/src/tests/decorations.rs b/crates/idml/src/tests/decorations.rs new file mode 100644 index 000000000..338acffca --- /dev/null +++ b/crates/idml/src/tests/decorations.rs @@ -0,0 +1,83 @@ +use super::*; + +fn fixture(local: &str) -> Document { + let styles = r#" + + + ParagraphStyle/PBase + ParagraphStyle/PReset + + + + CharacterStyle/CBaseText ColorText Color + CharacterStyle/CReset + CharacterStyle/CBase + + "#; + let story = format!( + r#"Ink"# + ); + let map = r#""#; + let spread = r#""#; + import_idml(&zip_files(&[("designmap.xml", map), ("Resources/Styles.xml", styles), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])) + .unwrap() +} + +fn assert_style_resets(d: &Document) { + let p = d.styles.para("PReset").unwrap(); + let c = d.styles.char_style("CReset").unwrap(); + for attrs in [&p.chars, &c.chars] { + assert_eq!(attrs.underline_color.as_deref(), Some(""), "an explicit reset must remain a sparse value"); + assert_eq!(attrs.strikethrough_color.as_deref(), Some("")); + } + let (_, base) = d.styles.resolve_para_style("PBase"); + let (_, reset) = d.styles.resolve_para_style("PLeaf"); + assert_eq!(base.underline_color, "[Black]"); + assert_eq!(base.strikethrough_color, "[Black]"); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let reset = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CLeaf".into(), ..Default::default() }); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let inherited = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CInherit".into(), ..Default::default() }); + assert_eq!(inherited.underline_color, "[Black]"); + assert_eq!(inherited.strikethrough_color, "[Black]"); + assert!(d.styles.char_style("CInherit").unwrap().chars.underline_color.is_none()); +} + +#[test] +fn text_color_resets_paragraph_and_character_style_chains_through_roundtrip() { + let d = fixture(""); + assert_style_resets(&d); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} + +#[test] +fn local_text_color_and_explicit_none_remain_distinct_through_roundtrip() { + for (local, expected) in [ + (r#"UnderlineColor="Text Color" StrikeThruColor="Text Color""#, ""), + (r#"UnderlineColor="n" StrikeThruColor="n""#, "[None]"), + (r#"UnderlineColor="" StrikeThruColor="""#, "[None]"), + ("", "[Black]"), + ] { + let imported = fixture(local); + let reopened = import_idml(&export_idml(&imported)).unwrap(); + for d in [&imported, &reopened] { + let story = d.stories.values().next().unwrap(); + let (_, para) = d.styles.resolve_para(&story.paras[0]); + let actual = d.styles.resolve_char(¶, story.char_format_at(0)); + assert_eq!(actual.underline_color, expected); + assert_eq!(actual.strikethrough_color, expected); + } + } +} + +#[test] +fn exporting_native_text_color_reset_writes_the_sentinel() { + let mut d = fixture(""); + let attrs = &mut d.styles_mut().para_mut("PReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + let attrs = &mut d.styles_mut().char_style_mut("CReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 7fd8e3654..fb2370cb5 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -39,6 +39,28 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool { && (a.y - b.y).abs() < 1e-6 } +/// Merge bars of one paint layer; underlines and strikes must never be merged together. +fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> { + let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + for line in &ft.lines { + for g in line.glyphs.iter().filter(|g| g.visible) { + let style = &cs.styles[g.style as usize]; + let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) }; + if !on { + continue; + } + let rect = rule.rect(g.x, g.x + g.adv, line.baseline); + match bars.last_mut() { + Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => { + last.x1 = rect.x1; + } + _ => bars.push((rule.clone(), rect)), + } + } + } + bars +} + impl Exporter<'_> { pub(crate) fn font(&mut self, face: &FontFace) -> Option { self.fonts @@ -108,7 +130,17 @@ impl Exporter<'_> { me.tables(s, ft); } }); - let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + // All underlines precede all glyph runs, including ink overlapping from another run. + let underlines = text_rules(cs, ft, true); + if !underlines.is_empty() { + self.as_artifact(s, |me, s| { + for (rule, r) in underlines { + if let Some(c) = me.swatch_color(&rule.color, rule.tint) { + me.fill_rect(s, r, c); + } + } + }); + } for l in &ft.lines { let gs = &l.glyphs; let mut i = 0; @@ -135,21 +167,9 @@ impl Exporter<'_> { self.tagged_run(s, cs, &gs[i..j], l.baseline, story); i = j; } - // Underline / strikethrough, merged per style along the line. - for g in gs.iter().filter(|g| g.visible) { - let st = &cs.styles[g.style as usize]; - for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) { - match deco.last_mut() { - Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => { - last.x1 = r.x1 - } - _ => deco.push((rule.clone(), r)), - } - } - } } self.as_artifact(s, |me, s| { - for (rule, r) in deco { + for (rule, r) in text_rules(cs, ft, false) { if let Some(c) = me.swatch_color(&rule.color, rule.tint) { me.fill_rect(s, r, c); } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb7..854681642 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs { pub bounds: Option, /// Glyph outlines batched per run style. pub runs: Vec<(u32, BezPath)>, - /// Underline / strikethrough bars: colour swatch, tint, rectangle. - pub decos: Vec<(String, f32, Rect)>, + /// Bars below/above the glyph paint pass: colour swatch, tint, rectangle. + pub underlines: Vec<(String, f32, Rect)>, + pub strikes: Vec<(String, f32, Rect)>, pub glyphs: usize, } @@ -116,15 +117,18 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut decos = Vec::new(); + let mut underlines = Vec::new(); + let mut strikes = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { continue; } let style = &cs.styles[g.style as usize]; - for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) { - decos.push((rule.color.clone(), rule.tint, r)); + for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { + if on { + bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline))); + } } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { @@ -155,10 +159,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); } - for (_, _, r) in &decos { + for (_, _, r) in underlines.iter().chain(&strikes) { bounds = union(bounds, *r); } - LineGlyphs { bounds, runs, decos, glyphs } + LineGlyphs { bounds, runs, underlines, strikes, glyphs } } fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs { @@ -339,12 +343,19 @@ impl Renderer { doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) }) }; - for i in shown { - let lg = &fg.lines[i]; - let Some(b) = lg.bounds else { continue }; - if !rect_overlaps(b, vis) { - continue; + shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis))); + // Paint all underlines before any glyphs: skewed runs and tightly spaced lines + // can overlap a later run's rule. A per-run paint pass would still cover ink. + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].underlines { + if let Some(c) = doc.resolve_color(sw, *tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(r); + } } + } + for &i in &shown { + let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { if let Some(c) = fill_of(*si) { @@ -374,7 +385,9 @@ impl Renderer { ctx.stroke_path(bp); } } - for (sw, tint, r) in &lg.decos { + } + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].strikes { if let Some(c) = doc.resolve_color(sw, *tint) { ctx.set_paint(color_of(&c, 1.0)); ctx.fill_rect(r); From fb68b7cc2d7f3b0257ef775108cb550d2e680103 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 23:50:58 -0600 Subject: [PATCH 08/18] M5: rebind embedded image links when opening relocated packages (#235) (#244) Reuse packaged-link candidates for embedded IDML images and native files. Resolve missing original paths beside the opened document, retaining the embedded image bytes and any original link that still exists. Bound path lookup allocations and cover actual package relocation through commands. Make existing data-merge test path assertions portable to Windows. Co-authored-by: Codex --- crates/engine/src/cmd/datamerge/tests.rs | 5 +- crates/engine/src/cmd/file.rs | 1 + crates/engine/src/cmd/interchange.rs | 88 ++++++++++++++++++++++-- crates/engine/src/cmd/package.rs | 25 +++++++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 6153a1d61..64f2f5d08 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -714,7 +714,8 @@ fn relative_path_and_reopen() { std::fs::create_dir_all(&b).unwrap(); let csv = b.join("data.csv"); write(&csv, "Name\nAda\n"); - assert_eq!(relative_between(&a, &csv), "../b/data.csv"); + let relative = Path::new("..").join("b").join("data.csv").to_string_lossy().into_owned(); + assert_eq!(relative_between(&a, &csv), relative); assert_eq!(relative_between(&b, &csv), "data.csv"); let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); @@ -743,7 +744,7 @@ fn relative_path_and_reopen() { let mut s2 = Session::new(); exec(&mut s2, "file.open", json!({"path": saved})); let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0]; - assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv")); + assert_eq!(src.relative_path.as_deref(), Some(relative.as_str())); assert_eq!(src.rows[0][0], "Ada"); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 267ea5d88..d475cafc5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -164,6 +164,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result { } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let mut d = from_bytes(&bytes)?; + super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); let (fonts, faces, warnings) = load_document_fonts(&mut d, path); let mut st = DocState::new(d, Some(path.to_string())); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 030cf1b6c..8a65ec913 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -59,13 +59,22 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { std::sync::Arc::make_mut(asset).link = Some(path); } } + resolve_packaged_links(&mut document, dir); document }; Ok(document) } #[cfg(not(target_arch = "wasm32"))] -fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { +const MAX_LINK_PATH_BYTES: usize = 128 * 1024; + +#[cfg(not(target_arch = "wasm32"))] +fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec { + // Enough for a maximum-length Windows path even with four-byte UTF-8 characters. + // Bound splitting/allocation for link strings supplied by imported documents. + if link.len() > MAX_LINK_PATH_BYTES { + return Vec::new(); + } 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. @@ -87,7 +96,29 @@ fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(Stri candidates.push(dir.join("Links").join(name)); } } - for path in candidates { + candidates +} + +/// Embedded images do not call the IDML resource loader. Rebind their missing links +/// beside the opened document too, without replacing their embedded image bytes. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) { + let Some(dir) = dir else { return }; + for asset in document.assets.values_mut() { + let Some(link) = asset.link.as_deref() else { continue }; + if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() { + continue; + } + let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue }; + // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. + let path = std::path::absolute(&path).unwrap_or(path); + std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { + for path in packaged_link_candidates(link, dir) { 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. @@ -132,7 +163,7 @@ mod tests { use super::*; - fn linked_idml(link: &str) -> Result<(Vec, Vec)> { + fn linked_idml(link: &str, embed_images: bool) -> Result<(Vec, Vec)> { let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png(); let mut session = Session::new(); session.execute("file.new", &json!({}))?; @@ -142,7 +173,7 @@ mod tests { 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 }); + let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images }); Ok((bytes, png)) } @@ -151,7 +182,7 @@ mod tests { 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(); + let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap(); @@ -169,7 +200,7 @@ mod tests { 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(); + let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); let doc = import(&idml, Some(&dir)).unwrap(); @@ -179,4 +210,49 @@ mod tests { assert_eq!(super::super::links::status(asset), "ok"); std::fs::remove_dir_all(dir).unwrap(); } + + #[test] + fn embedded_link_resolution_preserves_originals_and_image_bytes() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-embedded-links-{}-{nonce}", std::process::id())); + let original = dir.join("original").join("logo.png"); + let package = dir.join("package"); + let copy = package.join("Links").join("logo.png"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + std::fs::create_dir_all(copy.parent().unwrap()).unwrap(); + let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap(); + std::fs::write(&original, b"modified original").unwrap(); + std::fs::write(©, &png).unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative"); + assert_eq!(*asset.data, png); + assert_eq!(super::super::links::status(asset), "modified"); + + std::fs::remove_file(&original).unwrap(); + std::fs::write(©, b"modified packaged copy").unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(asset.link.as_deref(), copy.to_str()); + assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels"); + assert_eq!(super::super::links::status(asset), "modified"); + + std::fs::remove_file(©).unwrap(); + for folder in [Some(package.as_path()), None] { + let doc = import(&idml, folder).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate"); + assert_eq!(*asset.data, png); + assert_eq!(super::super::links::status(asset), "missing"); + } + let oversized = "a/".repeat(MAX_LINK_PATH_BYTES); + assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty()); + let mut doc = import(&idml, None).unwrap(); + Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone()); + resolve_packaged_links(&mut doc, Some(&package)); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(asset.link.as_deref(), Some(oversized.as_str())); + assert_eq!(*asset.data, png); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 5cc4cc860..50556d97f 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -221,6 +221,31 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn relocated_packages_rebind_embedded_image_links_on_open() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); + let original = dir.join("original"); + let moved = dir.join("moved"); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + std::fs::rename(&original, &moved).unwrap(); + let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); + for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { + let mut opened = Session::new(); + opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); + let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels"); + assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); + let links = opened.execute("links.list", &json!({})).unwrap(); + assert_eq!(links[0]["status"], "ok", "{extension}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn package_writes_document_links_and_report() { let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id())); From 4d16a8ce3da6614fb4a97df045ffe8404ab7973a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:03 -0400 Subject: [PATCH 09/18] IDML: layers import and export in stacking order; font names with a format suffix (#247) * IDML: layers import and export in stacking order * Fonts: a family named with a format suffix such as (OTF) resolves to the installed family --- crates/engine/src/cmd/fonts.rs | 35 ++++++++++++++++++++++++++ crates/fonts/src/fontdb.rs | 46 +++++++++++++++++++++++++++------- crates/fonts/src/lib.rs | 12 +++++++++ crates/idml/src/export.rs | 3 ++- crates/idml/src/import.rs | 2 ++ crates/idml/src/tests.rs | 35 ++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb9313..3b173c199 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -190,4 +190,39 @@ mod tests { assert_eq!(r["changed"], 1); assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str())); } + + /// InDesign names a family installed in several formats `Family (OTF)`; the plain family + /// stands for it, and a family that isn't available under either name is still missing. + #[test] + fn a_family_with_a_format_suffix_uses_the_plain_family() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 15})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4 (OTF)", "fontStyle": "Bold"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + let entry = l.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4 (OTF)").unwrap(); + assert_eq!((&entry["missing"], &entry["styleMissing"], &entry["source"]), (&json!(false), &json!(false), &json!("bundled")), "{l}"); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let faces: Vec<_> = + cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0).map(|g| g.face.get()).collect(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.family == "Source Serif 4" && f.style == "Bold")); + let kinds = |s: &mut Session| { + s.execute("preflight.run", &json!({})).unwrap()["issues"] + .as_array() + .unwrap() + .iter() + .map(|i| i["kind"].as_str().unwrap().to_string()) + .collect::>() + }; + assert!(!kinds(&mut s).iter().any(|k| k == "missingFont")); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Nope (OTF)"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}"); + assert!(kinds(&mut s).iter().any(|k| k == "missingFont")); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 4177f941c..14441e144 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -712,6 +712,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// `family` without the font-format suffix layout apps append when a family is installed in +/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. +fn without_format_suffix(family: &str) -> Option<&str> { + const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"]; + let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?; + let base = base.trim_end(); + (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() @@ -1218,6 +1227,14 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + let v = self.exact_styles(family); + match without_format_suffix(family) { + Some(base) if v.is_empty() => self.exact_styles(base), + _ => v, + } + } + + fn exact_styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1237,15 +1254,10 @@ impl ScopedFonts<'_> { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A + /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it. pub fn face(&self, family: &str, style: &str) -> Arc { - if let Some(f) = self.find(family, style) { - return f; - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.load_cataloged(family) - && let Some(f) = self.find(family, style) - { + if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) { return f; } self.find(FALLBACK_FAMILY, style) @@ -1254,8 +1266,24 @@ impl ScopedFonts<'_> { .unwrap_or_else(last_resort_face) } - /// Is `family` available (the document's, loaded, or installed on the system)? + fn find_or_load(&self, family: &str, style: &str) -> Option> { + if let Some(f) = self.find(family, style) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.load_cataloged(family) { + return self.find(family, style); + } + None + } + + /// Is `family` available (the document's, loaded, or installed on the system), as named or + /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { + self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) + } + + fn has_exact_family(&self, family: &str) -> bool { if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { return true; } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8f901e96d..c1d744780 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -425,6 +425,18 @@ mod tests { assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings"); } + #[test] + fn an_exact_family_name_wins_over_the_name_without_its_format_suffix() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + db.add_font(testing::font_with("DC Test Suffix (OTF)", &['a']).unwrap()); + assert_eq!(db.face("DC Test Suffix (OTF)", "Regular").family, "DC Test Suffix (OTF)"); + assert_eq!(db.face(&format!("{DEFAULT_FAMILY} (TT)"), "Regular").family, DEFAULT_FAMILY); + assert!(db.has_family(&format!("{DEFAULT_FAMILY} (T1)"))); + assert!(!db.has_family(&format!("{DEFAULT_FAMILY} (Bold)"))); + assert!(!db.has_family("DC Test Suffix")); + } + #[test] fn outlines_and_metrics() { let db = FontDb::global(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 62f50d0f9..09fa22f39 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -346,7 +346,8 @@ impl<'a> Ex<'a> { let n = format!("TV {name}"); root.push(El::new("TextVariable").attr("Self", format!("dTextVariablen{n}")).attr("Name", &n).attr("VariableType", ty)); } - for l in &d.layers { + // IDML lists layers back to front. + for l in d.layers.iter().rev() { let color = match names::layer_color_out(l.color) { Some(n) => p("LayerColor", "enumeration", n), None => { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f6e711d51..a958e6594 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -356,6 +356,8 @@ impl<'r> Importer<'r> { self.layer_ids.insert(s.to_string(), id); } } + // IDML lists layers back to front; `Document::layers` is front to back. + self.layers.reverse(); if self.layers.is_empty() { let id = LayerId(self.alloc()); self.layers.push(Layer { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 910c89a00..e6308e897 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -859,3 +859,38 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +/// InDesign's designmap lists layers back to front; `Document::layers[0]` is the front layer. +#[test] +fn layers_import_and_export_in_stacking_order() { + let designmap = r#" + + + + "#; + let spread = r#" + + + + + + + + + + "#; + let d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread)])).unwrap(); + let names = |d: &Document| d.layers.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(&d), ["Content", "Background"]); + assert_eq!(d.spreads[0].items[0].layer, d.layers[1].id, "the rectangle stays on Background"); + + let bytes = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut map = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("designmap.xml").unwrap(), &mut map).unwrap(); + let (bg, content) = (map.find(r#"Name="Background""#).unwrap(), map.find(r#"Name="Content""#).unwrap()); + assert!(bg < content, "designmap lists the back layer first"); + let back = import_idml(&bytes).unwrap(); + assert_eq!(names(&back), ["Content", "Background"]); + assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); +} From a42fa206774a704cd9420aaa6218fcd21c66f3fc Mon Sep 17 00:00:00 2001 From: Sawizzle <156952830+sawizzle@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:51:07 -0700 Subject: [PATCH 10/18] docs: register the MCP server from an installed release (#269) * docs: register the MCP server from an installed release Co-Authored-By: Claude Opus 5.5 * docs: drop the third-party link; the Windows path is the default install folder Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/mcp.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index f53c56767..772f8a91f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,6 +24,23 @@ Other MCP clients use the same command line, e.g. in a JSON config: {"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}} ``` +### From an installed release + +The release packages ship `designcraft-cli` alongside the desktop app, so no build is needed: + +| Install | CLI | +|---|---| +| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Linux (deb, rpm) | `/usr/bin/designcraft-cli` | +| macOS | the separate `designcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | + +```sh +# Windows, default install folder +claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp +# Linux, or macOS with the CLI unzipped onto PATH +claude mcp add designcraft -- designcraft-cli mcp +``` + ## Modes | | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) | From c984624956ae03eb6a3bb45a6bf48277959bd919 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 10 Oct 2026 07:51:12 +0200 Subject: [PATCH 11/18] Make menu entries selectable across the full row (#249) Co-authored-by: moppeeer <339764117+moppeeer@users.noreply.github.com> --- crates/ui-egui/src/menus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 793276b6c..c372f1efb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1804,7 +1804,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if crate::i18n::is_rtl(&lang) { ui.set_max_width(320.0); } - ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { + ui.with_layout(egui::Layout::top_down_justified(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { ui.set_min_width(240.0); let hidden = menu_items(app, ui, &entries, menu); if hidden > 0 && !app.ui.show_full_menus { From 3de58750cd0aa0d766470a4b6c46d4afa15b4a1e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:51:17 +1100 Subject: [PATCH 12/18] Add PDF/X-4 validation command (#200) --- apps/designcraft-cli/src/main.rs | 12 ++++++++++++ crates/pdf/src/lib.rs | 8 +++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 11514f709..b1819c357 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -12,6 +12,7 @@ //! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document +//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -84,6 +85,7 @@ fn main() -> ExitCode { Some("mcp") => report(mcp(&args[1..])), Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), + Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))), Some("links") => { use designcraft_engine::links::*; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -114,6 +116,16 @@ fn report(r: Result<(), String>) -> ExitCode { } } +fn validate_pdf(path: Option<&str>) -> Result<(), String> { + let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let issues = designcraft_pdf::check_pdfx4(&bytes); + let valid = issues.is_empty(); + let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues}); + outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?); + if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) } +} + /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 080bb5981..d2fbba7ba 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -20,9 +20,8 @@ //! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the //! right characters), so text is selectable and searchable. //! - **Marks:** crop marks, bleed marks and a page-information line, drawn in `[Registration]`. -//! - **Standards:** PDF/A-2b through krilla's validator. PDF/X-4 writes PDF 1.6 with trim/bleed -//! boxes, but the PDF/X output intent and identification are not written yet (krilla 0.8 has no -//! PDF/X support) — the export report says so. +//! - **Standards:** PDF/A-2b through krilla's validator and PDF/X-4 output intent/identification +//! with built-in conformance checks. `designcraft-cli validate-pdf` exposes those checks for CI. //! //! Not yet: tagged PDF, bookmarks, hyperlinks, overprint, layers as optional content. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -46,8 +45,7 @@ pub enum Standard { /// Plain PDF 1.7. #[default] None, - /// PDF/X-4 (PDF 1.6, trim/bleed boxes). TODO: OutputIntent + GTS_PDFXVersion once krilla - /// supports PDF/X. + /// PDF/X-4 (PDF 1.6, trim/bleed boxes, output intent and identification). PdfX4, /// PDF/A-2b (archival; validated by krilla). PdfA2b, From b744953d70f795c2ae48de497d17cf0d23ed9460 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:21 -0400 Subject: [PATCH 13/18] Span columns: text after a spanning paragraph flows below it (#276) A spanning paragraph closes the band of columns above it. Those columns are balanced first and the paragraph sits below the deepest of them; the columns after it start below the paragraph, so text that moves to the next column no longer restarts at the frame top over the heading. A spanning paragraph that doesn't fit moves to the next frame. Span counts below the column count set the paragraph across the first columns only. --- crates/compose/src/lib.rs | 318 ++++++++++++++++++++++++++++++++---- crates/compose/src/table.rs | 2 +- crates/compose/src/tests.rs | 156 ++++++++++++++++++ 3 files changed, 439 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b67bb1ee8..5283487a7 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod xref; use std::collections::HashMap; use std::ops::Range; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use designcraft_doc::{ @@ -470,7 +471,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .zip(&cols) .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) .collect(); - let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 }; + let mut cur = Cursor { + fi: 0, + col: 0, + last_baseline: None, + last_descent: 0.0, + last_reference: 0.0, + pending: 0.0, + pi: 0, + band: Band::default(), + limits: Rc::default(), + }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); let hyph_exceptions = doc.hyphenation_exception_map(); @@ -504,14 +515,33 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search + // in progress, and a budget for the re-lays. + let mut limits: Rc> = Rc::default(); + let mut trial: Option = None; + let mut balance_runs = 0usize; + let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count(); + // Paragraphs set across columns (vertical justification moves their frames' lines together). + let mut span_paras = vec![false; np]; 'paras: while pi < np { let prange = para_ranges[pi].clone(); + cur.pi = pi; + cur.limits = Rc::clone(&limits); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); note_snaps.truncate(pi); note_snaps.push((notes.num, notes.key, notes.placed.len())); info.truncate(pi); + // A balancing trial whose band ran past its frame: re-lay the band with more room. + if trial.as_ref().is_some_and(|t| cur.fi > t.key.0) + && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) + { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); if let Some(t) = story.para_table(pi) { @@ -528,6 +558,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let start_at = (cur.fi, cur.col); let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0); if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { + if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } out.overset_at = Some(prange.start); break 'paras; } @@ -611,7 +647,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. let cols_here = &cols[cur.fi]; - let col = cols_here[cur.col.min(cols_here.len() - 1)]; + let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { @@ -702,17 +738,63 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C designcraft_doc::Leading::Points(v) => v, }; let spacing = spacing_for(&pp, base_size); + // A paragraph set across the columns moves on to the next frame where others move on to + // the next column. + let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); + span_paras[pi] = spanning; + let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) }; // Paragraph start options. if force_col[pi] { - cur.next_column(&cols); + next_column(&mut cur); } if cur.last_baseline.is_some() { match pp.start_paragraph { - StartParagraph::NextColumn => cur.next_column(&cols), + StartParagraph::NextColumn => next_column(&mut cur), StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(), StartParagraph::Anywhere => {} } } + if spanning && cur.fi < frames.len() { + // Close the band: the paragraph goes below the deepest of its columns, which are + // balanced first. + let key = cur.band_key(); + let deepest = out.frames[cur.fi] + .lines + .get(cur.band.line0..) + .unwrap_or_default() + .iter() + .map(|l| (l.baseline, l.descent)) + .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1))); + if let Some((baseline, descent)) = deepest { + let bottom = baseline + descent; + let multi = cols[cur.fi].len() > 1; + let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi); + if !active && let Some(t) = trial.take() { + set_limit(&mut limits, t.key, None); + } + if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) { + let top = cur.clip(cols[cur.fi][0]).y0; + let mut t = trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false }); + // This layout fits: its bottom is the best so far. + t.hi = t.hi.min(bottom); + let next = if t.done { t.hi } else { t.next() }; + set_limit(&mut limits, key, Some(next)); + if !t.done { + trial = Some(t); + balance_runs += 1; + rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = key.1; + continue 'paras; + } + } + cur.col = 0; + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } else if cur.col != 0 { + cur.col = 0; + cur.resume(); + } + } if cur.last_baseline.is_some() { cur.pending += pp.space_before; } @@ -727,21 +809,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span loop { if cur.fi >= frames.len() { + if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); // The part of the paragraph that fits keeps its shading and border. para_box_decos(&mut out, pi, &pp); break 'paras; } let f = &frames[cur.fi]; - let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; - let col = match pp.span_columns { - SpanColumns::Span(n) if cur.col == 0 => { - let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) }; - let other = cols[cur.fi][n - 1]; - Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.x1), col.y1) - } - _ => col, - }; + let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col = cur.clip(base); + // A spanning paragraph isn't held to the balanced bottom of the columns above it. + let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; // Estimate slots for the breaker with the paragraph's base leading. let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); @@ -817,7 +900,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C remaining: breaks.len() - k, here: (cur.fi, cur.col), }; - let action = if restores < 4 * np + 64 { keep_violation(&ctx, &pp, &info, &force_col, &line_cap) } else { None }; + let action = if restores < (4 * np + 64) * (balance_runs + 1) { + keep_violation(&ctx, &pp, &info, &force_col, &line_cap) + } else { + None + }; if let Some(action) = action { restores += 1; let j = match action { @@ -830,20 +917,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - for k in j + 1..np { - force_col[k] = false; - line_cap[k] = None; - } - snaps[j].restore(&mut out, &mut cur, &mut list_counter); - (notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1); - notes.placed.truncate(note_snaps[j].2); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); pi = j; continue 'paras; } } // Next column / frame; re-break the rest of the paragraph there. g0 = s; - cur.next_column(&cols); + next_column(&mut cur); col_first_line = line_no; moved = true; break; @@ -905,7 +986,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); let jumped = match brk { Some(story::COLUMN_BREAK) => { - cur.next_column(&cols); + next_column(&mut cur); true } Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => { @@ -941,6 +1022,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_first: pp.keep_first as usize, keep_last: pp.keep_last as usize, }); + // The columns below a spanning paragraph start under it. + if spanning + && let Some(ft) = out.frames.get(cur.fi) + && let Some(l) = ft.lines.last().filter(|l| l.para == pi) + { + let above = Above { + top: l.baseline + l.descent, + baseline: l.baseline, + descent: l.descent, + reference: cur.last_reference, + pending: pp.space_after, + }; + cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) }; + } // Rules and shading for the paragraph. if let Some((fi, bl, asc, _)) = first_line_rect { let ft = &mut out.frames[fi]; @@ -980,7 +1075,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C ft.range = p..p; } let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); - vertical_justify(ft, f); + let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)); + vertical_justify(ft, f, spanned); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -1049,6 +1145,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out } +/// The columns a spanning paragraph is set across, from `col`'s top to its bottom. +fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect { + let SpanColumns::Span(n) = span else { return col }; + let n = if n == 0 { cols.len() } else { (n as usize).min(cols.len()) }; + let (Some(a), Some(b)) = (cols.first(), n.checked_sub(1).and_then(|i| cols.get(i))) else { return col }; + Rect::new(a.x0.min(b.x0), col.y0, a.x1.max(b.x1), col.y1) +} + /// Flag the lines of paragraphs whose Keep Options the layout couldn't honour: lines kept /// together that split, too few lines at the bottom (orphans) or top (widows) of a column, or a /// keep-with-next paragraph separated from the next one. @@ -1244,6 +1348,31 @@ impl Snapshot { } } +/// Re-lay from paragraph `j`: back to its snapshot and footnote numbering, with fresh keep +/// decisions for the paragraphs after it. +#[allow(clippy::too_many_arguments)] +fn rewind( + j: usize, + snaps: &[Snapshot], + note_snaps: &[(u32, Option, usize)], + notes: &mut Notes, + out: &mut ComposedStory, + cur: &mut Cursor, + list_counter: &mut u32, + force_col: &mut [bool], + line_cap: &mut [Option], +) { + force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); + line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + if let Some(s) = snaps.get(j) { + s.restore(out, cur, list_counter); + } + if let Some(&(num, key, placed)) = note_snaps.get(j) { + (notes.num, notes.key) = (num, key); + notes.placed.truncate(placed); + } +} + /// Where a placed paragraph sits (for keep resolution). #[derive(Clone, Debug)] struct ParaInfo { @@ -1364,23 +1493,136 @@ struct Cursor { last_reference: f64, /// Space before/after waiting to be added to the next line. pending: f64, + /// The paragraph being laid. + pi: usize, + /// The band of columns the cursor is in. + band: Band, + /// Column bottoms that balance bands, by band key (kept by the compose loop). + limits: Rc>, +} + +/// A band's frame and first paragraph. +type BandKey = (usize, usize); + +/// A run of the frame's columns: the whole frame, or the part between spanning paragraphs. +/// Text fills its columns one after the other; a spanning paragraph closes it. +#[derive(Clone, Copy, Debug, Default)] +struct Band { + /// First paragraph laid (at least in part) in the band: balancing re-lays from there. + para: usize, + /// Lines of the frame above the band. + line0: usize, + /// Below a spanning paragraph, its last line: every column of the band continues from it. + above: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Above { + /// Bottom of the spanning paragraph's last line (the band's top). + top: f64, + baseline: f64, + descent: f64, + reference: f64, + /// The spanning paragraph's space after. + pending: f64, +} + +/// Balancing the columns above a spanning paragraph: a search for the lowest column bottom at +/// which the band's text still fits its frame, re-laying the band at each trial bottom. +#[derive(Debug)] +struct Trial { + key: BandKey, + /// The spanning paragraph that closes the band. + span: usize, + /// Highest bottom known not to fit / lowest known to fit. + lo: f64, + hi: f64, + tries: u32, + /// The band is laid at `hi` for good. + done: bool, +} + +impl Trial { + /// The next bottom to try: half way, or `hi` once settled. + fn next(&mut self) -> f64 { + self.tries += 1; + if self.hi - self.lo > 0.5 && self.tries < 16 { + (self.lo + self.hi) / 2.0 + } else { + self.done = true; + self.hi + } + } +} + +/// A balancing trial whose band didn't fit its frame: records the next bottom to try in +/// `limits` (or, when even the settled bottom fails or the budget is spent, leaves the band +/// unbalanced) and returns the paragraph to re-lay from. +fn trial_failed(trial: &mut Option, limits: &mut Rc>, budget_left: bool) -> Option { + let t = trial.as_mut()?; + let key = t.key; + let bottom = if t.done || !budget_left { + *trial = None; + f64::INFINITY + } else { + t.lo = limit_of(limits, key).unwrap_or(t.lo); + t.next() + }; + set_limit(limits, key, Some(bottom)); + Some(key.1) +} + +fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option) { + let v = Rc::make_mut(limits); + v.retain(|(k, _)| *k != key); + if let Some(b) = bottom { + v.push((key, b)); + } +} + +fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option { + limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b) } impl Cursor { fn next_column(&mut self, cols: &[Vec]) { self.col += 1; if self.fi < cols.len() && self.col >= cols[self.fi].len() { - self.col = 0; - self.fi += 1; + self.next_frame(); + } else { + self.resume(); } - self.last_baseline = None; - self.pending = 0.0; } fn next_frame(&mut self) { self.fi += 1; self.col = 0; - self.last_baseline = None; - self.pending = 0.0; + self.band = Band { para: self.pi, ..Band::default() }; + self.resume(); + } + /// Start the current column at the top of the band. + fn resume(&mut self) { + match self.band.above { + Some(a) => { + self.last_baseline = Some(a.baseline); + self.last_descent = a.descent; + self.last_reference = a.reference; + self.pending = a.pending; + } + None => { + self.last_baseline = None; + self.pending = 0.0; + } + } + } + fn band_key(&self) -> BandKey { + (self.fi, self.band.para) + } + /// Column `col` of the current frame limited to the band: below the spanning paragraph above + /// it and, while balancing, above the trial bottom. + fn clip(&self, col: Rect) -> Rect { + let y0 = self.band.above.map_or(col.y0, |a| a.top.max(col.y0).min(col.y1.max(col.y0))); + let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0)); + Rect::new(col.x0, y0, col.x1, y1) } /// Baseline for the next line with leading `lead` and ascent `asc`. fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { @@ -2122,16 +2364,20 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: out } -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) { +/// `spanned`: a paragraph spans the frame's columns, so the lines move together. +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } - // Per column. - let ncols = ft.columns.len().max(1) as u32; + if spanned && f.opts.vertical_justification == VerticalJustification::Justify { + return; + } + // Per column (or all lines together). + let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 }; for c in 0..ncols { - let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect(); - let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue }; - let bottom = ft.lines[z].baseline + ft.lines[z].descent; + let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); + let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; + let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); let space = f.area.y1 - bottom; if space <= 0.0 { continue; diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 529c9b777..f1632992e 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -124,7 +124,7 @@ pub(crate) fn place_table( if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { return false; } - let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col_rect = |cur: &Cursor| cur.clip(cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]); let mut col = col_rect(cur); // Column widths: scaled to fit the measure. let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 256d9a13c..19a446f76 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,3 +1889,159 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +// ---------- span columns ---------- + +/// A story of `before` body paragraphs, a heading spanning all columns, and `after` body +/// paragraphs, in a three-column frame. Returns the heading's paragraph index. +fn span_doc(before: usize, after: usize, rect: Rect) -> (Document, StoryId, ItemId, usize) { + let mut paras: Vec<&str> = vec![LOREM; before]; + paras.push("A heading across the columns"); + paras.extend(vec![LOREM; after]); + let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.story_mut(sid).unwrap().paras[before].para.span_columns = Some(SpanColumns::Span(0)); + (d, sid, fid, before) +} + +/// The line's leading slot (glyph ascent and descent may reach into the neighbouring lines). +fn line_box(l: &Line) -> (f64, f64) { + (l.baseline - 0.75 * l.leading, l.baseline + 0.25 * l.leading) +} + +/// No two lines of a frame share any area. +fn assert_no_overlap(ft: &FrameText) { + for (i, a) in ft.lines.iter().enumerate() { + for b in &ft.lines[i + 1..] { + let ((at, ab), (bt, bb)) = (line_box(a), line_box(b)); + let x_overlap = a.x0 < b.x1 - 0.5 && b.x0 < a.x1 - 0.5; + let y_overlap = at < bb - 0.5 && bt < ab - 0.5; + assert!( + !(x_overlap && y_overlap), + "lines overlap: para {} col {} y {:.1}..{:.1} and para {} col {} y {:.1}..{:.1}", + a.para, + a.column, + at, + ab, + b.para, + b.column, + bt, + bb + ); + } + } +} + +/// The heading spans the frame; text before it sits above it and text after it below it. +fn assert_span_layout(ft: &FrameText, h: usize) { + let heading: Vec<&Line> = ft.lines.iter().filter(|l| l.para == h).collect(); + assert!(!heading.is_empty()); + let (c0, cn) = (ft.columns[0], ft.columns[ft.columns.len() - 1]); + for l in &heading { + assert!((l.x0 - c0.x0.min(cn.x0)).abs() < 1e-6 && (l.x1 - c0.x1.max(cn.x1)).abs() < 1e-6, "heading spans {}..{}", l.x0, l.x1); + } + let top = heading.iter().map(|l| line_box(l).0).fold(f64::INFINITY, f64::min); + let bottom = heading.iter().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + for l in ft.lines.iter().filter(|l| l.para < h) { + assert!(line_box(l).1 <= top + 0.5, "para {} col {} ends at {} below the heading top {top}", l.para, l.column, line_box(l).1); + } + for l in ft.lines.iter().filter(|l| l.para > h) { + assert!(line_box(l).0 >= bottom - 0.5, "para {} col {} starts at {} above the heading bottom {bottom}", l.para, l.column, line_box(l).0); + } + assert_no_overlap(ft); +} + +#[test] +fn text_after_a_spanning_paragraph_flows_below_it() { + let (d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + // The text before is balanced across the three columns above the heading. + let before: Vec<&Line> = ft.lines.iter().filter(|l| l.para < h).collect(); + let per_col = |c: u32| before.iter().filter(|l| l.column == c).count(); + let counts = [per_col(0), per_col(1), per_col(2)]; + assert!(counts.iter().all(|&n| n > 0), "{counts:?}"); + assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{counts:?}"); + // The text after starts right below the heading in every column, on the same baseline. + let bottom = ft.lines.iter().filter(|l| l.para == h).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max); + let firsts: Vec = (0..3).map(|c| ft.lines.iter().find(|l| l.para > h && l.column == c).map(|l| l.baseline).unwrap()).collect(); + for b in &firsts { + assert!(*b > bottom && *b < bottom + 40.0, "{firsts:?} after {bottom}"); + } + assert!((firsts[1] - firsts[2]).abs() < 1e-6, "{firsts:?}"); +} + +#[test] +fn spanning_heading_kept_with_next_and_spanning_two_of_three_columns() { + let (mut d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + d.story_mut(sid).unwrap().paras[h].para.keep_with_next = Some(2); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + // Spanning two of three columns: the third column beside the heading stays empty. + d.story_mut(sid).unwrap().paras[h].para.span_columns = Some(SpanColumns::Span(2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + let heading = ft.lines.iter().find(|l| l.para == h).unwrap(); + assert!((heading.x0 - ft.columns[0].x0).abs() < 1e-6 && (heading.x1 - ft.columns[1].x1).abs() < 1e-6); + assert_no_overlap(ft); + let bottom = heading.baseline + 0.25 * heading.leading; + assert!(ft.lines.iter().filter(|l| l.para > h).all(|l| line_box(l).0 >= bottom - 0.5)); + assert!((0..3).all(|c| ft.lines.iter().any(|l| l.para > h && l.column == c))); +} + +#[test] +fn spanning_paragraph_at_the_top_and_end_of_a_frame() { + let (d, sid, _, h) = span_doc(0, 4, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + assert!(ft.lines[0].para == h && ft.lines[0].baseline < 20.0); + assert!((1..3).all(|c| ft.lines.iter().any(|l| l.column == c))); + + let (d, sid, _, h) = span_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); +} + +#[test] +fn text_after_a_spanning_paragraph_threads_into_the_next_frame() { + let (mut d, sid, f1, h) = span_doc(2, 8, Rect::new(0.0, 0.0, 540.0, 200.0)); + let lid = d.default_layer(); + let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 540.0, 700.0), lid, "", ParaFormat::default()).unwrap(); + d.item_mut(f2).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.thread(f1, f2).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at); + assert_span_layout(&cs.frames[0], h); + let (a, b) = (&cs.frames[0], &cs.frames[1]); + assert!((0..3).all(|c| a.lines.iter().any(|l| l.para > h && l.column == c))); + assert!(!b.lines.is_empty() && b.lines[0].para > h && b.lines[0].baseline < 320.0); + assert_no_overlap(b); + // Every line of the story is set once, in order. + let lines = all_lines(&cs); + for w in lines.windows(2) { + assert!(w[1].range.start >= w[0].range.end, "{:?} then {:?}", w[0].range, w[1].range); + } + assert_eq!(lines.last().unwrap().range.end, d.story(sid).unwrap().text.len()); +} + +#[test] +fn spanning_paragraph_in_rtl_and_vertical_frames() { + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 500.0)); + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 540.0)); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.frames[0].lines.is_empty()); + assert_no_overlap(&cs.frames[0]); + assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); +} From e6b5a52a8553fc46b321351581995f7bb6a01a62 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:26 -0400 Subject: [PATCH 14/18] Text: the line before a page break is a paragraph's last line (#283) A line ended by a column, frame or page break takes the paragraph's last-line alignment, so a Left Justify paragraph sets it flush left. A line ended by a forced line break stays justified. The break that moves the following text to the next column or frame is the line's last glyph, so a space after the break no longer keeps the text in the same column. --- crates/compose/src/lib.rs | 13 +++++++++++-- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5283487a7..8f34d1177 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -933,8 +933,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let lx0 = x0 + ind_l; let lx1 = x1 - pp.right_indent; let last = k + 1 == breaks.len(); + // The break character that ends this line, if any (it is the line's last glyph). + let brk = if b.forced && e > s { + e.checked_sub(1).and_then(|i| glyphs.get(i)).map(|g| g.ch).filter(|&c| breaker::is_forced(c)) + } else { + None + }; + // A column, frame or page break ends the line as a paragraph's last line (last-line + // alignment); a justified line ended by a forced line break stays justified. + let ends_para = last || matches!(brk, Some(story::COLUMN_BREAK | story::FRAME_BREAK | story::PAGE_BREAK)); + let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = - layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info); + layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); @@ -983,7 +993,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C line_no += 1; // Column / frame / page break characters. if b.forced && e < glyphs.len() + 1 { - let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); let jumped = match brk { Some(story::COLUMN_BREAK) => { next_column(&mut cur); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 19a446f76..849d3a4e4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,32 @@ fn column_break_moves_following_text() { assert!(lines[1].baseline < 20.0, "second column starts at the top"); } +#[test] +fn line_before_a_break_character_is_a_last_line() { + // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the + // last-line alignment), while a forced line break inside a justified paragraph is justified. + use designcraft_doc::story::{COLUMN_BREAK, FORCED_LINE_BREAK, FRAME_BREAK, PAGE_BREAK}; + for (brk, align, justified) in [ + (COLUMN_BREAK, Align::LeftJustified, false), + (FRAME_BREAK, Align::LeftJustified, false), + (PAGE_BREAK, Align::LeftJustified, false), + (PAGE_BREAK, Align::FullyJustified, true), + (FORCED_LINE_BREAK, Align::LeftJustified, true), + ] { + // A space after the break is skipped and does not keep the break from moving the text. + let text = format!("one two three{brk} four five six"); + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, 300.0), ParaAttrs { align: Some(align), ..Default::default() }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let full = (l.end_x - l.x1).abs() < 0.6; + assert_eq!(full, justified, "{brk:?} {align:?}: line ends at {} of {}", l.end_x, l.x1); + if brk == COLUMN_BREAK { + assert_eq!(all_lines(&cs)[1].column, 1, "the text after a column break starts the next column"); + } + } +} + #[test] fn tabs_without_stops_use_default_half_inch() { let (d, sid, _) = doc_with("A\tB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); From c30340f60f210d713189cbae1378a0aa7474d717 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 10 Oct 2026 01:51:30 -0400 Subject: [PATCH 15/18] chore: bump github actions to fix Node 20 deprecation warnings (#268) --- .github/workflows/freebsd.yml | 7 ++-- .github/workflows/packaging-lint.yml | 2 +- .github/workflows/release.yml | 63 +++++++++++++--------------- .github/workflows/windows-arm64.yml | 10 ++--- 4 files changed, 38 insertions(+), 44 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 450e57347..77c030386 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,16 +27,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 300 steps: - - uses: actions/checkout@v4 - # The Japanese font tests run against craft-fonts (an optional build input; they skip without it). + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 8c1a4131c..59c0a1ceb 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index becf15f0a..634552c49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -72,18 +72,17 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -103,7 +102,7 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -131,18 +130,17 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override @@ -166,7 +164,7 @@ jobs: AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -190,10 +188,9 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -207,8 +204,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -232,7 +229,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -255,8 +252,8 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -270,7 +267,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -288,17 +285,16 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build and package in a FreeBSD VM (tar.gz) # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -323,7 +319,7 @@ jobs: bash packaging/freebsd/package.sh tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \ | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-freebsd-x86_64 path: dist/release/* @@ -339,18 +335,17 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install trunk @@ -366,7 +361,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -385,7 +380,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f2c54375d..b568d4e78 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -36,11 +36,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -51,7 +51,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: designcraft-windows-arm64 path: dist/arm64/* @@ -64,7 +64,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: designcraft-windows-arm64 path: dist From e904bd6057a49dad49f3559e2bc758afff06eb76 Mon Sep 17 00:00:00 2001 From: Dmytro <140102908+dmatviichuk@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:51:35 +0200 Subject: [PATCH 16/18] Add Ukrainian interface translation (#236) * UK1: add Ukrainian interface translation Translate existing interface catalogs and wire Ukrainian menu selection, contextual publishing labels and grammatical counters. Validate persisted selection, untouched document data, placeholder/key coverage and bundled Cyrillic glyphs. Co-Authored-By: Codex * i18n uk: translate the open and place alert titles Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ASSETS.md | 4 + ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/dialogs.rs | 38 +- crates/ui-egui/src/i18n.rs | 80 +- crates/ui-egui/src/i18n/uk.rs | 1461 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 29 +- crates/ui-egui/src/panels/interactive.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 20 +- crates/ui-egui/src/panels/table.rs | 2 +- crates/ui-egui/src/theme.rs | 28 + 11 files changed, 1649 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/i18n/uk.rs diff --git a/ASSETS.md b/ASSETS.md index f243fdb3c..66479bd7b 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below | +| `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | +| `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | +| `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` | | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) | | `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface | diff --git a/ROADMAP.md b/ROADMAP.md index b678d059f..bb517b5a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,7 +107,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `